diff --git a/.changeset/architect-await-gate.md b/.changeset/architect-await-gate.md deleted file mode 100644 index e791dc12..00000000 --- a/.changeset/architect-await-gate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@gemstack/framework': patch ---- - -Architect turns now honor agent-authored await gates (#356): an architect (or re-architect) turn that stops to ask via `showChoices()` / `showMultiSelect()` resolves through the live dashboard gate and re-prompts with the answer, instead of the question being silently swallowed into a stub fallback plan at the plan-approval gate. Headless runs are unchanged. Bounded at 5 rounds, like the build gate. diff --git a/.changeset/architect-drop-vike-nudge.md b/.changeset/architect-drop-vike-nudge.md deleted file mode 100644 index b6d8dd4f..00000000 --- a/.changeset/architect-drop-vike-nudge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@gemstack/ai-autopilot": patch ---- - -Remove the Vike nudge from the architect system prompt. The prompt no longer tells the model to "Prefer Vike as the default" or to "Default to the GemStack stack (Vike + Prisma)"; it now picks the stack that best fits the app and reasons from the objective Vike-vs-Next tradeoffs without favoring either. Nudging toward a framework in a system prompt erodes trust in the architect's rationale. diff --git a/.changeset/dashboard-run-view.md b/.changeset/dashboard-run-view.md index 7f3f07fe..a6b36903 100644 --- a/.changeset/dashboard-run-view.md +++ b/.changeset/dashboard-run-view.md @@ -2,4 +2,4 @@ "@gemstack/framework": minor --- -Add event-stream projections for the dashboard's run overview: `architectPlan`, `decisionLedger`, `loopStatus`, and `sessionInfo` derive the chosen stack + rationale, the decisions ledger, the production-grade loop status, and the live session from a `FrameworkEvent[]`. They plus `formatFrameworkEvent` are re-exported from a new browser-safe `@gemstack/framework/client` subpath (no Node imports), so the dashboard renders the rich run view — the stack/PROS-CONS card, decisions, loop status, and a human-readable event log instead of raw JSON — across the live view, past-run replay, and the relay watch view. +Add event-stream projections for the dashboard's run overview: `loopStatus` and `sessionInfo` derive the production-grade loop status and the live session from a `FrameworkEvent[]`. They plus `formatFrameworkEvent` are re-exported from a new browser-safe `@gemstack/framework/client` subpath (no Node imports), so the dashboard renders the rich run view (loop status and a human-readable event log instead of raw JSON) across the live view, past-run replay, and the relay watch view. diff --git a/.changeset/gate-refire-after-realternative.md b/.changeset/gate-refire-after-realternative.md deleted file mode 100644 index f4f78a4e..00000000 --- a/.changeset/gate-refire-after-realternative.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@gemstack/framework': patch ---- - -fix(framework): re-fire the plan-approval gate after picking an alternative (#324) - -Picking an alternative at the plan-approval gate re-architects the plan, and that fresh plan can differ a lot from the one you rejected. The gate now re-fires on the re-architected plan so you approve the plan you will actually build, not just the first one. Bounded to a few re-architect rounds so a run of alternative-picks can't loop forever. diff --git a/.changeset/interactive-choices-autopilot.md b/.changeset/interactive-choices-autopilot.md index 14997e36..4c1c59b6 100644 --- a/.changeset/interactive-choices-autopilot.md +++ b/.changeset/interactive-choices-autopilot.md @@ -2,6 +2,6 @@ '@gemstack/framework': minor --- -feat(framework): interactive plan-approval choice + autopilot in the dashboard (#304) +feat(framework): interactive choice gate + autopilot in the dashboard (#304) -The run now pauses at a plan-approval gate right after the architect decides the stack (the AWAIT point of the plan-then-AWAIT flow): the dashboard shows a "Your call" panel with "Proceed" plus each architect alternative as "Use X instead". Accept with the button or Ctrl+Enter, or leave the `[x] autopilot` countdown auto-accept the recommended plan after 10s (moving the mouse cancels it). Picking an alternative re-architects the run around it. New `requestChoice` option on `runFramework`, `choice` / `choice-resolved` events, and a `POST /choice` dashboard route; a headless run with no handler auto-accepts the recommended plan, so nothing else changes. Closes #304. +A run can now pause on a choice and wait for you: the dashboard shows a "Your call" panel with the options. Accept with the button or Ctrl+Enter, or leave the `[x] autopilot` countdown auto-accept the recommended option after 10s (moving the mouse cancels it). New `requestChoice` option on `runFramework`, `choice` / `choice-resolved` events, and a `POST /choice` dashboard route; a headless run with no handler auto-accepts the recommended option, so nothing else changes. Closes #304. diff --git a/.changeset/remove-architect-545.md b/.changeset/remove-architect-545.md new file mode 100644 index 00000000..831a8a8f --- /dev/null +++ b/.changeset/remove-architect-545.md @@ -0,0 +1,16 @@ +--- +'@gemstack/framework': minor +'@gemstack/ai-autopilot': minor +--- + +Remove the architect. A build no longer runs a turn to pick the app's stack, and no longer tells the agent what to build on: the agent reads the workspace and decides for itself, the same way it would outside The Framework. `buildPrompt` / `extendPrompt` / `scaffoldPrompt` now take just the intent and say nothing about a stack. + +Being opinionated about the stack is a hard thing to do well, and a system prompt that nudges one is worse than none at all (#545). The stack guidance we had was not designed, it accumulated. It goes rather than half-ships. + +Gone with it, because the architect was their only source: + +- the plan-approval gate ("Approve this plan?" / "Use X instead") and re-architect. The agent-authored await gates a build turn raises are unaffected: a build that stops to ask still pauses the run with Approve / Decline. +- the decisions ledger in a run, and the `DECISIONS.md` a run wrote from it. `@gemstack/ai-autopilot`'s `decisions/` module is untouched and still exported; nothing in a run feeds it now. +- the dashboard's "Stack & rationale" and "Decisions ledger" panels. Loop status, deploy, and session cards are unchanged. +- `@gemstack/ai-autopilot`: `agentArchitect`, `STACK_TRADEOFFS`, `BootstrapSteps.architect`, `BootstrapOptions.ledger`, the `architect` bootstrap event, `ArchitectPlan` / `ArchitectContext` / `ArchitectDecision` / `ArchitectAlternative`, and the `plan` field on the build / loop / deploy contexts and on `BootstrapResult`. The flow is now scope -> build -> loop -> deploy. +- `@gemstack/framework`: `driverArchitect`, `reArchitect`, `architectPrompt`, `parseArchitectPlan`, `architectPlan`, `decisionLedger`, `RunFrameworkResult.ledger`, and the `bench:architect` benchmark. diff --git a/.changeset/research-preset-button.md b/.changeset/research-preset-button.md index b7c924fe..4dfe3a25 100644 --- a/.changeset/research-preset-button.md +++ b/.changeset/research-preset-button.md @@ -2,4 +2,4 @@ '@gemstack/framework': minor --- -The [Research] preset (#331): `framework research [what]` and a [Research] button on the daemon dashboard run Rom's problem-variability review as a direct prompt via the new `runPrompt()` path, which honors await gates (showChoices/showMultiSelect) but skips the scope/architect/build scaffolding. The "what" defaults to `this PR`. +The [Research] preset (#331): `framework research [what]` and a [Research] button on the daemon dashboard run Rom's problem-variability review as a direct prompt via the new `runPrompt()` path, which honors await gates (showChoices/showMultiSelect) but skips the scope/build scaffolding. The "what" defaults to `this PR`. diff --git a/.changeset/single-select-gate-primitive.md b/.changeset/single-select-gate-primitive.md index 8cced42c..37981e79 100644 --- a/.changeset/single-select-gate-primitive.md +++ b/.changeset/single-select-gate-primitive.md @@ -4,4 +4,4 @@ feat(framework): extract a shared single-select gate primitive (`requestChoices`) (#335) -The single-select choice gate (#304) is now a reusable `requestChoices({ id, title, options, recommended })` export, the twin of `requestMultiSelect` (#332): it emits the `choice` event, parks for the pick, and falls back to the recommended option if the run is headless or aborts. The plan-approval gate builds on it, and it is the primitive the system prompt's `showChoices()` and the research preset need. No behavior change for existing runs. +The single-select choice gate (#304) is now a reusable `requestChoices({ id, title, options, recommended })` export, the twin of `requestMultiSelect` (#332): it emits the `choice` event, parks for the pick, and falls back to the recommended option if the run is headless or aborts. It is the primitive the system prompt's `showChoices()` and the research preset need. No behavior change for existing runs. diff --git a/.changeset/turn-boundary-choice-gate.md b/.changeset/turn-boundary-choice-gate.md index c8de2550..272b0fd8 100644 --- a/.changeset/turn-boundary-choice-gate.md +++ b/.changeset/turn-boundary-choice-gate.md @@ -4,4 +4,4 @@ feat(framework): turn an agent's showChoices()+AWAIT into a live gate at the turn boundary (#337) -The system prompt tells the agent to `showChoices()` and `AWAIT` at unclear-scope / alternatives points, but until now only framework-emitted gates (the plan-approval gate, multi-select) could pause a run. Now when a build turn ends by asking the user, an `await-choices` block, the framework shows the choice on the dashboard, waits for the pick, and re-prompts the agent to continue from that decision. It is the agent-authored counterpart to the plan-approval gate: a no-op when headless and when the agent just finishes instead of asking, so existing runs are unchanged. +The system prompt tells the agent to `showChoices()` and `AWAIT` at unclear-scope / alternatives points, but until now only framework-emitted gates (multi-select) could pause a run. Now when a build turn ends by asking the user, an `await-choices` block, the framework shows the choice on the dashboard, waits for the pick, and re-prompts the agent to continue from that decision. It is a no-op when headless and when the agent just finishes instead of asking, so existing runs are unchanged. diff --git a/Architecture.md b/Architecture.md index 6e89f27d..7cbc8a5b 100644 --- a/Architecture.md +++ b/Architecture.md @@ -133,9 +133,8 @@ What shipped, built up from that seed (the state layer + the loop are the moat, - **Personas** — reusable, stack-aware roles materialized into worker agents; **presets** (Vike flagship, Next.js) select the framework-specific ones by detecting the project's framework, on top of a framework-neutral core. - **Runner** — a pluggable sandbox seam (`FakeRunner` + a real `LocalRunner`; Docker/WebContainer/Flue are infra-gated adapters) where agents build and run an app. - **Surfaces** — the same run in a terminal, an in-page UI, or a detached background handle, over one replayable event stream. -- **Decisions ledger** — durable memory of rejected ideas + settled choices (round-trips `DECISIONS.md`) so a run stops re-pitching what was turned down. - **The loop** — an event-to-prompt-chain policy (a major change fires review + code-quality + security; a new UI flow fires QA + UX), gating on a `{ blockers }` verdict, with a data-driven **prompt library**. -- **Bootstrap** — the spine that sequences the above into scope -> architect -> build -> full-fledged loop -> deploy, taking an app from nothing to production-grade. +- **Bootstrap** — the spine that sequences the above into scope -> build -> full-fledged loop -> deploy, taking an app from nothing to production-grade. - **Scale mode** — a self-maintaining `CODE-OVERVIEW.md`, refreshed only on material change. - **Durability = in-process first.** Reuses `SubAgentRunStore` + resume primitives for pause/resume; a durable/queue-backed runner arrives as an optional adapter, not core. diff --git a/docs/packages/ai-autopilot.md b/docs/packages/ai-autopilot.md index c1d19cf4..3f4ed94b 100644 --- a/docs/packages/ai-autopilot.md +++ b/docs/packages/ai-autopilot.md @@ -17,7 +17,7 @@ The Supervisor (below) is the seed topology. Built up from it, the package is a - **Surfaces** — the same run in a terminal, an in-page UI, or a detached background handle, over one replayable event stream (`launchAutopilot`). - **Decisions ledger** — durable memory of rejected ideas + settled choices (round-trips `DECISIONS.md`) so a run stops re-pitching what was turned down. - **The loop** — an event-to-prompt-chain policy (a major change fires review + code-quality + security; a new UI flow fires QA + UX), gating on a `{ blockers }` verdict, with a data-driven built-in **prompt library**. -- **Bootstrap** — the spine that sequences all of the above into scope → architect → build → full-fledged loop → deploy, taking an app from nothing to production-grade. +- **Bootstrap** — the spine that sequences all of the above into scope → build → full-fledged loop → deploy, taking an app from nothing to production-grade. - **Scale mode** — a self-maintaining `CODE-OVERVIEW.md`, refreshed only on material change. See [`examples/bootstrap-quickstart`](https://github.com/gemstack-land/gemstack/tree/main/examples/bootstrap-quickstart) for all of it wired together offline. diff --git a/examples/bootstrap-quickstart/README.md b/examples/bootstrap-quickstart/README.md index 0fa870f7..88730de8 100644 --- a/examples/bootstrap-quickstart/README.md +++ b/examples/bootstrap-quickstart/README.md @@ -5,7 +5,7 @@ project's dependencies to a scaffolded, production-grade, deploy-decided app. ``` detect framework (preset) - → Bootstrap: scope → architect → build → full-fledged loop → deploy + → Bootstrap: scope → build → full-fledged loop → deploy → scale mode: CODE-OVERVIEW.md ``` @@ -26,12 +26,12 @@ ends at a live-looking URL with no credentials. - **Presets (#115)** — the framework is detected from the project deps (Vike here), so the build's workers are that framework's personas plus the shared neutral ones. -- **Bootstrap (#116)** — one scoping question, then an **architect** picks the stack - and records its choices to the **decisions ledger**, a **build** scaffolds the app +- **Bootstrap (#116)** — one scoping question, then a **build** scaffolds the app with the persona workers inside a **runner**, the **full-fledged loop** repeats the production-grade checklist until its `{ blockers }` verdict is empty, and a **deploy** is decided and shipped through the `DeployTarget` seam — here the real `cloudflareTarget` (SSR → Workers), run over a simulated `wrangler` offline. + What stack to build on is the build agent's call, not the framework's. - **Surfaces (#100/#120)** — every phase streams as narration over the generic `launchAutopilot` handle. - **Scale mode (#114)** — `CODE-OVERVIEW.md` is generated from the scaffold. @@ -40,8 +40,8 @@ ends at a live-looking URL with no credentials. The offline run above verifies the flow structurally. `src/live.ts` runs the same flow for real — a real model via `@gemstack/ai-sdk` and a real `LocalRunner` -workspace on the host filesystem, so the architect, the build workers, and the -deploy decision are all model-driven and the workers write **real files to disk**: +workspace on the host filesystem, so the build workers and the deploy decision are +all model-driven and the workers write **real files to disk**: ```bash ANTHROPIC_API_KEY=sk-... pnpm --filter @gemstack/example-bootstrap-quickstart start:live diff --git a/examples/bootstrap-quickstart/package.json b/examples/bootstrap-quickstart/package.json index 02b0dbca..dd4fadd6 100644 --- a/examples/bootstrap-quickstart/package.json +++ b/examples/bootstrap-quickstart/package.json @@ -2,7 +2,7 @@ "name": "@gemstack/example-bootstrap-quickstart", "version": "0.0.8", "private": true, - "description": "Runnable capstone for @gemstack/ai-autopilot: preset detection + Bootstrap (scope → architect → build → full-fledged loop → deploy) + scale mode, streamed over surfaces, offline via AiFake + FakeRunner.", + "description": "Runnable capstone for @gemstack/ai-autopilot: preset detection + Bootstrap (scope → build → full-fledged loop → deploy) + scale mode, streamed over surfaces, offline via AiFake + FakeRunner.", "type": "module", "scripts": { "typecheck": "tsc --noEmit", diff --git a/examples/bootstrap-quickstart/src/bootstrap.test.ts b/examples/bootstrap-quickstart/src/bootstrap.test.ts index a1b62dae..5912b07a 100644 --- a/examples/bootstrap-quickstart/src/bootstrap.test.ts +++ b/examples/bootstrap-quickstart/src/bootstrap.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { runCapstone, INTENT } from './bootstrap.js' describe('bootstrap capstone: the whole epic composes end-to-end (offline)', () => { - it('detects the preset, runs scope → architect → build → loop → deploy, and maps the code', async () => { + it('detects the preset, runs scope → build → loop → deploy, and maps the code', async () => { const lines: string[] = [] const { detection, result, events, files, overview } = await runCapstone(line => lines.push(line)) @@ -11,10 +11,6 @@ describe('bootstrap capstone: the whole epic composes end-to-end (offline)', () assert.equal(detection.preset?.name, 'vike') assert.equal(detection.framework, 'Vike') - // Architect: chose the stack and recorded its choices to the ledger. - assert.match(result.plan.stack, /Vike \+ Prisma/) - assert.equal(result.plan.decisions.length, 2) - // Build: each preset persona wrote its file into the sandbox. assert.ok('database/schema.ts' in files) assert.ok('pages/orders/+Page.jsx' in files) diff --git a/examples/bootstrap-quickstart/src/bootstrap.ts b/examples/bootstrap-quickstart/src/bootstrap.ts index d338c68c..0d3e917d 100644 --- a/examples/bootstrap-quickstart/src/bootstrap.ts +++ b/examples/bootstrap-quickstart/src/bootstrap.ts @@ -1,7 +1,6 @@ import { AiFake, agent, type ToolCall } from '@gemstack/ai-sdk' import { Bootstrap, - agentArchitect, supervisorBuild, loopChecklist, loopImprove, @@ -15,7 +14,6 @@ import { builtinPresetRegistry, presetPersonas, CodeOverviewMaintainer, - DecisionLedger, FakeRunner, runnerTools, launchAutopilot, @@ -31,18 +29,17 @@ import { * The capstone: the whole AI-framework epic in one offline flow. * * detect framework (preset) → Bootstrap - * scope → architect → build → full-fledged loop → deploy + * scope → build → full-fledged loop → deploy * → scale mode (CODE-OVERVIEW.md) * * A **preset** is picked from the project's dependencies, so the build's workers * are the right framework's personas. **Bootstrap** then sequences the flow: it - * asks one scoping question, has an **architect** choose the stack and record its - * choices to the **decisions ledger**, **builds** the app with the persona workers - * inside a **runner** sandbox, runs the **full-fledged loop** until the - * production-grade checklist's `{ blockers }` verdict is empty, and **decides a - * deploy** behind the `DeployTarget` seam. Every phase streams as narration over - * the generic **surface**. Finally **scale mode** generates `CODE-OVERVIEW.md` - * from the scaffold. + * asks one scoping question, **builds** the app with the persona workers inside a + * **runner** sandbox, runs the **full-fledged loop** until the production-grade + * checklist's `{ blockers }` verdict is empty, and **decides a deploy** behind the + * `DeployTarget` seam. What stack to build on is the build agent's call, not ours. + * Every phase streams as narration over the generic **surface**. Finally **scale + * mode** generates `CODE-OVERVIEW.md` from the scaffold. * * It runs offline: `AiFake` scripts the model and `FakeRunner` is an in-memory * sandbox, so there is no API key and the output is deterministic. `live.ts` runs @@ -78,16 +75,6 @@ const WORK = [ }, ] as const -/** The architect's structured decision (what `agentArchitect` parses). */ -const ARCHITECT_PLAN = { - stack: 'Vike + Prisma on Postgres, with vike-auth', - narration: 'Server-rendered orders app: Vike pages, a Prisma data layer, sessions via vike-auth.', - decisions: [ - { choice: 'Prisma on Postgres', why: 'the orders catalog is relational and needs typed queries' }, - { choice: 'SSR over SPA', why: 'orders need per-request data and auth on the server' }, - ], -} - /** The deploy decision (what `agentDeploy` parses). SSR → Cloudflare Workers. */ const DEPLOY_DECISION = { render: 'ssr', target: 'cloudflare', reason: 'per-request orders data + server-side auth' } @@ -95,9 +82,9 @@ const DEPLOY_DECISION = { render: 'ssr', target: 'cloudflare', reason: 'per-requ const DEPLOY_URL = 'https://orders-app.gemstack.workers.dev' /** - * Script the fake provider. Order (concurrency 1) is: the architect's plan, then - * each build worker's (write-file tool call, final text) pair, then the deploy - * decision. The full-fledged loop uses scripted local prompts, not the model. + * Script the fake provider. Order (concurrency 1) is: each build worker's + * (write-file tool call, final text) pair, then the deploy decision. The + * full-fledged loop uses scripted local prompts, not the model. */ function scriptModel(fake: AiFake): void { const workerSteps = WORK.flatMap((w, i) => { @@ -106,11 +93,7 @@ function scriptModel(fake: AiFake): void { ] return [{ toolCalls }, { text: `Wrote ${w.file}` }] }) - fake.respondWithSequence([ - { text: JSON.stringify(ARCHITECT_PLAN) }, - ...workerSteps, - { text: JSON.stringify(DEPLOY_DECISION) }, - ]) + fake.respondWithSequence([...workerSteps, { text: JSON.stringify(DEPLOY_DECISION) }]) } /** A static planner: the build subtasks, in the order the fake scripts them. */ @@ -160,8 +143,6 @@ export function formatBootstrapEvent(event: BootstrapEvent): string { switch (event.type) { case 'scope': return `▶ scope: ${event.scope} — "${event.intent}"` - case 'architect': - return `▶ architect: ${event.stack}\n${event.decisions.map(d => ` · ${d.choice} — ${d.why}`).join('\n')}` case 'narrate': return ` ${event.message}` case 'build': @@ -204,7 +185,6 @@ export async function runCapstone(write: (line: string) => void = () => {}): Pro const session = await runner.boot({ files: { 'package.json': JSON.stringify({ name: 'orders-app' }) + '\n' } }) const loop = buildLoop() - const ledger = new DecisionLedger() // The real Cloudflare adapter, run over the simulated wrangler above. A fake // token lets it proceed offline; the live capstone passes a real one. const deployTarget = cloudflareTarget({ @@ -216,14 +196,12 @@ export async function runCapstone(write: (line: string) => void = () => {}): Pro // Surfaces: run bootstrap detached; the terminal prints as events stream. const handle = launchAutopilot(onEvent => new Bootstrap({ - ledger, onEvent: e => { write(formatBootstrapEvent(e)) onEvent(e) }, steps: { scope: () => ({ scope: 'full', intent: INTENT }), - architect: agentArchitect(agent({ instructions: 'architect' })), build: supervisorBuild({ plan: staticPlanner, workers: presetWorkers(session, personas), concurrency: 1 }), checklist: loopChecklist({ loop }), improve: loopImprove({ loop }), diff --git a/examples/bootstrap-quickstart/src/live.ts b/examples/bootstrap-quickstart/src/live.ts index afecc39d..c932f912 100644 --- a/examples/bootstrap-quickstart/src/live.ts +++ b/examples/bootstrap-quickstart/src/live.ts @@ -1,7 +1,6 @@ import { AiRegistry, AnthropicProvider, agent } from '@gemstack/ai-sdk' import { Bootstrap, - agentArchitect, supervisorBuild, loopChecklist, loopImprove, @@ -15,7 +14,6 @@ import { builtinPresetRegistry, presetPersonas, CodeOverviewMaintainer, - DecisionLedger, LocalRunner, runnerTools, launchAutopilot, @@ -29,10 +27,10 @@ import { INTENT, formatBootstrapEvent, type CapstoneResult } from './bootstrap.j /** * The LIVE half of the capstone (#124): the same flow as `bootstrap.ts`, but with * the fakes swapped for real infra — a real model via `@gemstack/ai-sdk` and a real - * `LocalRunner` sandbox on the host filesystem. The architect, the build workers, - * and the deploy decision are all model-driven; the workers write REAL files into a - * real temp workspace. This is the honest "zero to a scaffolded app" proof that the - * offline run (AiFake + FakeRunner) can only assert structurally. + * `LocalRunner` sandbox on the host filesystem. The build workers and the deploy + * decision are all model-driven; the workers write REAL files into a real temp + * workspace. This is the honest "zero to a scaffolded app" proof that the offline + * run (AiFake + FakeRunner) can only assert structurally. * * Scoped for a first, bounded, cheap proof: the production-grade loop keeps the same * scripted verdict as the offline example (blocks once on "no auth", then clears), @@ -139,18 +137,15 @@ export async function runLiveCapstone(write: (line: string) => void = () => {}): try { const loop = buildLoop() - const ledger = new DecisionLedger() const handle = launchAutopilot(onEvent => new Bootstrap({ - ledger, onEvent: e => { write(formatBootstrapEvent(e)) onEvent(e) }, steps: { scope: () => ({ scope: 'full', intent: INTENT }), - architect: agentArchitect(agent({ model: MODEL, instructions: 'architect' })), build: supervisorBuild({ plan: livePlanner, workers: presetWorkers(session, personas), concurrency: 1 }), checklist: loopChecklist({ loop }), improve: loopImprove({ loop }), diff --git a/examples/bootstrap-quickstart/src/main-live.ts b/examples/bootstrap-quickstart/src/main-live.ts index ad916201..e5dc94a3 100644 --- a/examples/bootstrap-quickstart/src/main-live.ts +++ b/examples/bootstrap-quickstart/src/main-live.ts @@ -18,7 +18,6 @@ async function main(): Promise { console.log(` scope: ${result.scope}`) console.log(` production-grade: ${result.productionGrade} (after ${result.passes} checklist pass(es))`) console.log(` deploy: ${result.deploy?.plan.render.toUpperCase()} → ${result.deploy?.plan.target}`) - console.log(` decisions recorded: ${result.plan.decisions.length}`) console.log('\n--- scale mode: CODE-OVERVIEW.md ---') console.log(` ${overview?.summary}`) diff --git a/examples/bootstrap-quickstart/src/main.ts b/examples/bootstrap-quickstart/src/main.ts index f2ae6793..2c9ec707 100644 --- a/examples/bootstrap-quickstart/src/main.ts +++ b/examples/bootstrap-quickstart/src/main.ts @@ -16,7 +16,6 @@ async function main(): Promise { console.log(` scope: ${result.scope}`) console.log(` production-grade: ${result.productionGrade} (after ${result.passes} checklist pass(es))`) console.log(` deploy: ${result.deploy?.plan.render.toUpperCase()} → ${result.deploy?.plan.target}, url ${result.deploy?.result.url}`) - console.log(` decisions recorded: ${result.plan.decisions.length}`) console.log('\n--- scale mode: CODE-OVERVIEW.md ---') console.log(` ${overview?.summary}`) diff --git a/examples/framework-demo/README.md b/examples/framework-demo/README.md index 994b3f75..a3fe243f 100644 --- a/examples/framework-demo/README.md +++ b/examples/framework-demo/README.md @@ -5,8 +5,8 @@ deterministic, in a couple of seconds. This runs the real product (`@gemstack/framework`'s `runFramework`) with the built-in **fake driver**: no Claude Code, no model, no API keys. Same code a live -run executes — preset detection, architect decisions, the full-fledged -production-grade loop, deploy — just with scripted agent turns so it is instant +run executes — preset detection, the full-fledged production-grade loop, +deploy — just with scripted agent turns so it is instant and repeatable. Two things are genuinely real, not narrated: - **the app boots and serves** — the serve gate starts a real HTTP server and the @@ -29,10 +29,6 @@ Prompt: "A paginated orders page backed by an orders table, with sign-in." ◆ fake in /tmp/framework-demo-xxxx Detected Vike (confidence 2); framing with 3 persona(s) ▶ scope: full — "A paginated orders page backed by an orders table, with sign-in." -▶ architect: Vike + universal-orm on Postgres, with vike-auth - · universal-orm on Postgres — the orders catalog is relational and needs typed queries - · SSR over SPA — orders need per-request data and auth on the server - Building on Vike + universal-orm on Postgres, with vike-auth Checking the app is production-grade serve: start: node server.js serve: fetch http://localhost:50106/ -> 200 @@ -68,5 +64,5 @@ npx @gemstack/framework "a paginated orders page with sign-in" ``` That opens the localhost dashboard, wraps Claude Code as the coding agent, and runs -the identical scope → architect → build → production-grade loop → deploy — writing +the identical scope → build → production-grade loop → deploy — writing real files and, with `--serve`, leaving the real app running behind a preview link. diff --git a/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts b/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts index 3db2a1dd..a53fd4a5 100644 --- a/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts +++ b/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts @@ -1,7 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { Bootstrap, createBootstrap, BootstrapAborted } from './bootstrap.js' -import { DecisionLedger } from '../decisions/ledger.js' import type { BootstrapEvent, BootstrapSteps, ScopeAnswer } from './types.js' import type { SupervisorRun } from '../types.js' import type { Verdict } from '../loop/verdict.js' @@ -13,11 +12,6 @@ const fakeRun: SupervisorRun = { text: 'built', plan: [], results: [], usage: ze function stubSteps(over: Partial = {}): BootstrapSteps { return { scope: () => ({ scope: 'full', intent: 'a shop' }) satisfies ScopeAnswer, - architect: () => ({ - stack: 'Vike + universal-orm', - narration: 'Server-rendered shop with a Postgres data layer', - decisions: [{ choice: 'Use Vike for SSR', why: 'SEO + fast first paint' }], - }), build: () => fakeRun, checklist: () => ({ blockers: [] }) satisfies Verdict, ...over, @@ -25,76 +19,38 @@ function stubSteps(over: Partial = {}): BootstrapSteps { } describe('Bootstrap — happy path (full scope, passes first checklist)', () => { - it('sequences scope → architect → build → loop and returns a production-grade result', async () => { + it('sequences scope → build → loop and returns a production-grade result', async () => { const events: BootstrapEvent[] = [] const boot = new Bootstrap({ steps: stubSteps(), onEvent: e => events.push(e) }) const result = await boot.run() assert.equal(result.scope, 'full') assert.equal(result.intent, 'a shop') - assert.equal(result.plan.stack, 'Vike + universal-orm') assert.equal(result.run, fakeRun) assert.equal(result.passes, 1) assert.deepEqual(result.blockers, []) assert.equal(result.productionGrade, true) assert.equal(result.stoppedEarly, false) - // narration order: scope, architect, its narration, build narration, loop, checklist, done + // narration order: scope, loop narration, checklist, done const types = events.map(e => e.type) - assert.deepEqual(types, ['scope', 'architect', 'narrate', 'narrate', 'narrate', 'checklist', 'done']) + assert.deepEqual(types, ['scope', 'narrate', 'checklist', 'done']) const scoped = events[0] assert.ok(scoped?.type === 'scope' && scoped.scope === 'full') }) - it('records the architect decisions to the ledger', async () => { - const ledger = new DecisionLedger() - const boot = new Bootstrap({ steps: stubSteps(), ledger }) - await boot.run() - assert.equal(boot.decisions, ledger) - assert.equal(ledger.size, 1) - assert.equal(ledger.all()[0]?.status, 'accepted') - assert.match(ledger.all()[0]!.title, /Vike for SSR/) - }) - - it('emits the stack rationale (pros/cons/alternatives) and records alternatives as ledger rejections', async () => { - const events: BootstrapEvent[] = [] - const ledger = new DecisionLedger() + it('hands the scope and intent to the build step', async () => { + let seen: { scope: string; intent: string } | undefined const boot = new Bootstrap({ steps: stubSteps({ - architect: () => ({ - stack: 'Vike + universal-orm', - narration: 'n', - decisions: [{ choice: 'Use Vike for SSR', why: 'SEO' }], - pros: ['edge deploy'], - cons: ['smaller ecosystem'], - alternatives: [{ option: 'Next.js', whyNot: 'constrained edge deploy' }], - }), + build: ({ scope, intent }) => { + seen = { scope, intent } + return fakeRun + }, }), - ledger, - onEvent: e => events.push(e), }) await boot.run() - - const arch = events.find(e => e.type === 'architect') - assert.ok(arch?.type === 'architect') - assert.deepEqual(arch.pros, ['edge deploy']) - assert.deepEqual(arch.cons, ['smaller ecosystem']) - assert.deepEqual(arch.alternatives, [{ option: 'Next.js', whyNot: 'constrained edge deploy' }]) - - // The rejected alternative is in the ledger, honestly marked rejected. - const rejected = ledger.all().find(d => d.status === 'rejected') - assert.ok(rejected, 'expected a rejected alternative in the ledger') - assert.match(rejected!.title, /Next\.js/) - }) - - it('omits rationale fields on the architect event when the plan has none', async () => { - const events: BootstrapEvent[] = [] - const boot = new Bootstrap({ steps: stubSteps(), onEvent: e => events.push(e) }) - await boot.run() - const arch = events.find(e => e.type === 'architect') - assert.ok(arch?.type === 'architect') - assert.equal('pros' in arch, false) - assert.equal('alternatives' in arch, false) + assert.deepEqual(seen, { scope: 'full', intent: 'a shop' }) }) }) @@ -230,9 +186,9 @@ describe('Bootstrap — interrupt + isolation', () => { const boot = new Bootstrap({ signal: controller.signal, steps: stubSteps({ - architect: () => { - controller.abort() // user interrupts during the architect phase - return { stack: 'x', narration: '', decisions: [] } + build: () => { + controller.abort() // user interrupts during the build phase + return fakeRun }, }), }) @@ -267,7 +223,7 @@ describe('Bootstrap — construction', () => { // @ts-expect-error missing steps assert.throws(() => new Bootstrap({}), /requires `steps`/) // @ts-expect-error missing build - assert.throws(() => new Bootstrap({ steps: { scope: () => ({ scope: 'full', intent: '' }), architect: () => ({ stack: '', narration: '', decisions: [] }) } }), /`build` step/) + assert.throws(() => new Bootstrap({ steps: { scope: () => ({ scope: 'full', intent: '' }) } }), /`build` step/) assert.throws(() => new Bootstrap({ steps: stubSteps(), maxPasses: 0 }), /maxPasses/) }) }) diff --git a/packages/ai-autopilot/src/bootstrap/bootstrap.ts b/packages/ai-autopilot/src/bootstrap/bootstrap.ts index 5fa3e000..4e502a37 100644 --- a/packages/ai-autopilot/src/bootstrap/bootstrap.ts +++ b/packages/ai-autopilot/src/bootstrap/bootstrap.ts @@ -1,4 +1,3 @@ -import { DecisionLedger } from '../decisions/ledger.js' import { isPassing } from '../loop/verdict.js' import type { BootstrapEvent, @@ -18,10 +17,10 @@ export class BootstrapAborted extends Error { /** * The bootstrap orchestrator: sequences the injected {@link BootstrapSteps} into - * scope → architect → build → full-fledged loop, narrating each phase over - * {@link BootstrapOptions.onEvent} and recording the architect's choices to the - * decisions ledger. It owns the control flow (the loop, the gate, the interrupt); - * the steps own the model/runner work, so it runs offline against stubs. + * scope → build → full-fledged loop, narrating each phase over + * {@link BootstrapOptions.onEvent}. It owns the control flow (the loop, the gate, + * the interrupt); the steps own the model/runner work, so it runs offline against + * stubs. * * ```ts * const boot = new Bootstrap({ steps, onEvent: e => render(e) }) @@ -35,15 +34,13 @@ export class BootstrapAborted extends Error { export class Bootstrap { private readonly steps: BootstrapSteps private readonly maxPasses: number - private readonly ledger: DecisionLedger private readonly signal?: AbortSignal private readonly emit: (event: BootstrapEvent) => void constructor(opts: BootstrapOptions) { if (opts?.steps == null) throw new TypeError('[ai-autopilot] Bootstrap requires `steps`') - const { scope, architect, build } = opts.steps + const { scope, build } = opts.steps if (typeof scope !== 'function') throw new TypeError('[ai-autopilot] Bootstrap needs a `scope` step') - if (typeof architect !== 'function') throw new TypeError('[ai-autopilot] Bootstrap needs an `architect` step') if (typeof build !== 'function') throw new TypeError('[ai-autopilot] Bootstrap needs a `build` step') const maxPasses = opts.maxPasses ?? 3 @@ -53,16 +50,10 @@ export class Bootstrap { this.steps = opts.steps this.maxPasses = maxPasses - this.ledger = opts.ledger ?? new DecisionLedger() if (opts.signal) this.signal = opts.signal this.emit = makeEmitter(opts.onEvent) } - /** The ledger the run records architect choices into (its own, or the injected one). */ - get decisions(): DecisionLedger { - return this.ledger - } - /** Run the whole flow and resolve with the {@link BootstrapResult}. */ async run(): Promise { // 1. Scope — the only question we ask. @@ -70,40 +61,17 @@ export class Bootstrap { const { scope, intent } = await this.steps.scope() this.emit({ type: 'scope', scope, intent }) - // 2. Architect — pick the stack, narrate, record the choices. No permission asked. - this.throwIfAborted() - const plan = await this.steps.architect({ - intent, - scope, - ledger: this.ledger, - ...(this.signal ? { signal: this.signal } : {}), - }) - for (const d of plan.decisions) this.ledger.accept(d.choice, d.why, ['architecture']) - // Record the rejected alternatives too, so the ledger shows what was weighed - // and not re-litigated (e.g. "Next.js — no first-class edge deploy"). - for (const a of plan.alternatives ?? []) this.ledger.reject(a.option, a.whyNot, ['architecture']) - this.emit({ - type: 'architect', - stack: plan.stack, - decisions: plan.decisions, - ...(plan.pros?.length ? { pros: plan.pros } : {}), - ...(plan.cons?.length ? { cons: plan.cons } : {}), - ...(plan.alternatives?.length ? { alternatives: plan.alternatives } : {}), - }) - if (plan.narration) this.emit({ type: 'narrate', phase: 'architect', message: plan.narration }) - - // 3. Build — Supervisor over personas + runner; forward its events as narration. + // 2. Build — Supervisor over the runner; forward its events as narration. + // What stack to build on is the agent's call, not ours (#545). this.throwIfAborted() - this.emit({ type: 'narrate', phase: 'build', message: `Building on ${plan.stack}` }) const run = await this.steps.build({ - plan, scope, intent, onEvent: event => this.emit({ type: 'build', event }), ...(this.signal ? { signal: this.signal } : {}), }) - // 4. Full-fledged loop — repeat the checklist with fresh context until it is + // 3. Full-fledged loop — repeat the checklist with fresh context until it is // clean or the pass budget runs out. Prototype scope skips this. let passes = 0 let blockers: readonly string[] = [] @@ -114,7 +82,6 @@ export class Bootstrap { this.throwIfAborted() const verdict = await this.steps.checklist({ pass, - plan, intent, blockers, ...(this.signal ? { signal: this.signal } : {}), @@ -134,7 +101,6 @@ export class Bootstrap { if (this.steps.improve) { await this.steps.improve({ pass, - plan, intent, blockers, ...(this.signal ? { signal: this.signal } : {}), @@ -145,14 +111,13 @@ export class Bootstrap { const productionGrade = passes > 0 && blockers.length === 0 - // 5. Deploy — the final phase: decide SSR/SSG/SPA + target, narrate, and hand + // 4. Deploy — the final phase: decide SSR/SSG/SPA + target, narrate, and hand // the plan to a DeployTarget. v1 targets are plan-only (they do not ship). let deploy: DeployOutcome | undefined if (this.steps.deploy) { this.throwIfAborted() this.emit({ type: 'narrate', phase: 'deploy', message: 'Deciding how and where to deploy' }) deploy = await this.steps.deploy({ - plan, scope, intent, productionGrade, @@ -164,7 +129,6 @@ export class Bootstrap { const result: BootstrapResult = { scope, intent, - plan, run, passes, blockers, diff --git a/packages/ai-autopilot/src/bootstrap/deploy.test.ts b/packages/ai-autopilot/src/bootstrap/deploy.test.ts index bb56acb2..47558c5b 100644 --- a/packages/ai-autopilot/src/bootstrap/deploy.test.ts +++ b/packages/ai-autopilot/src/bootstrap/deploy.test.ts @@ -5,7 +5,6 @@ import { agentDeploy, planOnlyTarget, FakeDeployTarget, DEFAULT_DEPLOY_TARGETS } import type { DeployContext } from './types.js' const ctx = (over: Partial = {}): DeployContext => ({ - plan: { stack: 'Vike + universal-orm', narration: '', decisions: [] }, scope: 'full', intent: 'a shop', productionGrade: true, diff --git a/packages/ai-autopilot/src/bootstrap/deploy.ts b/packages/ai-autopilot/src/bootstrap/deploy.ts index b7487af2..b720b138 100644 --- a/packages/ai-autopilot/src/bootstrap/deploy.ts +++ b/packages/ai-autopilot/src/bootstrap/deploy.ts @@ -100,12 +100,10 @@ export function agentDeploy(deployer: Agent, opts: AgentDeployOptions = {}): Non }) const output = Output.object({ schema }) - return async ({ plan: architecture, scope, intent, productionGrade, signal }) => { + return async ({ scope, intent, productionGrade, signal }) => { const context = [ `# What the user wanted (${scope})`, intent, - `# Stack`, - architecture.stack, `# Production-grade`, productionGrade ? 'the app passed the production-grade checklist' : 'not yet fully production-grade', `# Allowed targets`, diff --git a/packages/ai-autopilot/src/bootstrap/index.ts b/packages/ai-autopilot/src/bootstrap/index.ts index 0c91d4bf..15032230 100644 --- a/packages/ai-autopilot/src/bootstrap/index.ts +++ b/packages/ai-autopilot/src/bootstrap/index.ts @@ -2,24 +2,19 @@ * Bootstrap mode — the spine from nothing to a running, production-grade app. * * {@link Bootstrap} sequences the injected {@link BootstrapSteps} into - * scope → architect → build → full-fledged loop, narrating each phase and - * recording the architect's choices to the decisions ledger. The default step - * builders wire those steps onto the real primitives (Supervisor, personas, the - * Loop); a test swaps in stubs + a `FakeRunner` to run the whole flow offline. + * scope → build → full-fledged loop, narrating each phase. The default step + * builders wire those steps onto the real primitives (Supervisor, the Loop); a + * test swaps in stubs + a `FakeRunner` to run the whole flow offline. * * - {@link Bootstrap} / {@link createBootstrap} — the orchestrator - * - {@link agentArchitect} — architect step over an `ai-sdk` agent * - {@link supervisorBuild} — build step over the {@link Supervisor} * - {@link loopChecklist} / {@link loopImprove} — the full-fledged loop steps */ export { Bootstrap, createBootstrap, BootstrapAborted } from './bootstrap.js' export { - agentArchitect, - STACK_TRADEOFFS, supervisorBuild, loopChecklist, loopImprove, - type ArchitectAgentOptions, type SupervisorBuildOptions, type LoopStepOptions, type LoopChecklistOptions, @@ -45,9 +40,6 @@ export type { BootstrapScope, BootstrapPhase, ScopeAnswer, - ArchitectDecision, - ArchitectAlternative, - ArchitectPlan, RenderMode, DeployPlan, DeployResult, @@ -59,7 +51,6 @@ export type { BootstrapSteps, BootstrapOptions, BuildContext, - ArchitectContext, DeployContext, LoopPassContext, } from './types.js' diff --git a/packages/ai-autopilot/src/bootstrap/serve-check.docker.test.ts b/packages/ai-autopilot/src/bootstrap/serve-check.docker.test.ts index f449227d..bb42bb98 100644 --- a/packages/ai-autopilot/src/bootstrap/serve-check.docker.test.ts +++ b/packages/ai-autopilot/src/bootstrap/serve-check.docker.test.ts @@ -14,7 +14,7 @@ import type { LoopPassContext } from './types.js' const skip = (await dockerAvailable()) ? false : 'no docker daemon available' /** A minimal loop-pass context — serveCheck ignores it, but the contract needs one. */ -const ctx: LoopPassContext = { pass: 1, plan: { stack: '', narration: '', decisions: [] }, intent: '', blockers: [] } +const ctx: LoopPassContext = { pass: 1, intent: '', blockers: [] } /** A server bound to 0.0.0.0 so the container's published port reaches it from the host. */ const server = (port: number, status: number, body: string): string => diff --git a/packages/ai-autopilot/src/bootstrap/serve-check.test.ts b/packages/ai-autopilot/src/bootstrap/serve-check.test.ts index 125b6b21..59a29b3d 100644 --- a/packages/ai-autopilot/src/bootstrap/serve-check.test.ts +++ b/packages/ai-autopilot/src/bootstrap/serve-check.test.ts @@ -8,7 +8,7 @@ import type { LoopPassContext } from './types.js' import type { Verdict } from '../loop/verdict.js' /** A minimal loop-pass context — serveCheck ignores it, but the contract needs one. */ -const ctx: LoopPassContext = { pass: 1, plan: { stack: '', narration: '', decisions: [] }, intent: '', blockers: [] } +const ctx: LoopPassContext = { pass: 1, intent: '', blockers: [] } function freePort(): Promise { return new Promise((resolve, reject) => { diff --git a/packages/ai-autopilot/src/bootstrap/steps.test.ts b/packages/ai-autopilot/src/bootstrap/steps.test.ts index 92cd08eb..3c60a4e6 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.test.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.test.ts @@ -1,95 +1,14 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { AiFake, agent } from '@gemstack/ai-sdk' -import { agentArchitect, supervisorBuild, loopChecklist, loopImprove } from './steps.js' +import { supervisorBuild, loopChecklist, loopImprove } from './steps.js' import { agentDeploy, FakeDeployTarget } from './deploy.js' import { Bootstrap } from './bootstrap.js' -import { DecisionLedger } from '../decisions/ledger.js' import { LoopEngine } from '../loop/loop.js' import { definePrompt, defineLoop } from '../loop/define.js' import type { BootstrapEvent } from './types.js' import type { SupervisorEvent } from '../types.js' -import type { ArchitectContext, BuildContext, LoopPassContext } from './types.js' - -const architectCtx = (over: Partial = {}): ArchitectContext => ({ - intent: 'a bookstore', - scope: 'full', - ledger: new DecisionLedger(), - ...over, -}) - -describe('agentArchitect (default step over an ai-sdk agent)', () => { - it('parses a structured plan and prepends the decisions briefing', async () => { - const fake = AiFake.fake() - try { - fake.respondWithSequence([ - { - text: JSON.stringify({ - stack: 'Vike + universal-orm', - narration: 'A server-rendered bookstore', - decisions: [{ choice: 'Postgres', why: 'relational catalog' }], - }), - }, - ]) - const ledger = new DecisionLedger() - ledger.reject('Use a NoSQL document store', 'the catalog is relational') - - const step = agentArchitect(agent({ instructions: 'architect' })) - const plan = await step(architectCtx({ ledger })) - - assert.equal(plan.stack, 'Vike + universal-orm') - assert.equal(plan.decisions[0]?.choice, 'Postgres') - // the rejected idea reached the model as a briefing so it will not re-pitch it - const sent = JSON.stringify(fake.getCalls()[0]) - assert.match(sent, /NoSQL document store/) - // the architect is asked to justify the stack, grounded in the objective tradeoffs - assert.match(sent, /PROS and its CONS/) - assert.match(sent, /renderer-agnostic/) - } finally { - fake.restore() - } - }) - - it('parses the stack rationale (pros/cons/alternatives) when the model returns it', async () => { - const fake = AiFake.fake() - try { - fake.respondWithSequence([ - { - text: JSON.stringify({ - stack: 'Vike + universal-orm', - narration: 'n', - decisions: [], - pros: ['edge deploy'], - cons: ['smaller ecosystem'], - alternatives: [{ option: 'Next.js', whyNot: 'constrained edge deploy' }], - }), - }, - ]) - const step = agentArchitect(agent({ instructions: 'architect' })) - const plan = await step(architectCtx({ ledger: new DecisionLedger() })) - assert.deepEqual(plan.pros, ['edge deploy']) - assert.deepEqual(plan.cons, ['smaller ecosystem']) - assert.deepEqual(plan.alternatives, [{ option: 'Next.js', whyNot: 'constrained edge deploy' }]) - } finally { - fake.restore() - } - }) - - it('omits rationale fields when the model returns none (backward compatible)', async () => { - const fake = AiFake.fake() - try { - fake.respondWithSequence([ - { text: JSON.stringify({ stack: 'Vike', narration: 'n', decisions: [] }) }, - ]) - const step = agentArchitect(agent({ instructions: 'architect' })) - const plan = await step(architectCtx({ ledger: new DecisionLedger() })) - assert.equal('pros' in plan, false) - assert.equal('alternatives' in plan, false) - } finally { - fake.restore() - } - }) -}) +import type { BuildContext, LoopPassContext } from './types.js' describe('supervisorBuild (default step over the Supervisor)', () => { it('runs the Supervisor and forwards its events as narration', async () => { @@ -103,7 +22,6 @@ describe('supervisorBuild (default step over the Supervisor)', () => { concurrency: 1, }) const ctx: BuildContext = { - plan: { stack: 'Vike + universal-orm', narration: '', decisions: [] }, scope: 'full', intent: 'a bookstore', onEvent: e => events.push(e), @@ -123,7 +41,7 @@ describe('supervisorBuild (default step over the Supervisor)', () => { controller.abort() const step = supervisorBuild({ plan: () => [], workers: agent({ instructions: 'w' }) }) await assert.rejects( - async () => step({ plan: { stack: '', narration: '', decisions: [] }, scope: 'full', intent: '', onEvent: () => {}, signal: controller.signal }), + async () => step({ scope: 'full', intent: '', onEvent: () => {}, signal: controller.signal }), /aborted before start/, ) }) @@ -132,7 +50,6 @@ describe('supervisorBuild (default step over the Supervisor)', () => { describe('loopChecklist / loopImprove (default full-fledged loop steps)', () => { const passCtx = (over: Partial = {}): LoopPassContext => ({ pass: 1, - plan: { stack: 'Vike + universal-orm', narration: '', decisions: [] }, intent: 'a bookstore', blockers: [], ...over, @@ -169,18 +86,11 @@ describe('loopChecklist / loopImprove (default full-fledged loop steps)', () => }) describe('Bootstrap end-to-end with the default steps (offline)', () => { - it('runs scope → architect → build → full-fledged loop against real primitives', async () => { + it('runs scope → build → full-fledged loop against real primitives', async () => { const fake = AiFake.fake() try { - // Three model calls, in order: the architect plan, the build worker, the deploy decision. + // Two model calls, in order: the build worker, then the deploy decision. fake.respondWithSequence([ - { - text: JSON.stringify({ - stack: 'Vike + universal-orm', - narration: 'A server-rendered bookstore with a Postgres data layer', - decisions: [{ choice: 'universal-orm on Postgres', why: 'typed, relational catalog' }], - }), - }, { text: 'scaffolded the catalog page and orders schema' }, { text: JSON.stringify({ render: 'ssr', target: 'dokploy', reason: 'per-request catalog + auth' }) }, ]) @@ -200,15 +110,12 @@ describe('Bootstrap end-to-end with the default steps (offline)', () => { ], }) - const ledger = new DecisionLedger() const deployTarget = new FakeDeployTarget({ result: { deployed: true, url: 'https://bookstore.example' } }) const events: BootstrapEvent[] = [] const boot = new Bootstrap({ - ledger, onEvent: e => events.push(e), steps: { scope: () => ({ scope: 'full', intent: 'a bookstore' }), - architect: agentArchitect(agent({ instructions: 'architect' })), build: supervisorBuild({ plan: () => [{ description: 'scaffold the app', worker: 'w' }], workers: { w: agent({ instructions: 'worker' }) }, @@ -222,13 +129,11 @@ describe('Bootstrap end-to-end with the default steps (offline)', () => { const result = await boot.run() - assert.equal(result.plan.stack, 'Vike + universal-orm') assert.equal(result.run.results[0]?.ok, true) assert.equal(result.passes, 2) assert.deepEqual(result.blockers, []) assert.equal(result.productionGrade, true) assert.equal(improved, 1) // improved once, between the two checks - assert.equal(ledger.size, 1) // architect choice recorded // deploy ran last: decided SSR/dokploy and reached the (fake) target assert.deepEqual(result.deploy?.plan, { render: 'ssr', target: 'dokploy', reason: 'per-request catalog + auth' }) assert.equal(result.deploy?.result.url, 'https://bookstore.example') diff --git a/packages/ai-autopilot/src/bootstrap/steps.ts b/packages/ai-autopilot/src/bootstrap/steps.ts index 3d0875cb..ec670105 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.ts @@ -2,7 +2,6 @@ import { Output } from '@gemstack/ai-sdk' import type { Agent } from '@gemstack/ai-sdk' import { z } from 'zod' import { Supervisor } from '../supervisor.js' -import { decisionBriefing } from '../decisions/tools.js' import { LoopEngine } from '../loop/loop.js' import { LOOP_EVENTS, LOOP_PROMPTS } from '../loop/policy.js' import type { Planner, Synthesizer, SupervisorOptions } from '../types.js' @@ -10,93 +9,13 @@ import type { Verdict } from '../loop/verdict.js' import type { BootstrapSteps, BuildContext } from './types.js' /** - * Default wirings of the four bootstrap steps onto the real primitives — - * Supervisor, personas, and the LoopEngine. Each is thin (it constructs a primitive - * and adapts its I/O to the step contract), so the same orchestrator runs - * against these in production or against stubs in a test. The model + runner stay - * injected: you pass the architect agent, the planner/workers, and the loop. + * Default wirings of the bootstrap steps onto the real primitives — Supervisor + * and the LoopEngine. Each is thin (it constructs a primitive and adapts its I/O + * to the step contract), so the same orchestrator runs against these in + * production or against stubs in a test. The model + runner stay injected: you + * pass the planner/workers and the loop. */ -/** Options for {@link agentArchitect}. */ -export interface ArchitectAgentOptions { - /** Override the architect instruction prepended to the intent. */ - instructions?: string -} - -/** - * Objective, reusable stack tradeoffs the architect grounds its justification in, - * so the PROS/CONS it reports are real reasons rather than invented per run. Kept - * as one exported block so the ai-sdk architect and the driver architect - * (framework) share the same knowledge. Extend it as the default stack evolves. - */ -export const STACK_TRADEOFFS = `Ground the stack justification in these objective tradeoffs, do not invent reasons: -- Vike (Vite + SSR, renderer-agnostic): deploys anywhere including edge/serverless - (Cloudflare, Vercel, Node); works with React, Vue, or Solid; lighter and less - opinionated. Downsides: fewer batteries-included conventions and a smaller - ecosystem than Next. -- Next.js (App Router + React Server Components): largest ecosystem, batteries - included (image/font/routing), first-class Vercel deploy. Downsides: heavier, - React-only, a more opinionated server model, and more constrained edge/Cloudflare - support. -Weigh these against the app's actual needs. Neither is a default; pick the one -the requirements point to.` - -const DEFAULT_ARCHITECT_INSTRUCTIONS = `You are the lead architect. Choose the stack and structure for the app the user -describes and commit to it — act like a senior engineer who decides and explains, -not one who asks permission. Choose the stack that best fits what the user is -building. Only choose packages that are published and installable on npm. -Narrate what you are building -and why in a sentence or two, and list the key choices so they are recorded and -not re-litigated later. - -Justify the stack honestly: give its real PROS and its CONS (every stack has -tradeoffs), and name the main alternative you rejected and why it lost. This is -shown to the user as the rationale, so be concrete, not promotional. - -${STACK_TRADEOFFS}` - -/** - * An architect step backed by an `ai-sdk` agent. It prompts the agent for a - * structured `{ stack, narration, decisions }` plan (via `Output.object`), and - * prepends the decisions briefing so it does not re-pitch an already-rejected - * idea. The orchestrator records the returned choices to the ledger. - */ -export function agentArchitect(architect: Agent, opts: ArchitectAgentOptions = {}): BootstrapSteps['architect'] { - const schema = z.object({ - stack: z.string().describe('The chosen stack, one line'), - narration: z.string().describe('What you are building and why, to tell the user'), - decisions: z - .array(z.object({ choice: z.string(), why: z.string() })) - .describe('Key architectural choices and their rationale'), - pros: z.array(z.string()).describe('Why the chosen stack fits — its real upsides').optional(), - cons: z.array(z.string()).describe('Honest downsides / tradeoffs of the chosen stack').optional(), - alternatives: z - .array(z.object({ option: z.string(), whyNot: z.string() })) - .describe('Stacks considered but rejected, and why each lost') - .optional(), - }) - const output = Output.object({ schema }) - const instructions = opts.instructions ?? DEFAULT_ARCHITECT_INSTRUCTIONS - - return async ({ intent, scope, ledger }) => { - const briefing = decisionBriefing(ledger) - const head = briefing ? `${briefing}\n\n${instructions}` : instructions - const prompt = `${head}\n\n# What the user wants (${scope})\n${intent}\n\n${output.toSystemPrompt()}` - const response = await architect.prompt(prompt) - const parsed = output.parse(response.text ?? '') - // Omit the rationale fields when absent rather than setting them to - // `undefined` (exactOptionalPropertyTypes), so consumers see a clean plan. - return { - stack: parsed.stack, - narration: parsed.narration, - decisions: parsed.decisions, - ...(parsed.pros?.length ? { pros: parsed.pros } : {}), - ...(parsed.cons?.length ? { cons: parsed.cons } : {}), - ...(parsed.alternatives?.length ? { alternatives: parsed.alternatives } : {}), - } - } -} - /** Options for {@link supervisorBuild}. */ export interface SupervisorBuildOptions { /** How to decompose the build into subtasks (usually `agentPlanner(...)`). */ @@ -106,12 +25,11 @@ export interface SupervisorBuildOptions { synthesize?: Synthesizer concurrency?: number budget?: SupervisorOptions['budget'] - /** Build the task text from the plan + intent. Default: intent + chosen stack. */ + /** Build the task text from the intent. Default: the intent alone. */ task?: (ctx: BuildContext) => string } -const defaultBuildTask = (ctx: BuildContext): string => - `Build the app.\n\n# Goal\n${ctx.intent}\n\n# Stack\n${ctx.plan.stack}` +const defaultBuildTask = (ctx: BuildContext): string => `Build the app.\n\n# Goal\n${ctx.intent}` /** * A build step that runs the {@link Supervisor} over the given planner + workers, diff --git a/packages/ai-autopilot/src/bootstrap/types.ts b/packages/ai-autopilot/src/bootstrap/types.ts index e533d096..f45050c2 100644 --- a/packages/ai-autopilot/src/bootstrap/types.ts +++ b/packages/ai-autopilot/src/bootstrap/types.ts @@ -1,4 +1,3 @@ -import type { DecisionLedger } from '../decisions/ledger.js' import type { SupervisorEvent, SupervisorRun } from '../types.js' import type { Verdict } from '../loop/verdict.js' @@ -7,19 +6,17 @@ import type { Verdict } from '../loop/verdict.js' * well-structured app (#116). It sequences autopilot's existing primitives into * one flow: * - * scope → architect → build → full-fledged loop + * scope → build → full-fledged loop * * - **Scope** is the one and only interrogation: prototype vs full, plus intent. - * - **Architect** picks the stack (Vike + Prisma), narrates it, and - * records key choices to the decisions ledger — no permission asked. - * - **Build** runs the Supervisor over the stack personas inside a runner, + * - **Build** runs the Supervisor inside a runner, * streaming narration; the caller can interrupt via an `AbortSignal`. * - **Full-fledged loop** (full scope only) repeats the production-grade * checklist with fresh context, improving against its `{ blockers }` verdict * until it is empty or a `maxPasses` budget stops it. * * The orchestrator ({@link Bootstrap}) owns the sequencing, narration, and loop - * control; the four {@link BootstrapSteps} are injected, so a test drives it with + * control; the {@link BootstrapSteps} are injected, so a test drives it with * stubs + a {@link FakeRunner} while production wires real agents. Its narration * rides the generic surface stream (`EventStream`). */ @@ -28,7 +25,7 @@ import type { Verdict } from '../loop/verdict.js' export type BootstrapScope = 'prototype' | 'full' /** The phase a narration line belongs to. */ -export type BootstrapPhase = 'scope' | 'architect' | 'build' | 'loop' | 'deploy' +export type BootstrapPhase = 'scope' | 'build' | 'loop' | 'deploy' /** The answer to the one upfront question. */ export interface ScopeAnswer { @@ -37,20 +34,6 @@ export interface ScopeAnswer { intent: string } -/** One architectural choice the architect made and why — recorded to the ledger. */ -export interface ArchitectDecision { - choice: string - why: string -} - -/** A stack option the architect considered but did not choose, and why. */ -export interface ArchitectAlternative { - /** The rejected option (e.g. "Next.js"). */ - option: string - /** Why it lost to the chosen stack, one line (e.g. "no first-class edge/Cloudflare deploy"). */ - whyNot: string -} - /** How the app is rendered/served, which drives the deploy shape. */ export type RenderMode = 'ssr' | 'ssg' | 'spa' @@ -100,36 +83,12 @@ export interface DeployTarget { deploy(ctx: DeployTargetContext): DeployResult | Promise } -/** The architect's output: the stack it chose, a narration, and the key choices. */ -export interface ArchitectPlan { - /** The chosen stack, one line (e.g. "Vike + Prisma, Postgres, vike-auth"). */ - stack: string - /** What it is building and why, to narrate to the user. */ - narration: string - /** Key choices to record to the decisions ledger so they are not re-litigated. */ - decisions: readonly ArchitectDecision[] - /** Why the chosen stack fits — the upsides, for the rationale panel. */ - pros?: readonly string[] - /** Honest downsides of the chosen stack — the tradeoffs it accepts. */ - cons?: readonly string[] - /** Options considered and rejected, with why — recorded to the ledger as rejections. */ - alternatives?: readonly ArchitectAlternative[] -} - /** * A narration/progress event bootstrap emits over the generic surface stream. * The build phase forwards the Supervisor's own events verbatim under `build`. */ export type BootstrapEvent = | { type: 'scope'; scope: BootstrapScope; intent: string } - | { - type: 'architect' - stack: string - decisions: readonly ArchitectDecision[] - pros?: readonly string[] - cons?: readonly string[] - alternatives?: readonly ArchitectAlternative[] - } | { type: 'narrate'; phase: BootstrapPhase; message: string } | { type: 'build'; event: SupervisorEvent } | { type: 'checklist'; pass: number; blockers: readonly string[]; passing: boolean } @@ -141,8 +100,6 @@ export type BootstrapEvent = export interface BootstrapResult { scope: BootstrapScope intent: string - /** The architecture chosen. */ - plan: ArchitectPlan /** The initial build's supervised run. */ run: SupervisorRun /** Full-fledged passes performed (0 for a prototype, or when no checklist ran). */ @@ -159,7 +116,6 @@ export interface BootstrapResult { /** Context handed to the build step. */ export interface BuildContext { - plan: ArchitectPlan scope: BootstrapScope intent: string /** Forward each Supervisor event here so bootstrap can narrate the build. */ @@ -167,18 +123,8 @@ export interface BuildContext { signal?: AbortSignal } -/** Context handed to the architect step. */ -export interface ArchitectContext { - intent: string - scope: BootstrapScope - /** The ledger to consult (choices are recorded by the orchestrator, not here). */ - ledger: DecisionLedger - signal?: AbortSignal -} - /** Context handed to the deploy step (the final phase). */ export interface DeployContext { - plan: ArchitectPlan scope: BootstrapScope intent: string /** Whether the full-fledged loop ended clean, so the step can factor readiness in. */ @@ -190,7 +136,6 @@ export interface DeployContext { export interface LoopPassContext { /** 1-based pass number; each pass is meant to run with fresh context. */ pass: number - plan: ArchitectPlan intent: string /** The blockers the checklist last reported (empty on the improve of pass 1). */ blockers: readonly string[] @@ -198,16 +143,14 @@ export interface LoopPassContext { } /** - * The four injectable steps. Only `scope`, `architect`, and `build` are required; + * The injectable steps. Only `scope` and `build` are required; * `checklist` (and its paired `improve`) drive the full-fledged loop and are used * only for `scope: 'full'`. */ export interface BootstrapSteps { /** The one upfront question. */ scope: () => ScopeAnswer | Promise - /** Pick the stack, narrate, and return the key choices. */ - architect: (ctx: ArchitectContext) => ArchitectPlan | Promise - /** Run the build (Supervisor over personas + runner). */ + /** Run the build (Supervisor over a runner). */ build: (ctx: BuildContext) => SupervisorRun | Promise /** Report the production-grade verdict for a pass. Full scope only. */ checklist?: (ctx: LoopPassContext) => Verdict | Promise @@ -222,8 +165,6 @@ export interface BootstrapOptions { steps: BootstrapSteps /** Max full-fledged passes before stopping with blockers open. Default 3. */ maxPasses?: number - /** The decisions ledger. A fresh one is created when omitted. */ - ledger?: DecisionLedger /** Observe narration. Isolated: a throwing callback is logged and swallowed. */ onEvent?: (event: BootstrapEvent) => void /** Interrupt the run between phases (the "user can interrupt" affordance). */ diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index c25b4d36..86a4454d 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -66,12 +66,12 @@ * - {@link promptInstructions} — compose a body with the decisions briefing * * Bootstrap mode is the spine that sequences all of the above into one flow: - * scope → architect → build → full-fledged loop, taking a user from nothing to a + * scope → build → full-fledged loop, taking a user from nothing to a * running, production-grade app. It narrates each phase and repeats the * production-grade checklist until its `{ blockers }` verdict is empty. * * - {@link Bootstrap} — the orchestrator over the injectable steps - * - {@link agentArchitect} / {@link supervisorBuild} — the default step wirings + * - {@link supervisorBuild} — the default build step wiring * - {@link loopChecklist} / {@link loopImprove} — the full-fledged loop steps * - {@link agentDeploy} + the {@link DeployTarget} seam ({@link planOnlyTarget}, * {@link FakeDeployTarget}, {@link cloudflareTarget}, {@link dokployTarget}) — the @@ -243,8 +243,6 @@ export { Bootstrap, createBootstrap, BootstrapAborted, - agentArchitect, - STACK_TRADEOFFS, supervisorBuild, loopChecklist, loopImprove, @@ -262,7 +260,6 @@ export { type DeployExecutor, type DokployTargetOptions, type FetchLike, - type ArchitectAgentOptions, type SupervisorBuildOptions, type LoopStepOptions, type LoopChecklistOptions, @@ -272,9 +269,6 @@ export { type BootstrapScope, type BootstrapPhase, type ScopeAnswer, - type ArchitectDecision, - type ArchitectAlternative, - type ArchitectPlan, type RenderMode, type DeployPlan, type DeployResult, @@ -286,7 +280,6 @@ export { type BootstrapSteps, type BootstrapOptions, type BuildContext, - type ArchitectContext, type DeployContext, type LoopPassContext, } from './bootstrap/index.js' diff --git a/packages/ai-autopilot/src/personas/library.ts b/packages/ai-autopilot/src/personas/library.ts index 331502f4..082bd173 100644 --- a/packages/ai-autopilot/src/personas/library.ts +++ b/packages/ai-autopilot/src/personas/library.ts @@ -62,7 +62,7 @@ export const dataModeler: Persona = definePersona({ role: 'Models data schema-first: derive migrations, generate a typed client, write typed queries', appliesTo: ['prisma', '@prisma/client', 'drizzle-orm', 'drizzle-kit'], systemPrompt: `You own the data layer. Default to Prisma — schema-first, migrations derived -from the schema, and a fully typed client — unless the architect explicitly chose +from the schema, and a fully typed client — unless the app already uses another published SQL ORM (e.g. Drizzle). Never assume an unpublished/private ORM is installable: use only packages that resolve on npm. diff --git a/packages/framework-dashboard/components/RunOverview.tsx b/packages/framework-dashboard/components/RunOverview.tsx index d828a9b3..8de38b68 100644 --- a/packages/framework-dashboard/components/RunOverview.tsx +++ b/packages/framework-dashboard/components/RunOverview.tsx @@ -1,23 +1,21 @@ import type { FrameworkEvent } from '@gemstack/framework' -import { architectPlan, decisionLedger, loopStatus, sessionInfo, deployPlan, runProgress } from '@gemstack/framework/client' +import { loopStatus, sessionInfo, deployPlan, runProgress } from '@gemstack/framework/client' import { Badge } from './ui/badge.js' import { cn } from '../lib/utils.js' // The run overview (#431): the "moat" the wrapped agent's own chat cannot show, rebuilt // on the new dashboard. Each card is a pure projection of the event stream (run-view.ts -// in @gemstack/framework) — the chosen stack + PROS/CONS + rejected alternatives, the -// decisions ledger, the production-grade loop status, and a link to the live session. -// Cards render only when their data has arrived, so an early run shows nothing extra. +// in @gemstack/framework) — the production-grade loop status, the deploy plan, and a link +// to the live session. Cards render only when their data has arrived, so an early run +// shows nothing extra. export function RunOverview({ events }: { events: FrameworkEvent[] }) { - const plan = architectPlan(events) - const ledger = decisionLedger(events) const loop = loopStatus(events) const session = sessionInfo(events) const deploy = deployPlan(events) const progress = runProgress(events) const hasProgress = Boolean(progress.sessionName) || progress.readyForMerge - if (!plan && !loop && !deploy && !session?.sessionLink && !hasProgress) return null + if (!loop && !deploy && !session?.sessionLink && !hasProgress) return null return (
@@ -30,36 +28,6 @@ export function RunOverview({ events }: { events: FrameworkEvent[] }) { {progress.readyForMerge ? 'ready for merge' : 'building…'}
)} - {plan && ( -
-

Stack & rationale

-

{plan.stack}

- {(plan.pros.length > 0 || plan.cons.length > 0) && ( -
-
    - {plan.pros.map((p, i) => ( -
  • - {p} -
  • - ))} -
-
    - {plan.cons.map((c, i) => ( -
  • - {c} -
  • - ))} -
-
- )} - {plan.alternatives.length > 0 && ( -

- Considered: {plan.alternatives.map(a => `${a.option} (${a.whyNot})`).join('; ')} -

- )} -
- )} - {loop && (

@@ -94,20 +62,6 @@ export function RunOverview({ events }: { events: FrameworkEvent[] }) {

)} - {ledger.length > 0 && ( -
-

Decisions ledger

-
    - {ledger.map((d, i) => ( -
  • - {d.choice} - — {d.why} -
  • - ))} -
-
- )} - {session?.sessionLink && ( ` to resume a run from another workspace. Pass `--no-persist` to skip writing state. We do not persist the agent's transcript; Claude Code owns that. diff --git a/packages/framework/bench/README.md b/packages/framework/bench/README.md deleted file mode 100644 index b56d39e9..00000000 --- a/packages/framework/bench/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Benchmarks - -## architect prompt (#485 / #499) - -Does the architect prompt (`architectPrompt` in `src/steps.ts`) pick a sane stack for the app, give the honest tradeoffs it promises, and stay unbiased? #499 already had to strip a Vike nudge from it, so this benchmark guards the same trust concern. - -The harness (`src/architect-bench.ts`) runs a labeled corpus of app ideas through `driverArchitect` and reports: - -- **sane stacks** — for apps whose *type* dictates the family (a CLI is not a web app, a mobile app is not Next.js), did it pick something in the acceptable set and nothing clearly wrong. -- **honest tradeoffs** — how often the plan carried a pro **and** a con **and** a rejected alternative (the architect prompt asks for all three). -- **framework balance** — across genuinely framework-agnostic web apps, which frontend framework it picked. A heavy skew to one (e.g. Vike) is the bias #499 was about. - -```bash -pnpm --filter @gemstack/framework build -pnpm --filter @gemstack/framework bench:architect -``` - -Same requirements as above (a logged-in `claude` CLI). Scoring is pure and unit-tested; the runner just drives the model. diff --git a/packages/framework/bench/architect.mjs b/packages/framework/bench/architect.mjs deleted file mode 100644 index 7cd10b1e..00000000 --- a/packages/framework/bench/architect.mjs +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -// Live benchmark for the architect prompt (#485/#499): run a labeled corpus of app ideas -// through the architect and report whether it picks sane stacks, gives honest tradeoffs, -// and stays framework-balanced on agnostic web apps. Requires the `claude` CLI logged in. -// -// pnpm --filter @gemstack/framework build -// pnpm --filter @gemstack/framework bench:architect -// MODEL=claude-haiku-4-5-20251001 pnpm --filter @gemstack/framework bench:architect -// -// Scoring lives in src/architect-bench.ts (unit-tested); this is just the live runner. -import { ClaudeCodeDriver } from '../dist/index.js' -import { - ARCHITECT_BENCH_CASES, - formatArchitectBenchReport, - runArchitectBench, -} from '../dist/architect-bench.js' - -const driver = new ClaudeCodeDriver({ permissionMode: 'acceptEdits' }) - -console.log(`Running ${ARCHITECT_BENCH_CASES.length} architect cases...\n`) - -const report = await runArchitectBench({ - driver, - cases: ARCHITECT_BENCH_CASES, - cwd: process.cwd(), - ...(process.env.MODEL ? { model: process.env.MODEL } : {}), - onResult: (r, i) => { - const tag = r.case.webAgnostic ? `bal:${r.framework}` : r.stackFit ? 'ok ' : 'XX ' - const flags = `${r.complete ? '' : ' [no tradeoffs]'}` - console.log(`${tag.padEnd(12)} ${String(i + 1).padStart(2)}. ${r.stack}${flags} <- ${r.case.intent}`) - }, -}) - -console.log(`\n${formatArchitectBenchReport(report)}`) diff --git a/packages/framework/package.json b/packages/framework/package.json index 75a726cf..6d5fddcf 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -55,7 +55,6 @@ "typecheck": "tsc --noEmit", "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", "bundle:dashboard": "node scripts/bundle-dashboard.mjs", - "bench:architect": "node bench/architect.mjs", "clean": "rm -rf dist dist-test" }, "dependencies": { diff --git a/packages/framework/src/architect-bench.test.ts b/packages/framework/src/architect-bench.test.ts deleted file mode 100644 index 45cf029f..00000000 --- a/packages/framework/src/architect-bench.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { strict as assert } from 'node:assert' -import { test } from 'node:test' -import { FakeDriver } from './driver/index.js' -import { - ARCHITECT_BENCH_CASES, - detectFramework, - formatArchitectBenchReport, - isComplete, - isStackFit, - runArchitectBench, - scoreArchitectBench, - type ArchitectBenchCase, - type ArchitectBenchResult, -} from './architect-bench.js' -import type { ArchitectPlan } from '@gemstack/ai-autopilot' - -const plan = (over: Partial = {}): ArchitectPlan => ({ - stack: 'Next.js + Prisma', - narration: 'n', - decisions: [], - pros: ['fast'], - cons: ['heavy'], - alternatives: [{ option: 'Vike', whyNot: 'smaller ecosystem' }], - ...over, -}) - -const resultOf = (c: ArchitectBenchCase, p: ArchitectPlan): ArchitectBenchResult => ({ - case: c, - plan: p, - stack: p.stack, - stackFit: isStackFit(c, p.stack), - complete: isComplete(p), - framework: detectFramework(p.stack), -}) - -test('detectFramework picks the named frontend framework, most specific first', () => { - assert.equal(detectFramework('SvelteKit + Drizzle'), 'sveltekit') // not plain "svelte" - assert.equal(detectFramework('Next.js (React) + Prisma'), 'next') // not plain "react" - assert.equal(detectFramework('Vike + Vue'), 'vike') - assert.equal(detectFramework('Express + Postgres'), 'other') -}) - -test('isComplete requires a pro, a con, and a rejected alternative', () => { - assert.equal(isComplete(plan()), true) - assert.equal(isComplete(plan({ cons: [] })), false) - assert.equal(isComplete(plan({ alternatives: [] })), false) - assert.equal(isComplete(plan({ pros: [] })), false) -}) - -test('isStackFit needs an accept match and no reject match', () => { - const c: ArchitectBenchCase = { intent: 'i', accept: [/express|fastify/i], reject: [/react|vue/i], why: '' } - assert.equal(isStackFit(c, 'Fastify + Postgres'), true) - assert.equal(isStackFit(c, 'Django'), false) // no accept match - assert.equal(isStackFit(c, 'Express + React admin'), false) // reject match wins -}) - -test('scoreArchitectBench: web-agnostic cases feed the balance, not the fit rate', () => { - const category: ArchitectBenchCase = { intent: 'cli', accept: [/node/i], reject: [/react/i], why: '' } - const agnostic: ArchitectBenchCase = { intent: 'web', accept: [/./], webAgnostic: true, why: '' } - const report = scoreArchitectBench([ - resultOf(category, plan({ stack: 'Node CLI', pros: ['x'], cons: ['y'], alternatives: [{ option: 'a', whyNot: 'b' }] })), - resultOf(category, plan({ stack: 'React SPA' })), // unfit (reject match) - resultOf(agnostic, plan({ stack: 'Vike + React' })), - resultOf(agnostic, plan({ stack: 'Next.js' })), - ]) - assert.deepEqual(report.stackFit, { count: 1, of: 2 }) // only the two category cases count toward fit - assert.deepEqual(report.frameworkBalance, { vike: 1, next: 1 }) - assert.equal(report.complete.of, 4) -}) - -test('runArchitectBench drives each case through the architect and scores the plan', async () => { - const cases: ArchitectBenchCase[] = [ - { intent: 'a CLI', accept: [/node|go/i], reject: [/react|vike/i], why: '' }, - { intent: 'a web app', accept: [/./], webAgnostic: true, why: '' }, - ] - // A scripted driver returning a sane plan for each: a Node CLI, then a Vike web app. - const replies = [ - { stack: 'Node.js + TypeScript', narration: 'a cli', decisions: [], pros: ['simple'], cons: ['no ui'], alternatives: [{ option: 'Go', whyNot: 'slower to write' }] }, - { stack: 'Vike + React', narration: 'a web app', decisions: [], pros: ['portable'], cons: ['smaller ecosystem'], alternatives: [{ option: 'Next.js', whyNot: 'heavier' }] }, - ] - let i = 0 - const driver = new FakeDriver({ respond: () => '```json\n' + JSON.stringify(replies[i++]) + '\n```' }) - const report = await runArchitectBench({ driver, cases, cwd: '/tmp/ws' }) - assert.deepEqual(report.stackFit, { count: 1, of: 1 }) // the CLI got a sane, non-web stack - assert.deepEqual(report.complete, { count: 2, of: 2 }) // both plans had pros + cons + an alternative - assert.deepEqual(report.frameworkBalance, { vike: 1 }) -}) - -test('the shipped corpus is well-formed: unique intents, an accept pattern each, and both kinds present', () => { - const intents = new Set() - for (const c of ARCHITECT_BENCH_CASES) { - assert.ok(c.intent.trim() && c.why.trim(), `case missing fields: ${c.intent}`) - assert.ok(!intents.has(c.intent), `duplicate intent: ${c.intent}`) - intents.add(c.intent) - assert.ok(c.accept.length > 0, `case has no accept pattern: ${c.intent}`) - } - assert.ok(ARCHITECT_BENCH_CASES.some(c => c.webAgnostic), 'corpus should include web-agnostic cases') - assert.ok(ARCHITECT_BENCH_CASES.some(c => !c.webAgnostic), 'corpus should include category cases') -}) - -test('formatArchitectBenchReport surfaces the headline, the balance, and questionable stacks', () => { - const category: ArchitectBenchCase = { intent: 'a CLI tool', accept: [/node/i], reject: [/react/i], why: '' } - const agnostic: ArchitectBenchCase = { intent: 'a web app', accept: [/./], webAgnostic: true, why: '' } - const text = formatArchitectBenchReport( - scoreArchitectBench([resultOf(category, plan({ stack: 'React SPA' })), resultOf(agnostic, plan({ stack: 'Vike' }))]), - ) - assert.match(text, /sane stacks/) - assert.match(text, /framework balance/) - assert.match(text, /questionable stacks/) - assert.match(text, /\[React SPA\] a CLI tool/) -}) diff --git a/packages/framework/src/architect-bench.ts b/packages/framework/src/architect-bench.ts deleted file mode 100644 index 649f7385..00000000 --- a/packages/framework/src/architect-bench.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { DecisionLedger, type ArchitectPlan } from '@gemstack/ai-autopilot' -import type { Driver } from './driver/index.js' -import { driverArchitect } from './steps.js' - -/** - * A benchmark for the architect prompt (`architectPrompt` in `steps.ts`) — the turn that - * decides an app's stack. Rom's line on #485: the system prompt is the most critical part - * of The Framework, and #499 already had to strip a Vike nudge from this one because a - * biased stack pick erodes trust. So measure it - * rather than assume: does the architect pick a *sane* stack for the app, does it give the - * *honest tradeoffs* the prompt demands (pros AND cons AND a rejected alternative), and — - * the trust check — on genuinely framework-agnostic web apps, is it *balanced* across - * frameworks rather than defaulting to Vike? - * - * Scoring ({@link scoreArchitectBench}) is pure and unit-tested; the live run - * ({@link runArchitectBench}) drives a real model through the given {@link Driver}. The - * corpus ({@link ARCHITECT_BENCH_CASES}) is hand-labeled — each case says which stacks are - * sane (`accept`) and, where a whole category would be wrong, which are not (`reject`). - */ - -/** One labeled architect case. */ -export interface ArchitectBenchCase { - /** The app the user asks for. */ - intent: string - /** The picked stack fits if it matches at least one of these. */ - accept: readonly RegExp[] - /** ...and matches none of these (a category that is clearly wrong for this app). */ - reject?: readonly RegExp[] - /** - * A web app where any modern framework is a fine choice. These do not test stack-fit - * (they always fit); they feed the framework-balance tally — the #499 trust check. - */ - webAgnostic?: boolean - /** Why these labels — so a reviewer can contest them. */ - why: string -} - -/** The outcome of running one case through the architect. */ -export interface ArchitectBenchResult { - case: ArchitectBenchCase - plan: ArchitectPlan - /** The picked stack, verbatim. */ - stack: string - /** The stack matched an `accept` pattern and no `reject` pattern. */ - stackFit: boolean - /** The plan carried at least one pro, one con, and one rejected alternative. */ - complete: boolean - /** The frontend framework detected in the stack, or `'other'`. Used for the balance tally. */ - framework: string -} - -/** Aggregate metrics over a set of {@link ArchitectBenchResult}s — the decision input. */ -export interface ArchitectBenchReport { - total: number - /** Cases whose picked stack was sane for the app (excludes web-agnostic cases). */ - stackFit: { count: number; of: number } - /** Cases whose plan gave the full honest tradeoff (pros + cons + a rejected alternative). */ - complete: { count: number; of: number } - /** - * Framework distribution across the web-agnostic cases (the trust check): a heavy skew to - * one framework is the bias #499 was worried about. `{}` when there are no such cases. - */ - frameworkBalance: Record - results: ArchitectBenchResult[] -} - -/** Known frontend frameworks, most specific first, for the balance tally. */ -const FRAMEWORKS: readonly { name: string; re: RegExp }[] = [ - { name: 'sveltekit', re: /\bsveltekit\b/i }, - { name: 'next', re: /\bnext(\.js)?\b/i }, - { name: 'nuxt', re: /\bnuxt\b/i }, - { name: 'remix', re: /\bremix\b/i }, - { name: 'astro', re: /\bastro\b/i }, - { name: 'vike', re: /\bvike\b/i }, - { name: 'solidstart', re: /\bsolid(start)?\b/i }, - { name: 'react', re: /\breact\b/i }, - { name: 'vue', re: /\bvue\b/i }, - { name: 'svelte', re: /\bsvelte\b/i }, -] - -/** The frontend framework named in a stack string, or `'other'` if none is recognized. */ -export function detectFramework(stack: string): string { - return FRAMEWORKS.find(f => f.re.test(stack))?.name ?? 'other' -} - -/** Whether a plan carries the full honest tradeoff the architect prompt asks for. */ -export function isComplete(plan: ArchitectPlan): boolean { - return (plan.pros?.length ?? 0) > 0 && (plan.cons?.length ?? 0) > 0 && (plan.alternatives?.length ?? 0) > 0 -} - -/** Whether a picked stack is sane for the case: matches an accept and no reject. */ -export function isStackFit(benchCase: ArchitectBenchCase, stack: string): boolean { - const accepted = benchCase.accept.some(re => re.test(stack)) - const rejected = benchCase.reject?.some(re => re.test(stack)) ?? false - return accepted && !rejected -} - -/** - * Score already-run cases. Pure: no model, no IO. Web-agnostic cases are excluded from the - * stack-fit rate (they always fit) and instead feed the framework-balance tally. - */ -export function scoreArchitectBench(results: readonly ArchitectBenchResult[]): ArchitectBenchReport { - let fit = 0 - let fitOf = 0 - let complete = 0 - const frameworkBalance: Record = {} - for (const r of results) { - if (r.complete) complete++ - if (r.case.webAgnostic) { - frameworkBalance[r.framework] = (frameworkBalance[r.framework] ?? 0) + 1 - } else { - fitOf++ - if (r.stackFit) fit++ - } - } - return { - total: results.length, - stackFit: { count: fit, of: fitOf }, - complete: { count: complete, of: results.length }, - frameworkBalance, - results: [...results], - } -} - -/** Options for {@link runArchitectBench}. */ -export interface RunArchitectBenchOptions { - driver: Driver - cases: readonly ArchitectBenchCase[] - /** Where the driver runs the (throwaway) architect turn. */ - cwd: string - model?: string - signal?: AbortSignal - onResult?: (result: ArchitectBenchResult, index: number) => void -} - -/** - * Run every case through {@link driverArchitect} and score it. One short-lived session per - * case, disposed after. No system prompt is injected: the architect prompt is self-contained, - * so this measures that prompt itself (the thing #499 changed), not the #326 framing around - * it. Sequential — an architect turn is not cheap and serial keeps the run stable. - */ -export async function runArchitectBench(opts: RunArchitectBenchOptions): Promise { - const results: ArchitectBenchResult[] = [] - for (const [index, benchCase] of opts.cases.entries()) { - if (opts.signal?.aborted) break - const session = await opts.driver.start({ - cwd: opts.cwd, - system: '', - ...(opts.model ? { model: opts.model } : {}), - ...(opts.signal ? { signal: opts.signal } : {}), - }) - try { - const architect = driverArchitect(session, {}) - // driverArchitect only reads ctx.intent; scope + ledger satisfy the contract with a - // fresh (empty) ledger so no prior decision steers the pick. - const plan = await architect({ intent: benchCase.intent, scope: 'full', ledger: new DecisionLedger() }) - const stack = plan.stack ?? '' - const result: ArchitectBenchResult = { - case: benchCase, - plan, - stack, - stackFit: isStackFit(benchCase, stack), - complete: isComplete(plan), - framework: detectFramework(stack), - } - results.push(result) - opts.onResult?.(result, index) - } finally { - await session.dispose() - } - } - return scoreArchitectBench(results) -} - -/** Render an {@link ArchitectBenchReport} as a compact human-readable summary. */ -export function formatArchitectBenchReport(report: ArchitectBenchReport): string { - const pct = (n: number, d: number) => (d === 0 ? 'n/a' : `${((n / d) * 100).toFixed(0)}%`) - const lines: string[] = [] - lines.push(`architect: ${report.stackFit.count}/${report.stackFit.of} sane stacks (${pct(report.stackFit.count, report.stackFit.of)})`) - lines.push(` honest tradeoffs (pros + cons + a rejected alternative): ${report.complete.count}/${report.complete.of} (${pct(report.complete.count, report.complete.of)})`) - const balance = Object.entries(report.frameworkBalance).sort((a, b) => b[1] - a[1]) - if (balance.length) { - lines.push(` framework balance on agnostic web apps (${balance.reduce((n, [, c]) => n + c, 0)} cases):`) - for (const [name, count] of balance) lines.push(` ${name.padEnd(12)} ${count}`) - } - const unfit = report.results.filter(r => !r.case.webAgnostic && !r.stackFit) - const incomplete = report.results.filter(r => !r.complete) - if (unfit.length) { - lines.push(' questionable stacks:') - for (const r of unfit) lines.push(` [${r.stack}] ${r.case.intent}`) - } - if (incomplete.length) { - lines.push(' missing tradeoffs:') - for (const r of incomplete) lines.push(` ${r.case.intent}`) - } - return lines.join('\n') -} - -const WEB = /\b(next(\.js)?|vike|nuxt|sveltekit|remix|astro|react|vue|svelte|solid(start)?|angular)\b/i -const SERVER = /\b(express|fastify|hono|nest(js)?|koa|adonis|node(\.js)?|django|flask|fastapi|rails|laravel|go\b|gin|rust|axum|spring)\b/i - -/** - * The labeled corpus. Two kinds: - * - **category cases** — an app whose *type* dictates the stack family (a CLI is not a web - * app; a mobile app is not Next.js). These test stack-fit with `accept` + `reject`. - * - **web-agnostic cases** — plain web apps where any modern framework is fine. They feed - * the framework-balance tally, the trust check that #499 was about. - * Labels are defensible, not gospel — the `why` is there to argue with. - */ -export const ARCHITECT_BENCH_CASES: readonly ArchitectBenchCase[] = [ - { - intent: 'A command-line tool that bulk-renames files with a --dry-run flag', - accept: [/\b(node(\.js)?|deno|bun|typescript|go\b|rust|python)\b/i], - reject: [WEB], - why: 'a CLI needs a runtime, not a web framework', - }, - { - intent: 'A JSON REST API for a todo list backed by Postgres, no frontend', - accept: [SERVER], - reject: [/\bastro\b|\bhugo\b|\beleventy\b|\bgatsby\b|create-react-app|vite \+ react/i], - why: 'a headless API wants a server framework, not a static-site generator or a SPA', - }, - { - intent: 'A cross-platform mobile app to track workouts offline', - accept: [/\b(react native|expo|flutter|ionic|capacitor|kotlin multiplatform)\b/i], - // Reject a *web frontend* framework used as the app shell (it would not be native), but - // not a server framework — a mobile app may legitimately have a backend. - reject: [/\b(next(\.js)?|vike|nuxt|astro|remix|sveltekit)\b/i], - why: 'a native mobile app needs a mobile framework, not a web frontend', - }, - { - intent: 'A desktop note-taking app with local file storage and a system tray', - // Accept requires a desktop shell; that alone is the test. No reject on web frameworks: - // Tauri / Electron legitimately embed a web frontend (React/Vike/etc.) inside the shell. - accept: [/\b(electron|tauri|neutralino|wails|\.net maui|qt)\b/i], - why: 'a desktop app wants a desktop shell (which may embed a web UI)', - }, - { - intent: 'A nightly Python job that scrapes prices and writes a CSV report', - accept: [/\b(python|node(\.js)?|typescript|go\b)\b/i], - reject: [WEB], - why: 'a batch script needs a runtime, not a UI framework', - }, - { - intent: 'A documentation site generated from markdown files', - accept: [/\b(astro|vike|next(\.js)?|nuxt|vitepress|docusaurus|eleventy|hugo|starlight|sveltekit)\b/i], - why: 'a docs site wants a static-site / content meta-framework — many are fine', - }, - // web-agnostic: any modern framework is a fine pick — these feed the balance tally. - { - intent: 'A web app to manage a personal book library with search', - accept: [WEB], - webAgnostic: true, - why: 'a straightforward CRUD web app; no framework is uniquely right', - }, - { - intent: 'A realtime team chat web app with channels and presence', - accept: [WEB], - webAgnostic: true, - why: 'realtime web app; several frameworks handle it well', - }, - { - intent: 'A web dashboard showing charts of sales data with filters', - accept: [WEB], - webAgnostic: true, - why: 'a data dashboard web app; framework choice is open', - }, - { - intent: 'A small marketing website with a contact form', - accept: [WEB], - webAgnostic: true, - why: 'a content site; many frameworks fit', - }, - { - intent: 'A web-based kanban board with drag-and-drop cards', - accept: [WEB], - webAgnostic: true, - why: 'an interactive web app; framework-agnostic', - }, - { - intent: 'An online storefront with a product catalog and cart', - accept: [WEB], - webAgnostic: true, - why: 'an e-commerce web app; several stacks are reasonable', - }, -] diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index dfa4e8f8..7bda070c 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -565,7 +565,8 @@ test('runCli --fake --no-dashboard runs the whole flow offline to production-gra const code = await runCli(['--fake', '--no-dashboard'], io) assert.equal(code, 0) const text = out.join('\n') - assert.match(text, /architect:/) + assert.match(text, /scope: full/) // the run opens on scope now that the architect is gone + assert.match(text, /Build this app end to end/) assert.match(text, /checklist pass 1/) assert.match(text, /production-grade/) assert.match(text, /deploy: SSR/) @@ -591,9 +592,8 @@ test('a live daemon steers a dashboard-less run through its gates via control.js let settled = false const done = runCli(['--fake', '--no-dashboard', '--cwd', cwd], io).finally(() => (settled = true)) - // Play the daemon: tail events.jsonl for parked gates (plan-approval, then the - // build's await-choices) and answer each with its recommended pick, exactly as - // the daemon page's Accept button would. + // Play the daemon: tail events.jsonl for the build's parked await-choices gate and + // answer it with its recommended pick, exactly as the daemon page's Accept button would. const answered = new Set() const eventsPath = join(cwd, FRAMEWORK_DIR, EVENTS_FILE) for (let i = 0; i < 500 && !settled; i++) { @@ -613,16 +613,15 @@ test('a live daemon steers a dashboard-less run through its gates via control.js } assert.equal(await done, 0) - assert.ok(answered.has('plan-approval'), 'the plan gate parked and was steered') assert.ok(answered.has('await-choices'), 'the build await gate parked and was steered') assert.match(out.join('\n'), /production-grade/) - // Both resolutions were attributed to the steering user, not a headless auto-accept. + // The resolution was attributed to the steering user, not a headless auto-accept. const resolved = (await readFile(eventsPath, 'utf8')) .split('\n') .filter(Boolean) .map(l => JSON.parse(l)) .filter((e: { kind: string }) => e.kind === 'choice-resolved') - assert.equal(resolved.length, 2) + assert.equal(resolved.length, 1) assert.ok(resolved.every((e: { by: string }) => e.by === 'user')) } finally { if (prevAwait === undefined) delete process.env.FRAMEWORK_FAKE_AWAIT diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index ba063bb0..bc30bea8 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -7,8 +7,6 @@ import { builtinDomainPresets, cloudflareTarget, dokployTarget, - nodeLedgerFs, - saveLedger, selectPreset, type DeployTarget, type DomainPreset, @@ -213,8 +211,7 @@ Options: The Framework drives the wrapped agent as a black box: it prompts, reads the code, and gates on the outcome (builds / serves / review-passes), then re-prompts. The -localhost dashboard foregrounds the stack rationale, the loop status, and the -decisions ledger beside the agent's own session.` +localhost dashboard foregrounds the loop status beside the agent's own session.` /** Parsed CLI options. */ export interface CliOptions { @@ -1009,7 +1006,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise` (#353): the // direct prompt path — run one prompt through runPrompt, which honors its gates - // (#337/#339) but skips the scope -> architect -> build scaffolding entirely. + // (#337/#339) but skips the scope -> build scaffolding entirely. // Research renders its preset template around the "what"; prompt runs the text // verbatim (it may already BE an edited preset, so it must not be re-rendered). // Shares all the wiring above (dashboard, store, control channel, budget). @@ -1196,7 +1193,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { return 0 } -/** - * Persist the decisions ledger as a human-readable `DECISIONS.md` at the - * workspace root, reusing ai-autopilot's markdown store. Best-effort: a write - * failure is reported but never fails the run. - */ -async function writeDecisions(cwd: string, ledger: RunFrameworkResult['ledger'], io: CliIO): Promise { - if (ledger.all().length === 0) return - try { - await saveLedger(nodeLedgerFs(), ledger, join(cwd, 'DECISIONS.md')) - } catch (err) { - io.err(`could not write DECISIONS.md (${err instanceof Error ? err.message : String(err)})`) - } -} - /** * Build a real {@link DeployTarget} for a known target name, or return an error / * nothing. `cloudflare` runs `wrangler` via a host executor bound to the build's diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index 77b7edd6..0de6f4f9 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -4,14 +4,10 @@ // sandbox, node:fs/http, …) into the browser bundle. Types come from the root entry. export { formatFrameworkEvent, pickedIds } from './events.js' export { - architectPlan, - decisionLedger, loopStatus, sessionInfo, deployPlan, runProgress, - type ArchitectPlan, - type Decision, type LoopStatus, type SessionInfo, type DeployPlan, diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts index a5b94682..9435791b 100644 --- a/packages/framework/src/events.ts +++ b/packages/framework/src/events.ts @@ -68,7 +68,7 @@ export function pickedIds(picked: string | readonly string[]): string[] { /** * The single event type the whole run streams over. It unifies three sources so * the dashboard (and terminal) render one timeline: bootstrap-phase narration - * (the moat: architect rationale, checklist verdicts, deploy), the wrapped + * (the moat: checklist verdicts, deploy), the wrapped * agent's own black-box progress, and framework-level status. We own this stream * (guardrail #2, #165) rather than surfacing the agent's transport directly. */ @@ -91,7 +91,7 @@ export type FrameworkEvent = * gated on. */ | { kind: 'system-prompt'; text: string } - /** A bootstrap-phase narration event (scope / architect / checklist / deploy / ...). */ + /** A bootstrap-phase narration event (scope / checklist / deploy / ...). */ | { kind: 'bootstrap'; event: BootstrapEvent } /** The wrapped agent's own progress, forwarded verbatim (never gated on). */ | { kind: 'driver'; event: DriverEvent } @@ -244,8 +244,6 @@ function formatBootstrapEvent(event: BootstrapEvent): string { switch (event.type) { case 'scope': return `▶ scope: ${event.scope} — "${event.intent}"` - case 'architect': - return `▶ architect: ${event.stack}\n${event.decisions.map(d => ` · ${d.choice} — ${d.why}`).join('\n')}` case 'narrate': return ` ${event.message}` case 'build': diff --git a/packages/framework/src/fake-script.test.ts b/packages/framework/src/fake-script.test.ts index 48a8072e..9a3043f0 100644 --- a/packages/framework/src/fake-script.test.ts +++ b/packages/framework/src/fake-script.test.ts @@ -5,25 +5,25 @@ import { parseChoicesGate, parseMultiSelectGate } from './turn-gate.js' test('demoTurns default: the plain scripted run, no await gate', () => { const turns = demoTurns(undefined) - assert.equal(turns.length, 5) // architect, build, checklist-blocker, improve, clean + assert.equal(turns.length, 4) // build, checklist-blocker, improve, clean assert.ok(turns.every(t => parseChoicesGate(t.text) === undefined && parseMultiSelectGate(t.text) === undefined)) }) test('demoTurns choices: the build stops to ask a single-select the gate can parse (#337)', () => { const turns = demoTurns('choices') - assert.equal(turns.length, 6) // + a resume turn after the ask - const gate = parseChoicesGate(turns[1]!.text) // the build turn asks + assert.equal(turns.length, 5) // + a resume turn after the ask + const gate = parseChoicesGate(turns[0]!.text) // the build turn asks assert.ok(gate) assert.equal(gate.recommended, 'opt:0') assert.ok(gate.options.some(o => /Session cookies/.test(o.label))) // The turn right after the ask is the resume (no gate), so the run continues. - assert.equal(parseChoicesGate(turns[2]!.text), undefined) + assert.equal(parseChoicesGate(turns[1]!.text), undefined) }) test('demoTurns multiselect: the build stops to ask a checklist with defaults (#339)', () => { const turns = demoTurns('multiselect') - assert.equal(turns.length, 6) - const gate = parseMultiSelectGate(turns[1]!.text) + assert.equal(turns.length, 5) + const gate = parseMultiSelectGate(turns[0]!.text) assert.ok(gate) const defaults = gate.options.filter(o => o.default).map(o => o.label) assert.deepEqual(defaults, ['auth model', 'orders schema']) diff --git a/packages/framework/src/fake-script.ts b/packages/framework/src/fake-script.ts index f6526877..3a0b9918 100644 --- a/packages/framework/src/fake-script.ts +++ b/packages/framework/src/fake-script.ts @@ -5,10 +5,10 @@ import type { DeployDecision } from './run.js' /** * The deterministic `--fake` scenario: a small Vike + Prisma orders app. * It wires a {@link FakeDriver} whose scripted turns walk the exact prompt order - * the flow issues (architect JSON, build summary, checklist-with-blocker, - * improve, clean checklist), so the whole scope -> deploy flow runs offline with - * no CLI and no model, ending production-grade. Mirrors the ai-autopilot - * bootstrap-quickstart, but driven entirely *through* the driver seam. + * the flow issues (build summary, checklist-with-blocker, improve, clean + * checklist), so the whole scope -> deploy flow runs offline with no CLI and no + * model, ending production-grade. Mirrors the ai-autopilot bootstrap-quickstart, + * but driven entirely *through* the driver seam. */ /** The default intent the `--fake` demo builds. */ @@ -26,25 +26,9 @@ export const FAKE_DEPLOY: DeployDecision = { reason: 'per-request orders data + server-side auth', } -const ARCHITECT = { - stack: 'Vike + Prisma on Postgres, with vike-auth', - narration: 'Server-rendered orders app: Vike pages, a Prisma data layer, sessions via vike-auth.', - decisions: [ - { choice: 'Prisma on Postgres', why: 'the orders catalog is relational and needs typed queries' }, - { choice: 'SSR over SPA', why: 'orders need per-request data and auth on the server' }, - ], - pros: [ - 'Deploys to the edge (Cloudflare) for low-latency per-request orders data', - 'Renderer-agnostic, so the UI is not locked to one framework', - ], - cons: ['Smaller ecosystem than Next.js', 'Fewer batteries-included conventions, so more is wired by hand'], - alternatives: [{ option: 'Next.js', whyNot: 'more constrained Cloudflare/edge deploy for the per-request data path' }], -} - // A small, plausible per-turn usage so the demo shows spend accumulating (#322). const FAKE_USAGE = { costUsd: 0.02, inputTokens: 1800, outputTokens: 600, cacheReadTokens: 12000, cacheCreationTokens: 800 } -const ARCHITECT_TURN: FakeTurn = { text: '```json\n' + JSON.stringify(ARCHITECT) + '\n```', actions: ['Read'], usage: FAKE_USAGE } const BUILD_TURN: FakeTurn = { text: 'Built the orders app: an orders schema and migration, a paginated /orders page, and a sign-in stub.', actions: ['Write', 'Write', 'Bash'], @@ -58,7 +42,7 @@ const CHECKLIST_BLOCKER: FakeTurn = { const IMPROVE: FakeTurn = { text: 'Added a +guard to the orders page (vike-auth) so it requires a signed-in user.', actions: ['Edit'], usage: FAKE_USAGE } const CHECKLIST_CLEAN: FakeTurn = { text: 'Reviewed again.\n```json\n{ "blockers": [] }\n```', actions: ['Read'], usage: FAKE_USAGE } -const TURNS: FakeTurn[] = [ARCHITECT_TURN, BUILD_TURN, CHECKLIST_BLOCKER, IMPROVE, CHECKLIST_CLEAN] +const TURNS: FakeTurn[] = [BUILD_TURN, CHECKLIST_BLOCKER, IMPROVE, CHECKLIST_CLEAN] // Demo variants that make the build stop to ask, so the turn-boundary gates (#337 // single-select / #339 multi-select checklist) can be seen offline. The build turn ends @@ -98,7 +82,7 @@ export function demoTurns(awaitMode: string | undefined): FakeTurn[] { if (!askTurn) return TURNS // The build asks (askTurn), the gate resolves, the framework re-prompts (RESUME_TURN), // then the normal review turns follow. - return [ARCHITECT_TURN, askTurn, RESUME_TURN, CHECKLIST_BLOCKER, IMPROVE, CHECKLIST_CLEAN] + return [askTurn, RESUME_TURN, CHECKLIST_BLOCKER, IMPROVE, CHECKLIST_CLEAN] } /** Build the scripted {@link FakeDriver} for the demo. */ diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 00d41bd8..ae079a32 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -5,7 +5,7 @@ * dashboard that foregrounds the orchestration the agent's own chat cannot show. * * The whole product is built on `@gemstack/ai-autopilot`'s already-shipped - * spine (bootstrap, the loop, the decisions ledger, presets, deploy targets); + * spine (bootstrap, the loop, presets, deploy targets); * this package adds the two missing pieces from #166: the **driver** seam that * wraps the agent, and the **product shell** (CLI + dashboard) that drives it. * @@ -21,10 +21,10 @@ * * ## Driver-backed steps * ai-autopilot's `Bootstrap` steps, re-implemented to run everything *through* - * the driver (option A): the architect is a structured JSON decision, build / - * improve are prompts, the checklist gates on the `{ blockers }` verdict. + * the driver (option A): build / improve are prompts, the checklist gates on the + * `{ blockers }` verdict. * - * - {@link driverArchitect} / {@link driverBuild} / {@link driverChecklist} / {@link driverImprove} + * - {@link driverBuild} / {@link driverChecklist} / {@link driverImprove} * * ## Run + product shell * - {@link runFramework} — detect the preset, frame the agent with its personas, @@ -34,15 +34,11 @@ */ export * from './driver/index.js' export { - driverArchitect, - reArchitect, driverBuild, driverChecklist, driverImprove, decideDeploy, deployWith, - parseArchitectPlan, - architectPrompt, buildPrompt, extendPrompt, improvePrompt, @@ -119,13 +115,9 @@ export { OPEN_LOOP_MODES, } from './events.js' export { - architectPlan, - decisionLedger, loopStatus, sessionInfo, runProgress, - type ArchitectPlan, - type Decision, type LoopStatus, type SessionInfo, type RunProgress, diff --git a/packages/framework/src/memory.test.ts b/packages/framework/src/memory.test.ts index bca55986..b7adbc64 100644 --- a/packages/framework/src/memory.test.ts +++ b/packages/framework/src/memory.test.ts @@ -26,17 +26,13 @@ test('memoryFraming is empty for an empty list', () => { assert.equal(memoryFraming([]), '') }) -test('memoryFraming separates agent-owned files from framework-owned, and shows current contents', () => { +test('memoryFraming lists every file to keep up to date, and shows current contents', () => { const framing = memoryFraming([ { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase', content: 'It is a blog.' }, { name: 'KNOWLEDGE-BASE.md', purpose: 'durable facts' }, // absent - { name: 'DECISIONS.md', purpose: 'the decision log', agentMaintained: false }, ]) - // agent-owned files land under the "keep up to date" instruction - assert.match(framing, /Keep these up to date[\s\S]*CODE-OVERVIEW\.md/) - // DECISIONS.md is flagged read-only so the agent will not clobber our ledger write - assert.match(framing, /Read-only[\s\S]*DECISIONS\.md/) - assert.doesNotMatch(framing.split('Read-only')[0]!, /DECISIONS\.md/) // not in the owned list + // every file is agent-maintained: one list under the "keep up to date" instruction + assert.match(framing, /Keep these up to date[\s\S]*CODE-OVERVIEW\.md[\s\S]*KNOWLEDGE-BASE\.md/) // present contents are inlined assert.match(framing, /### CODE-OVERVIEW\.md\nIt is a blog\./) }) diff --git a/packages/framework/src/memory.ts b/packages/framework/src/memory.ts index b1eb425a..62beaaa5 100644 --- a/packages/framework/src/memory.ts +++ b/packages/framework/src/memory.ts @@ -12,12 +12,6 @@ export interface MemoryFile { name: string /** What it holds, shown to the agent so it knows what belongs where. */ purpose: string - /** - * False when The Framework owns the file and the agent must not edit it (it may - * still read it for context). `DECISIONS.md` is written from our decisions - * ledger at run end, so an agent edit would be clobbered. Default `true`. - */ - agentMaintained?: boolean } /** The canonical repo memory files, in the order they are framed. */ @@ -25,8 +19,6 @@ export const MEMORY_FILES: readonly MemoryFile[] = [ { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase: structure, key modules, and how they fit together' }, { name: 'KNOWLEDGE-BASE.md', purpose: 'durable facts and conventions learned about this project' }, { name: 'BRAINSTORMING.md', purpose: 'open ideas and things to explore later' }, - // Framework-owned: written from the decisions ledger at run end. Read-only to the agent. - { name: 'DECISIONS.md', purpose: 'the running log of decisions and why', agentMaintained: false }, ] /** A memory file paired with its current contents. */ @@ -59,27 +51,19 @@ export async function loadRepoMemory( /** * Build the system-prompt block that turns the repo's special files into the * agent's persistent memory: the current contents of any files present (so the - * agent starts with that context), plus an instruction to keep the ones it owns - * current. Returns `''` when there is nothing to frame. + * agent starts with that context), plus an instruction to keep them current. + * Returns `''` when there is nothing to frame. */ export function memoryFraming(memories: readonly LoadedMemory[]): string { if (memories.length === 0) return '' - const owned = memories.filter(m => m.agentMaintained !== false) - const framework = memories.filter(m => m.agentMaintained === false) const present = memories.filter(m => m.content) const lines: string[] = ['## Project memory'] lines.push( "This project keeps its long-term memory in these files at the repo root. Read them for context before you start.", ) - if (owned.length) { - lines.push('\nKeep these up to date as you work, creating one when it does not exist yet:') - for (const m of owned) lines.push(`- ${m.name}: ${m.purpose}`) - } - if (framework.length) { - lines.push('\nRead-only (The Framework writes these, do not edit them):') - for (const m of framework) lines.push(`- ${m.name}: ${m.purpose}`) - } + lines.push('\nKeep these up to date as you work, creating one when it does not exist yet:') + for (const m of memories) lines.push(`- ${m.name}: ${m.purpose}`) if (present.length) { lines.push('\nCurrent contents:') for (const m of present) lines.push(`\n### ${m.name}\n${m.content}`) diff --git a/packages/framework/src/prompt-run.ts b/packages/framework/src/prompt-run.ts index 6ffe4e47..cd0bde2d 100644 --- a/packages/framework/src/prompt-run.ts +++ b/packages/framework/src/prompt-run.ts @@ -9,7 +9,7 @@ import { leaveResumeNote } from './todo-loop.js' /** * The direct prompt path (#331): run *one prompt* through the driver and honor - * its await gates — no scope/architect/build scaffolding, no review loop. This + * its await gates — no scope/build scaffolding, no review loop. This * is what a review-shaped preset like [Research] needs: the prompt operates on * existing code, stops at `showChoices()` / `showMultiSelect()` + AWAIT, and * continues from the user's answer. Plumbing, not babysitting — the framework diff --git a/packages/framework/src/readability-preset.ts b/packages/framework/src/readability-preset.ts index c59413a3..8071d13c 100644 --- a/packages/framework/src/readability-preset.ts +++ b/packages/framework/src/readability-preset.ts @@ -3,7 +3,7 @@ import { renderPresetPrompt, type PresetParam } from './preset-params.js' /** * The [Readability] preset (#360): Rom's refactor-for-human-readers pass, shipped * as a direct prompt like [Research] (#331) — it reworks existing code, so it - * skips the scope -> architect -> build scaffolding. `` is the + * skips the scope -> build scaffolding. `` is the * user-facing blank (#330); `` is an agent-facing macro defined at the * bottom of the prompt itself, like the Research preset's CAPS tokens. */ diff --git a/packages/framework/src/research-preset.ts b/packages/framework/src/research-preset.ts index 7361a2d8..76e49fb2 100644 --- a/packages/framework/src/research-preset.ts +++ b/packages/framework/src/research-preset.ts @@ -3,7 +3,7 @@ import { renderPresetPrompt, type PresetParam } from './preset-params.js' /** * The [Research] preset (#331): Rom's problem-variability review, shipped as a * direct prompt (see `runPrompt`) rather than a build run — research reviews - * existing code, so it skips the scope -> architect -> build scaffolding. The + * existing code, so it skips the scope -> build scaffolding. The * `` placeholder is the user-facing blank (#330); the CAPS tokens * (``, ``, …) are agent-facing macros defined at the bottom * of the prompt itself, and `showMultiSelect()` + `` becomes a live diff --git a/packages/framework/src/run-view.test.ts b/packages/framework/src/run-view.test.ts index 9354e404..1a39df60 100644 --- a/packages/framework/src/run-view.test.ts +++ b/packages/framework/src/run-view.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { architectPlan, decisionLedger, loopStatus, sessionInfo, deployPlan, runProgress } from './run-view.js' +import { loopStatus, sessionInfo, deployPlan, runProgress } from './run-view.js' import type { FrameworkEvent } from './events.js' test('runProgress starts building with no name and flips to ready on setReadyForMerge (#326)', () => { @@ -19,51 +19,6 @@ test('runProgress takes the latest session name when the agent renames it (#326) assert.equal(runProgress(events).sessionName, 'better-name') }) -const architect = (stack: string, extra: Record = {}): FrameworkEvent => ({ - kind: 'bootstrap', - event: { type: 'architect', stack, decisions: [], ...extra } as never, -}) - -test('architectPlan returns the chosen stack + rationale from the architect event (#431)', () => { - const events: FrameworkEvent[] = [ - { kind: 'log', message: 'hi' }, - architect('Vike + Prisma', { - decisions: [{ choice: 'Prisma on Postgres', why: 'relational catalog' }], - pros: ['edge deploy'], - cons: ['smaller ecosystem'], - alternatives: [{ option: 'Next.js', whyNot: 'constrained edge deploy' }], - }), - ] - const plan = architectPlan(events) - assert.equal(plan?.stack, 'Vike + Prisma') - assert.deepEqual(plan?.pros, ['edge deploy']) - assert.deepEqual(plan?.cons, ['smaller ecosystem']) - assert.deepEqual(plan?.decisions, [{ choice: 'Prisma on Postgres', why: 'relational catalog' }]) - assert.deepEqual(plan?.alternatives, [{ option: 'Next.js', whyNot: 'constrained edge deploy' }]) -}) - -test('architectPlan is null before the architect runs; latest wins on re-architect', () => { - assert.equal(architectPlan([{ kind: 'log', message: 'x' }]), null) - const plan = architectPlan([architect('Vike'), architect('Next.js')]) - assert.equal(plan?.stack, 'Next.js') // #324 re-architect supersedes -}) - -test('decisionLedger accumulates decisions then lists rejected alternatives (#431)', () => { - const events: FrameworkEvent[] = [ - architect('Vike', { decisions: [{ choice: 'Vike for SSR', why: 'SEO' }] }), - architect('Vike + auth', { - decisions: [{ choice: 'vike-auth', why: 'sessions' }], - alternatives: [{ option: 'Next.js', whyNot: 'edge deploy' }], - }), - ] - const ledger = decisionLedger(events) - assert.deepEqual(ledger, [ - { choice: 'Vike for SSR', why: 'SEO', rejected: false }, - { choice: 'vike-auth', why: 'sessions', rejected: false }, - { choice: 'Next.js', why: 'edge deploy', rejected: true }, - ]) -}) - test('loopStatus tracks the latest checklist verdict and closes on done (#431)', () => { const boot = (event: Record): FrameworkEvent => ({ kind: 'bootstrap', event: event as never }) assert.equal(loopStatus([{ kind: 'log', message: 'x' }]), null) // no checklist yet diff --git a/packages/framework/src/run-view.ts b/packages/framework/src/run-view.ts index 925f0532..d6f4f1d6 100644 --- a/packages/framework/src/run-view.ts +++ b/packages/framework/src/run-view.ts @@ -1,64 +1,11 @@ import type { FrameworkEvent } from './events.js' -// Derived run state for the dashboard's overview cards (#431): the chosen stack + its -// rationale, the decisions ledger, the production-grade loop status, and the live session -// link — each a pure projection of the same FrameworkEvent stream the log renders, so the -// live dashboard and a past-run replay show the identical summary. Kept here (not in the -// dashboard) so it is unit-tested against the real event shapes. The bootstrap phase -// (architect/checklist/deploy) carries the structured data; we surface it as cards. - -/** The architect's chosen stack and why (#431), from the last `architect` bootstrap event. */ -export interface ArchitectPlan { - stack: string - decisions: { choice: string; why: string }[] - pros: string[] - cons: string[] - alternatives: { option: string; whyNot: string }[] -} - -/** The chosen stack + rationale, or null before the architect has run. Latest wins (#324 re-architect). */ -export function architectPlan(events: readonly FrameworkEvent[]): ArchitectPlan | null { - for (let i = events.length - 1; i >= 0; i--) { - const event = events[i]! - if (event.kind !== 'bootstrap' || event.event.type !== 'architect') continue - const a = event.event - return { - stack: a.stack, - decisions: a.decisions.map(d => ({ choice: d.choice, why: d.why })), - pros: [...(a.pros ?? [])], - cons: [...(a.cons ?? [])], - alternatives: (a.alternatives ?? []).map(x => ({ option: x.option, whyNot: x.whyNot })), - } - } - return null -} - -/** One row of the decisions ledger: a made choice, or a considered-and-rejected alternative. */ -export interface Decision { - choice: string - why: string - rejected: boolean -} - -/** - * The decisions ledger (#431): every architect decision in the order made (a later - * re-architect of the same choice supersedes the earlier one), then the latest plan's - * rejected alternatives. Empty until the architect has run. - */ -export function decisionLedger(events: readonly FrameworkEvent[]): Decision[] { - const made = new Map() // choice -> why, insertion-ordered, last write wins the reason - for (const event of events) { - if (event.kind !== 'bootstrap' || event.event.type !== 'architect') continue - for (const d of event.event.decisions) { - made.delete(d.choice) - made.set(d.choice, d.why) - } - } - const decisions: Decision[] = [...made].map(([choice, why]) => ({ choice, why, rejected: false })) - const plan = architectPlan(events) - for (const alt of plan?.alternatives ?? []) decisions.push({ choice: alt.option, why: alt.whyNot, rejected: true }) - return decisions -} +// Derived run state for the dashboard's overview cards (#431): the production-grade +// loop status, the deploy plan, and the live session link — each a pure projection of +// the same FrameworkEvent stream the log renders, so the live dashboard and a past-run +// replay show the identical summary. Kept here (not in the dashboard) so it is +// unit-tested against the real event shapes. The bootstrap phase (checklist/deploy) +// carries the structured data; we surface it as cards. /** The production-grade loop status (#431): the current pass, whether it passed, and any blockers. */ export interface LoopStatus { diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index aab1b25c..2a386b95 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -44,10 +44,6 @@ test('runFramework drives the whole flow through the driver, offline, to product assert.equal(result.passes, 2) assert.deepEqual(result.blockers, []) - // The architect's choices were recorded and narrated. - const architect = events.find(e => e.kind === 'bootstrap' && e.event.type === 'architect') - assert.ok(architect) - // The deploy phase decided SSR -> cloudflare. assert.equal(result.deploy?.plan.target, 'cloudflare') @@ -129,7 +125,7 @@ test('runFramework accumulates per-turn usage and emits a running total (#322)', test('runFramework stops itself once the budget cap is reached (#322)', async () => { const events: FrameworkEvent[] = [] - // $0.01 cap trips on the very first $0.02 turn (the architect). + // $0.01 cap trips on the very first $0.02 turn (the build). await assert.rejects( runFramework({ intent: FAKE_INTENT, @@ -157,26 +153,6 @@ test('runFramework stops itself once the budget cap is reached (#322)', async () assert.ok(!events.some(e => e.kind === 'bootstrap' && e.event.type === 'done')) }) -test('a plan-approval gate parked for a pick unblocks when the budget trips (#322)', async () => { - const events: FrameworkEvent[] = [] - // The gate awaits a pick that never arrives; the budget cap trips on the first - // (architect) turn and must unblock it rather than hang the run. - await assert.rejects( - runFramework({ - intent: FAKE_INTENT, - driver: fakeDriver(), - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - budgetUsd: 0.01, - requestChoice: () => new Promise(() => {}), - onEvent: e => events.push(e), - }), - ) - assert.ok(events.some(e => e.kind === 'choice')) - const end = events.at(-1)! - assert.equal(end.kind === 'end' && end.stopped, true) -}) - test('runFramework shows a literal session link immediately (no template)', async () => { const events: FrameworkEvent[] = [] await runFramework({ @@ -277,16 +253,12 @@ test('repo memory files frame the agent: contents + a maintain instruction (#260 driver, cwd: '/tmp/ws', signals: FAKE_SIGNALS, - memory: [ - { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase', content: 'A blog with comments.' }, - { name: 'DECISIONS.md', purpose: 'the decision log', agentMaintained: false }, - ], + memory: [{ name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase', content: 'A blog with comments.' }], onEvent: () => {}, }) assert.match(system(), /Project memory/) assert.match(system(), /A blog with comments\./) // contents inlined as context assert.match(system(), /Keep these up to date[\s\S]*CODE-OVERVIEW\.md/) - assert.match(system(), /Read-only[\s\S]*DECISIONS\.md/) // agent must not clobber our ledger write }) test('no memory option leaves the framing unchanged (#260)', async () => { @@ -319,7 +291,6 @@ test("a domain preset's loop drives the production-grade review phase (#252)", a const events: FrameworkEvent[] = [] const driver = new FakeDriver({ turns: [ - { text: '```json\n{"stack":"X","narration":"n","decisions":[]}\n```' }, // architect { text: 'Built the app.' }, // build { text: 'Reviewed.\n```json\n{"blockers":[]}\n```' }, // the domain review prompt, clean ], @@ -342,7 +313,6 @@ test("a domain preset's loop drives the production-grade review phase (#252)", a test('the domain review loop blocks, improve runs, then it clears (#252)', async () => { const driver = new FakeDriver({ turns: [ - { text: '```json\n{"stack":"X","narration":"n","decisions":[]}\n```' }, // architect { text: 'Built the app.' }, // build { text: 'Reviewed.\n```json\n{"blockers":["needs error handling"]}\n```' }, // review, pass 1: blocks { text: 'Added error handling.' }, // improve @@ -391,7 +361,6 @@ function promptRecordingDriver(): { driver: Driver; prompts: () => string[] } { const sent: string[] = [] const inner = new FakeDriver({ turns: [ - { text: '```json\n{"stack":"X","narration":"n","decisions":[]}\n```' }, // architect { text: 'Built the app.' }, // build { text: 'Reviewed.\n```json\n{"blockers":[]}\n```' }, // review, clean ], @@ -507,152 +476,6 @@ test('runFramework prototype scope skips the full-fledged loop', async () => { assert.equal(result.productionGrade, false) }) -test('the plan-approval gate (#304) pauses on a choice, proceeds, and keeps the plan', async () => { - const events: FrameworkEvent[] = [] - const seen: ChoiceRequest[] = [] - const { result } = await runFramework({ - intent: FAKE_INTENT, - driver: fakeDriver(), - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - deploy: FAKE_DEPLOY, - onEvent: e => events.push(e), - requestChoice: async req => { - seen.push(req) - return { picked: 'proceed', by: 'user' } - }, - }) - - // The gate emitted one choice, offering "proceed" (recommended) plus the - // architect's alternative as "Use Next.js instead". - assert.equal(seen.length, 1) - const choice = events.find(e => e.kind === 'choice') - assert.ok(choice && choice.kind === 'choice') - assert.equal(choice.recommended, 'proceed') - assert.ok(choice.options.some(o => o.id === 'proceed')) - assert.ok(choice.options.some(o => o.id === 'alt:0' && /Next\.js/.test(o.label))) - - // It resolved to the pick, and the choice came before the architect narration. - const resolved = events.find(e => e.kind === 'choice-resolved') - assert.ok(resolved && resolved.kind === 'choice-resolved') - assert.equal(resolved.picked, 'proceed') - assert.equal(resolved.by, 'user') - const ci = events.findIndex(e => e.kind === 'choice') - const ai = events.findIndex(e => e.kind === 'bootstrap' && e.event.type === 'architect') - assert.ok(ci >= 0 && ai > ci) - - // Proceeding kept the original stack and still reached production-grade. - const architect = events.find(e => e.kind === 'bootstrap' && e.event.type === 'architect') - assert.ok(architect && architect.kind === 'bootstrap' && architect.event.type === 'architect') - assert.match(architect.event.stack, /Vike \+ Prisma/) - assert.equal(result.productionGrade, true) -}) - -test('without a requestChoice handler no choice events are emitted (#304)', async () => { - const events: FrameworkEvent[] = [] - await runFramework({ - intent: FAKE_INTENT, - driver: fakeDriver(), - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - onEvent: e => events.push(e), - }) - assert.equal(events.some(e => e.kind === 'choice'), false) - assert.equal(events.some(e => e.kind === 'choice-resolved'), false) -}) - -test('picking an alternative re-architects the run around it (#304)', async () => { - const first = { - stack: 'Vike + Prisma', - narration: 'first plan', - decisions: [{ choice: 'SSR', why: 'per-request data' }], - alternatives: [{ option: 'Next.js', whyNot: 'edge deploy is more constrained' }], - } - const second = { - stack: 'Next.js + Postgres', - narration: 'second plan', - decisions: [{ choice: 'App Router', why: 'the user chose it' }], - alternatives: [], - } - // A driver that answers by prompt: the steered re-architect returns the Next.js - // plan, the first architect the Vike plan, the checklist passes clean. - const driver = new FakeDriver({ - respond: (prompt: string): string => { - if (/You are the architect/.test(prompt) && /prefers Next\.js/.test(prompt)) - return '```json\n' + JSON.stringify(second) + '\n```' - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(first) + '\n```' - if (/production-grade checklist/.test(prompt)) return 'Reviewed.\n```json\n{ "blockers": [] }\n```' - return 'done' - }, - sessionId: 'fake-realt', - }) - - const events: FrameworkEvent[] = [] - const { result } = await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - onEvent: e => events.push(e), - requestChoice: async () => ({ picked: 'alt:0', by: 'user' }), - }) - - // Re-architecting was narrated, and the final narrated stack is the alternative. - assert.ok(events.some(e => e.kind === 'log' && /Re-architecting around your choice: Next\.js/.test(e.message))) - const architect = events.find(e => e.kind === 'bootstrap' && e.event.type === 'architect') - assert.ok(architect && architect.kind === 'bootstrap' && architect.event.type === 'architect') - assert.match(architect.event.stack, /Next\.js \+ Postgres/) - assert.equal(result.productionGrade, true) -}) - -test('the gate re-fires to approve the re-architected plan (#324)', async () => { - const first = { - stack: 'Vike + Prisma', - narration: 'first plan', - decisions: [{ choice: 'SSR', why: 'per-request data' }], - alternatives: [{ option: 'Next.js', whyNot: 'edge deploy is more constrained' }], - } - const second = { - stack: 'Next.js + Postgres', - narration: 'second plan', - decisions: [{ choice: 'App Router', why: 'the user chose it' }], - alternatives: [{ option: 'Remix', whyNot: 'smaller ecosystem' }], - } - const driver = new FakeDriver({ - respond: (prompt: string): string => { - if (/You are the architect/.test(prompt) && /prefers Next\.js/.test(prompt)) - return '```json\n' + JSON.stringify(second) + '\n```' - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(first) + '\n```' - if (/production-grade checklist/.test(prompt)) return 'Reviewed.\n```json\n{ "blockers": [] }\n```' - return 'done' - }, - sessionId: 'refire', - }) - - const events: FrameworkEvent[] = [] - // Round 0: pick the alternative (Next.js). Round 1: approve the re-architected plan. - const picks = ['alt:0', 'proceed'] - let round = 0 - const { result } = await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - onEvent: e => events.push(e), - requestChoice: async () => ({ picked: picks[round++] ?? 'proceed', by: 'user' as const }), - }) - - // The gate fired twice: once on the initial plan, once on the re-architected one. - const choices = events.filter(e => e.kind === 'choice') - assert.equal(choices.length, 2) - assert.equal(choices[0]!.kind === 'choice' && choices[0]!.id, 'plan-approval') - assert.equal(choices[1]!.kind === 'choice' && choices[1]!.id, 'plan-approval-1') - // The second gate offered the re-architected (Next.js) plan for approval. - assert.ok(choices[1]!.kind === 'choice' && /Next\.js \+ Postgres/.test(choices[1]!.options[0]!.label)) - assert.ok(events.some(e => e.kind === 'log' && /Re-architecting around your choice: Next\.js/.test(e.message))) - assert.equal(result.productionGrade, true) -}) - const MS_OPTS: MultiSelectOption[] = [ { id: 'p0', label: 'auth flow', default: true }, { id: 'p1', label: 'routing' }, @@ -719,19 +542,12 @@ test('requestMultiSelect can resolve to an empty set when the user checks nothin }) test('a build turn that stops to ask fires a live gate and resumes on the pick (#337)', async () => { - const plan = { - stack: 'Vike + Prisma', - narration: 'a plan', - decisions: [{ choice: 'SSR', why: 'per-request data' }], - alternatives: [], - } const awaitBlock = 'I need a decision first.\n```await-choices\n' + '{ "title": "Which data store?", "options": [{ "id": "sqlite", "label": "SQLite" }, { "id": "pg", "label": "Postgres" }], "recommended": "sqlite" }\n' + '```' const driver = new FakeDriver({ respond: (prompt: string): string => { - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(plan) + '\n```' if (/Build this app end to end/.test(prompt)) return awaitBlock // the build stops to ask if (/You paused to ask/.test(prompt)) return 'Built it with Postgres. Done.' // the resume if (/production-grade checklist/.test(prompt)) return 'Reviewed.\n```json\n{ "blockers": [] }\n```' @@ -755,7 +571,6 @@ test('a build turn that stops to ask fires a live gate and resumes on the pick ( }) // The agent-authored choice surfaced as a live gate with the offered options. - // (The architect plan-approval gate #304 also fires first; find ours by id.) const choice = events.find(e => e.kind === 'choice' && e.id === 'await-choices') assert.ok(choice && choice.kind === 'choice') assert.equal(choice.title, 'Which data store?') @@ -767,14 +582,12 @@ test('a build turn that stops to ask fires a live gate and resumes on the pick ( }) test('a build turn that stops to showMultiSelect fires a checklist gate and resumes (#339)', async () => { - const plan = { stack: 'Vike', narration: 'p', decisions: [{ choice: 'x', why: 'y' }], alternatives: [] } const awaitBlock = 'Rated the problems.\n```await-multiselect\n' + '{ "title": "Which problems to deep-dive?", "options": [{ "id": "auth", "label": "auth", "default": true }, { "id": "routing", "label": "routing" }] }\n' + '```' const driver = new FakeDriver({ respond: (prompt: string): string => { - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(plan) + '\n```' if (/Build this app end to end/.test(prompt)) return awaitBlock if (/You paused to ask/.test(prompt)) return 'Added the picks to TODO. Done.' if (/production-grade checklist/.test(prompt)) return 'Reviewed.\n```json\n{ "blockers": [] }\n```' @@ -807,14 +620,12 @@ test('a build turn that stops to showMultiSelect fires a checklist gate and resu }) test('a build turn that stops for plan approval resumes on Approve (#358)', async () => { - const plan = { stack: 'Vike', narration: 'p', decisions: [{ choice: 'x', why: 'y' }], alternatives: [] } const awaitBlock = 'The scope is large, so I wrote a plan.\n```await-confirmation\n' + '{ "title": "Approve the orders plan?", "file": "PLAN_orders.agent.md" }\n' + '```' const driver = new FakeDriver({ respond: (prompt: string): string => { - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(plan) + '\n```' if (/Build this app end to end/.test(prompt)) return awaitBlock if (/You paused to ask/.test(prompt)) return 'Built the plan out. Done.' if (/production-grade checklist/.test(prompt)) return 'Reviewed.\n```json\n{ "blockers": [] }\n```' @@ -851,12 +662,10 @@ test('a build turn that stops for plan approval resumes on Approve (#358)', asyn }) test('a declined plan stops the run cleanly instead of building on (#358)', async () => { - const plan = { stack: 'Vike', narration: 'p', decisions: [{ choice: 'x', why: 'y' }], alternatives: [] } const awaitBlock = 'Plan written.\n```await-confirmation\n{ "title": "Approve?", "file": "PLAN_x.agent.md" }\n```' let resumed = false const driver = new FakeDriver({ respond: (prompt: string): string => { - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(plan) + '\n```' if (/Build this app end to end/.test(prompt)) return awaitBlock if (/You paused to ask/.test(prompt)) resumed = true if (/production-grade checklist/.test(prompt)) resumed = true // a declined plan must not be reviewed either @@ -886,61 +695,9 @@ test('a declined plan stops the run cleanly instead of building on (#358)', asyn assert.equal(end.detail, 'plan declined') }) -test('a re-architect turn that stops to ask fires a live gate instead of a stub plan (#356)', async () => { - const first = { - stack: 'Vike + Prisma', - narration: 'first plan', - decisions: [{ choice: 'SSR', why: 'data' }], - alternatives: [{ option: 'Next.js', whyNot: 'coupling' }], - } - const steered = { stack: 'Next.js + Postgres', narration: 'steered plan', decisions: [{ choice: 'RSC', why: 'fits' }], alternatives: [] } - const awaitBlock = - 'One call before I re-decide.\n```await-choices\n' + - '{ "title": "Server components?", "options": [{ "id": "yes", "label": "Yes" }, { "id": "no", "label": "No" }], "recommended": "yes" }\n' + - '```' - const driver = new FakeDriver({ - respond: (prompt: string): string => { - // Order matters: the re-architect prompt also matches "You are the architect". - if (/You paused to ask/.test(prompt)) return '```json\n' + JSON.stringify(steered) + '\n```' - if (/The user reviewed your first choice/.test(prompt)) return awaitBlock // re-architect stops to ask - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(first) + '\n```' - if (/Build this app end to end/.test(prompt)) return 'built' - if (/production-grade checklist/.test(prompt)) return 'ok\n```json\n{ "blockers": [] }\n```' - return 'done' - }, - sessionId: 'rearch356', - }) - - const events: FrameworkEvent[] = [] - await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - onEvent: e => events.push(e), - requestChoice: async req => { - if (req.id === 'plan-approval') return { picked: 'alt:0', by: 'user' } // steer to Next.js -> reArchitect - if (req.id === 'await-choices') return { picked: 'no', by: 'user' } // answer the agent's question - return { picked: 'proceed', by: 'user' } // approve the re-architected plan - }, - }) - - // The agent's mid-re-architect question surfaced as a live gate... - const gate = events.find(e => e.kind === 'choice' && e.id === 'await-choices') - assert.ok(gate && gate.kind === 'choice') - assert.equal(gate.title, 'Server components?') - assert.ok(events.some(e => e.kind === 'log' && /Continuing with your choice: No/.test(e.message))) - // ...and the re-approval gate (#324) offered the REAL steered plan, not the stub fallback. - const reApproval = events.find(e => e.kind === 'choice' && e.id === 'plan-approval-1') - assert.ok(reApproval && reApproval.kind === 'choice') - assert.ok(reApproval.options.some(o => o.label === 'Proceed: Next.js + Postgres')) -}) - test('without a requestChoice handler a build that asks is not gated (#337 headless)', async () => { - const plan = { stack: 'Vike', narration: 'p', decisions: [{ choice: 'x', why: 'y' }], alternatives: [] } const driver = new FakeDriver({ respond: (prompt: string): string => { - if (/You are the architect/.test(prompt)) return '```json\n' + JSON.stringify(plan) + '\n```' if (/Build this app end to end/.test(prompt)) return 'built it\n```await-choices\n{ "options": [{ "label": "A" }] }\n```' if (/production-grade checklist/.test(prompt)) return 'Reviewed.\n```json\n{ "blockers": [] }\n```' return 'done' diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 972706d8..e69806ef 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -1,6 +1,5 @@ import { Bootstrap, - DecisionLedger, DockerRunner, ExtensionRegistry, LocalRunner, @@ -17,8 +16,6 @@ import { serveCheck, skillInstructions, skillPersonas, - type ArchitectContext, - type ArchitectPlan, type BootstrapEvent, type BootstrapResult, type BootstrapScope, @@ -43,7 +40,7 @@ import { AWAIT_PROTOCOL, CONFIRM_APPROVED, CONFIRM_DECLINED, PLAN_DECLINED_MESSA // Value import from todo-loop.js is a benign cycle: todo-loop.js only calls // run.js's hoisted function declarations (requestChoices / resolveAwaitGate). import { leaveResumeNote, runTodoLoop, type TodoLoopResult } from './todo-loop.js' -import { continueAfterChoice, decideDeploy, deployWith, domainLoopChecklist, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts, reArchitect, type AwaitResolver } from './steps.js' +import { continueAfterChoice, decideDeploy, deployWith, domainLoopChecklist, driverBuild, driverChecklist, driverImprove, driverLoopPrompts } from './steps.js' import { hasSessionIdPlaceholder, OPEN_LOOP_MODES, pickedIds, resolveSessionLink, type ChoicePick, type ChoiceRequest, type FrameworkEvent } from './events.js' import { UsageMeter } from './usage.js' @@ -210,12 +207,11 @@ export interface RunFrameworkOptions { /** Interrupt the run between phases. */ signal?: AbortSignal /** - * Pause the run on an interactive choice and await a pick (#304). Called at the - * plan-approval gate right after the architect decides the stack: the run emits a - * `choice` event, calls this, and resumes on the returned option — proceeding as - * planned, or re-architecting around a picked alternative. Omit for a headless - * run: the gate then auto-accepts the recommended option without pausing. The CLI - * wires this to the dashboard's Accept button + autopilot countdown. + * Pause the run on an interactive choice and await a pick (#304). Called when a + * build turn stops to ask (#337/#358): the run emits a `choice` event, calls + * this, and resumes on the returned option. Omit for a headless run: the gate + * then auto-accepts the recommended option without pausing. The CLI wires this + * to the dashboard's Accept button + autopilot countdown. */ requestChoice?: (req: ChoiceRequest) => Promise /** @@ -269,7 +265,6 @@ export interface RunFrameworkResult { result: BootstrapResult detection: FrameworkDetection events: FrameworkEvent[] - ledger: DecisionLedger /** * The generated app, left running when a {@link ServeConfig} was supplied and * the run finished. The caller owns its lifecycle: show {@link AppPreview.url}, @@ -292,7 +287,7 @@ export interface RunFrameworkResult { /** * Run the whole turnkey flow: detect the framework preset, frame the wrapped * agent with its framework skill (page builder + docs), then drive ai-autopilot's `Bootstrap` - * (scope → architect → build → full-fledged loop → deploy) entirely *through* + * (scope → build → full-fledged loop → deploy) entirely *through* * the driver (option A). Every phase, plus the agent's own progress, streams as * a {@link FrameworkEvent}. Reversible: swap in a real deploy target, or a * different `Driver`, without touching this wiring. @@ -481,16 +476,13 @@ export async function runFramework(opts: RunFrameworkOptions): Promise declineController.abort(new Error('[framework] plan declined')), } - const architectAwait: { resolveAwait?: AwaitResolver } = opts.requestChoice - ? { - resolveAwait: async (gate, round) => { - const answer = await resolveAwaitGate(gate, round, gateDeps) - if (isDeclinedConfirmation(gate, answer)) { - emit({ kind: 'log', message: PLAN_DECLINED_MESSAGE }) - gateDeps.onDecline() - return answer - } - emit({ kind: 'log', message: `Continuing with your choice: ${answer}` }) - return answer - }, - } - : {} - let preview: AppPreview | undefined try { const bootstrap = new Bootstrap({ - ledger, maxPasses: opts.maxPasses ?? DEFAULT_MAX_PASSES, signal: runSignal, onEvent: (event: BootstrapEvent) => emit({ kind: 'bootstrap', event }), steps: { scope: () => ({ scope: opts.scope ?? 'full', intent: opts.intent }), - // An architect turn may itself stop to ask (#356, e.g. the #326 - // unclear-scope flow): resolve it through the same gates instead of - // letting the stub-plan fallback swallow the question. Only when someone - // can answer, so a headless run stays byte-identical. - architect: planApprovalGate(driverArchitect(session, architectAwait), session, { - ...gateDeps, - ...architectAwait, - }), build: agentAwaitGate(driverBuild(session, workspaceOpt), session, gateDeps), checklist, improve: driverImprove(session, workspaceOpt), @@ -622,7 +589,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise instead" — and resumes on the pick. Picking an - * alternative re-architects around it (a fresh, coherent plan, not just a swapped - * name). A no-op unless a {@link RunFrameworkOptions.requestChoice} handler is - * wired, so a headless / programmatic run is byte-identical to before. - */ -function planApprovalGate( - base: (ctx: ArchitectContext) => Promise, - session: DriverSession, - deps: { - requestChoice?: (req: ChoiceRequest) => Promise - emit: (event: FrameworkEvent) => void - /** The run signal; a gate parked for a pick unblocks (proceed) if the run aborts. */ - signal?: AbortSignal - /** Resolver for a re-architect turn that stops to ask (#356). */ - resolveAwait?: AwaitResolver - }, -): (ctx: ArchitectContext) => Promise { - return async ctx => { - const { requestChoice, emit } = deps - let plan = await base(ctx) - if (!requestChoice) return plan - - // Re-fire the gate after each re-architect so the user approves the FINAL plan, - // not just the first (#324): a picked alternative can differ a lot from what was - // rejected, and an autopilot run should still get one look at it. Bounded so a - // run of alt-picks can't loop forever. - for (let round = 0; round < MAX_PLAN_ROUNDS; round++) { - const alternatives = plan.alternatives ?? [] - const options: ChoicesOption[] = [ - { id: 'proceed', label: `Proceed: ${plan.stack}` }, - ...alternatives.map((a, i) => ({ - id: `alt:${i}`, - label: `Use ${a.option} instead`, - ...(a.whyNot ? { detail: a.whyNot } : {}), - })), - ] - // Round 0 keeps the stable `plan-approval` id; later rounds get a unique id so - // a dashboard never confuses a re-approval with the pick it just resolved. - const id = round === 0 ? 'plan-approval' : `plan-approval-${round}` - // The shared single-select gate (#335): emits `choice`, parks for the pick, and - // falls back to the recommended `proceed` if the handler rejects or the run aborts - // (user stop / budget cap #322), so the gate never hangs. - const picked = await requestChoices({ - id, - title: 'Approve this plan?', - options, - recommended: 'proceed', - requestChoice, - emit, - ...(deps.signal ? { signal: deps.signal } : {}), - }) - const altMatch = /^alt:(\d+)$/.exec(picked) - const chosen = altMatch ? alternatives[Number(altMatch[1])] : undefined - if (!chosen) return plan // proceed (or an unknown pick) approves the current plan - emit({ kind: 'log', message: `Re-architecting around your choice: ${chosen.option}` }) - plan = await reArchitect(session, ctx, plan.stack, chosen.option, deps.resolveAwait ? { resolveAwait: deps.resolveAwait } : {}) - } - // Ran out of rounds still picking alternatives: proceed with the latest plan - // rather than loop forever. - emit({ kind: 'log', message: 'Proceeding with the latest plan (re-architect limit reached).' }) - return plan - } -} - -/** How many times a run may re-architect at the plan-approval gate before proceeding (#324). */ -const MAX_PLAN_ROUNDS = 5 - /** How many times a build may stop to ask (and be resumed) before it just proceeds (#337). */ const MAX_AWAIT_ROUNDS = 5 diff --git a/packages/framework/src/security-audit-preset.ts b/packages/framework/src/security-audit-preset.ts index 52bfddbf..36defb12 100644 --- a/packages/framework/src/security-audit-preset.ts +++ b/packages/framework/src/security-audit-preset.ts @@ -3,8 +3,7 @@ import { renderPresetPrompt, type PresetParam } from './preset-params.js' /** * The [Security audit] preset (#461): Rom's exhaustive security pass, shipped as * a direct prompt like [Readability] (#360) and [Maintainability] (#361) — it - * scrutinizes existing code, so it skips the scope -> architect -> build - * scaffolding. `` is the user-facing blank (defaults to `this PR`). + * scrutinizes existing code, so it skips the scope -> build scaffolding. `` is the user-facing blank (defaults to `this PR`). * It is also one of the post-merge quality prompts #326 fires on * `setReadyForMerge()`. Keep it in sync with the issue rather than growing it here. */ diff --git a/packages/framework/src/serve-gate.test.ts b/packages/framework/src/serve-gate.test.ts index 38013c6c..3010c2c5 100644 --- a/packages/framework/src/serve-gate.test.ts +++ b/packages/framework/src/serve-gate.test.ts @@ -23,11 +23,7 @@ function freePort(): Promise { }) } -const CLEAN_REVIEW = [ - { text: '```json\n{"stack":"Node HTTP","narration":"n","decisions":[]}\n```' }, - { text: 'built the app' }, - { text: 'reviewed\n```json\n{"blockers":[]}\n```' }, -] +const CLEAN_REVIEW = [{ text: 'built the app' }, { text: 'reviewed\n```json\n{"blockers":[]}\n```' }] test('serve gate: production-grade only when the agent review AND the real server pass', async () => { const dir = await mkdtemp(join(tmpdir(), 'fw-serve-')) diff --git a/packages/framework/src/steps.test.ts b/packages/framework/src/steps.test.ts index a5a37483..775df54b 100644 --- a/packages/framework/src/steps.test.ts +++ b/packages/framework/src/steps.test.ts @@ -3,21 +3,17 @@ import { test } from 'node:test' import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DecisionLedger, LoopEngine, defineLoop, definePrompt, type DeployTarget, type SupervisorEvent } from '@gemstack/ai-autopilot' +import { LoopEngine, defineLoop, definePrompt, type DeployTarget, type SupervisorEvent } from '@gemstack/ai-autopilot' import { FakeDriver } from './driver/index.js' import { - architectPrompt, deployWith, domainLoopChecklist, - driverArchitect, driverBuild, driverChecklist, driverImprove, extendPrompt, isWorkspaceEmpty, MISSING_VERDICT_BLOCKER, - parseArchitectPlan, - reArchitect, verdictFromLoopRun, } from './steps.js' @@ -32,8 +28,6 @@ function makeWorkspace(files: Record = {}): string { return dir } -const PLAN = { stack: 'Vike + universal-orm', narration: 'orders app', decisions: [] } - test('domainLoopChecklist dispatches a review event and unions the prompts blockers (#252)', async () => { const loop = new LoopEngine({ loops: [defineLoop({ on: 'major-change', run: ['a', 'b'] })], @@ -42,7 +36,7 @@ test('domainLoopChecklist dispatches a review event and unions the prompts block definePrompt({ id: 'b', run: async () => 'reviewed\n```json\n{"blockers":[]}\n```' }), ], }) - const verdict = await domainLoopChecklist(loop)({ pass: 1, plan: PLAN, intent: 'build a thing', blockers: [] }) + const verdict = await domainLoopChecklist(loop)({ pass: 1, intent: 'build a thing', blockers: [] }) assert.deepEqual(verdict.blockers, ['fix X']) // union across the chain (b reported none) }) @@ -58,7 +52,7 @@ test('domainLoopChecklist falls back to the built-in checklist when no loop matc return { blockers: ['from built-in'] } }, }) - const verdict = await checklist({ pass: 1, plan: PLAN, intent: 'x', blockers: [] }) + const verdict = await checklist({ pass: 1, intent: 'x', blockers: [] }) assert.equal(fellBack, true) assert.deepEqual(verdict.blockers, ['from built-in']) }) @@ -75,127 +69,10 @@ test('verdictFromLoopRun surfaces a review that failed to execute as a blocker', assert.deepEqual(blockers, ['review "a" did not complete']) }) -test('parseArchitectPlan reads a fenced json plan', () => { - const text = 'Here is the plan:\n```json\n{"stack":"Vike","narration":"n","decisions":[{"choice":"SSR","why":"data"}]}\n```' - const plan = parseArchitectPlan(text, 'an app') - assert.equal(plan.stack, 'Vike') - assert.deepEqual(plan.decisions, [{ choice: 'SSR', why: 'data' }]) -}) - -test('parseArchitectPlan falls back safely on garbage', () => { - const plan = parseArchitectPlan('no json here', 'a blog') - assert.match(plan.stack, /a blog/) - assert.deepEqual(plan.decisions, []) -}) - -test('parseArchitectPlan reads the stack rationale (pros/cons/alternatives)', () => { - const text = - '```json\n' + - JSON.stringify({ - stack: 'Vike', - narration: 'n', - decisions: [{ choice: 'SSR', why: 'data' }], - pros: ['edge deploy', 'renderer-agnostic'], - cons: ['smaller ecosystem'], - alternatives: [{ option: 'Next.js', whyNot: 'constrained edge deploy' }], - }) + - '\n```' - const plan = parseArchitectPlan(text, 'an app') - assert.deepEqual(plan.pros, ['edge deploy', 'renderer-agnostic']) - assert.deepEqual(plan.cons, ['smaller ecosystem']) - assert.deepEqual(plan.alternatives, [{ option: 'Next.js', whyNot: 'constrained edge deploy' }]) -}) - -test('parseArchitectPlan omits rationale fields when absent (backward compatible)', () => { - const plan = parseArchitectPlan('```json\n{"stack":"Vike","narration":"n","decisions":[]}\n```', 'an app') - assert.equal('pros' in plan, false) - assert.equal('cons' in plan, false) - assert.equal('alternatives' in plan, false) -}) - -test('architectPrompt asks for pros/cons + the rejected alternative, grounded in the tradeoffs', () => { - const p = architectPrompt('a blog') - assert.match(p, /PROS and its CONS/) - assert.match(p, /"alternatives"/) - assert.match(p, /renderer-agnostic/) // STACK_TRADEOFFS is embedded -}) - -test('driverArchitect returns the parsed plan from the driver turn', async () => { - const session = await new FakeDriver({ - turns: [{ text: '```json\n{"stack":"Next.js","narration":"n","decisions":[]}\n```' }], - }).start({ cwd: '/ws' }) - const plan = await driverArchitect(session)({ intent: 'x', scope: 'full', ledger: new DecisionLedger() }) - assert.equal(plan.stack, 'Next.js') -}) - -const ARCHITECT_AWAIT_BLOCK = - 'I need to know what you meant.\n```await-choices\n' + - '{ "title": "Which did you mean?", "options": [{ "id": "blog", "label": "A blog" }, { "id": "shop", "label": "A shop" }] }\n' + - '```' - -test('driverArchitect resolves an await gate and re-prompts before parsing (#356)', async () => { - const session = await new FakeDriver({ - respond: (prompt: string) => - /You paused to ask/.test(prompt) - ? '```json\n' + JSON.stringify(PLAN) + '\n```' - : ARCHITECT_AWAIT_BLOCK, // the architect stops to ask first - }).start({ cwd: '/ws' }) - const asked: string[] = [] - const plan = await driverArchitect(session, { - resolveAwait: async gate => { - asked.push(gate.title) - return 'A shop' - }, - })({ intent: 'x', scope: 'full', ledger: new DecisionLedger() }) - assert.deepEqual(asked, ['Which did you mean?']) - assert.equal(plan.stack, PLAN.stack) // the real plan, not the stub fallback -}) - -test('driverArchitect without a resolver keeps the stub fallback (headless, #356)', async () => { - const session = await new FakeDriver({ turns: [{ text: ARCHITECT_AWAIT_BLOCK }] }).start({ cwd: '/ws' }) - const plan = await driverArchitect(session)({ intent: 'a shop', scope: 'full', ledger: new DecisionLedger() }) - assert.equal(plan.stack, 'A sensible stack for: a shop') -}) - -test('reArchitect resolves an await gate and re-prompts before parsing (#356)', async () => { - const steered = { stack: 'Next.js + Postgres', narration: 'steered', decisions: [] } - const prompts: string[] = [] - const session = await new FakeDriver({ - respond: (prompt: string) => { - prompts.push(prompt) - return /You paused to ask/.test(prompt) ? '```json\n' + JSON.stringify(steered) + '\n```' : ARCHITECT_AWAIT_BLOCK - }, - }).start({ cwd: '/ws' }) - const plan = await reArchitect( - session, - { intent: 'x', scope: 'full', ledger: new DecisionLedger() }, - 'Vike', - 'Next.js', - { resolveAwait: async () => 'A shop' }, - ) - assert.equal(plan.stack, 'Next.js + Postgres') - // The continuation restates the answer contract so the reply is parseable. - assert.ok(prompts.some(p => /You paused to ask.*chose: A shop[\s\S]*ONLY a fenced/.test(p))) -}) - -test('an architect that keeps asking past the round limit falls back to the latest text (#356)', async () => { - const session = await new FakeDriver({ respond: () => ARCHITECT_AWAIT_BLOCK }).start({ cwd: '/ws' }) - let rounds = 0 - const plan = await driverArchitect(session, { - resolveAwait: async () => { - rounds++ - return 'A shop' - }, - })({ intent: 'a shop', scope: 'full', ledger: new DecisionLedger() }) - assert.equal(rounds, 5) // bounded, no infinite loop - assert.equal(plan.stack, 'A sensible stack for: a shop') -}) - test('driverBuild emits supervisor events and returns the driver summary', async () => { const session = await new FakeDriver({ turns: [{ text: 'Built the app.' }] }).start({ cwd: '/ws' }) const events: SupervisorEvent[] = [] const run = await driverBuild(session)({ - plan: PLAN, scope: 'full', intent: 'orders app', onEvent: e => events.push(e), @@ -211,13 +88,13 @@ test('driverChecklist parses the { blockers } verdict', async () => { const session = await new FakeDriver({ turns: [{ text: 'review\n```json\n{"blockers":["no auth"]}\n```' }], }).start({ cwd: '/ws' }) - const verdict = await driverChecklist(session)({ pass: 1, plan: PLAN, intent: 'x', blockers: [] }) + const verdict = await driverChecklist(session)({ pass: 1, intent: 'x', blockers: [] }) assert.deepEqual(verdict.blockers, ['no auth']) }) test('driverChecklist fails closed on a verdict-less reply (not passing)', async () => { const session = await new FakeDriver({ turns: [{ text: 'looks fine to me' }] }).start({ cwd: '/ws' }) - const verdict = await driverChecklist(session)({ pass: 1, plan: PLAN, intent: 'x', blockers: [] }) + const verdict = await driverChecklist(session)({ pass: 1, intent: 'x', blockers: [] }) assert.deepEqual(verdict.blockers, [MISSING_VERDICT_BLOCKER]) }) @@ -231,7 +108,6 @@ test('deployWith runs the target against the decided plan and uses its name', as }, } const outcome = await deployWith({ render: 'ssr', reason: 'per-request data' }, target)({ - plan: PLAN, scope: 'full', intent: 'orders app', productionGrade: true, @@ -244,7 +120,7 @@ test('deployWith runs the target against the decided plan and uses its name', as test('driverImprove prompts the driver with the blockers', async () => { const session = await new FakeDriver({ turns: [{ text: 'fixed' }] }).start({ cwd: '/ws' }) - await driverImprove(session)({ pass: 1, plan: PLAN, intent: 'x', blockers: ['add auth'] }) + await driverImprove(session)({ pass: 1, intent: 'x', blockers: ['add auth'] }) assert.match(session.prompts[0]!, /add auth/) }) @@ -274,7 +150,6 @@ test('driverBuild re-prompts to scaffold from scratch when the workspace stays e }).start({ cwd }) const events: SupervisorEvent[] = [] const run = await driverBuild(session, { verifyWorkspace: true })({ - plan: PLAN, scope: 'full', intent: 'a blog', onEvent: e => events.push(e), @@ -297,7 +172,6 @@ test('driverBuild does not re-prompt when the build produced files', async () => try { const session = await new FakeDriver({ turns: [{ text: 'built it' }] }).start({ cwd }) const run = await driverBuild(session, { verifyWorkspace: true })({ - plan: PLAN, scope: 'full', intent: 'a blog', onEvent: () => {}, @@ -315,7 +189,6 @@ test('driverBuild extends an existing project instead of rebuilding it (#185)', const session = await new FakeDriver({ turns: [{ text: 'added the feature' }] }).start({ cwd }) const events: SupervisorEvent[] = [] await driverBuild(session, { verifyWorkspace: true })({ - plan: PLAN, scope: 'full', intent: 'add a search box', onEvent: e => events.push(e), @@ -337,7 +210,6 @@ test('driverBuild uses greenfield framing for an empty workspace (#185)', async try { const session = await new FakeDriver({ turns: [{ text: 'scaffolded it' }] }).start({ cwd }) await driverBuild(session, { verifyWorkspace: true })({ - plan: PLAN, scope: 'full', intent: 'a blog', onEvent: () => {}, @@ -349,10 +221,9 @@ test('driverBuild uses greenfield framing for an empty workspace (#185)', async } }) -test('extendPrompt names the intent and the detected stack, and forbids a rebuild', () => { - const prompt = extendPrompt({ stack: 'Next.js', narration: 'n', decisions: [] }, 'add a settings page') +test('extendPrompt names the intent and forbids a rebuild', () => { + const prompt = extendPrompt('add a settings page') assert.match(prompt, /add a settings page/) - assert.match(prompt, /Next\.js/) assert.match(prompt, /do NOT re-scaffold|do not.*swap its stack/i) }) @@ -361,13 +232,13 @@ test('driverImprove scaffolds from scratch when the workspace is empty, else fix const builtCwd = makeWorkspace({ 'src/app.ts': 'export {}' }) try { const s1 = await new FakeDriver({ turns: [{ text: 'scaffolded' }] }).start({ cwd: emptyCwd }) - await driverImprove(s1, { verifyWorkspace: true })({ pass: 1, plan: PLAN, intent: 'a blog', blockers: ['add auth'] }) + await driverImprove(s1, { verifyWorkspace: true })({ pass: 1, intent: 'a blog', blockers: ['add auth'] }) // Empty workspace: it scaffolds instead of making the "smallest change". assert.match(s1.prompts[0]!, /from scratch|empty/i) assert.doesNotMatch(s1.prompts[0]!, /add auth/) const s2 = await new FakeDriver({ turns: [{ text: 'fixed' }] }).start({ cwd: builtCwd }) - await driverImprove(s2, { verifyWorkspace: true })({ pass: 1, plan: PLAN, intent: 'a blog', blockers: ['add auth'] }) + await driverImprove(s2, { verifyWorkspace: true })({ pass: 1, intent: 'a blog', blockers: ['add auth'] }) assert.match(s2.prompts[0]!, /add auth/) } finally { rmSync(emptyCwd, { recursive: true, force: true }) diff --git a/packages/framework/src/steps.ts b/packages/framework/src/steps.ts index e1fd8288..3127bf1d 100644 --- a/packages/framework/src/steps.ts +++ b/packages/framework/src/steps.ts @@ -1,13 +1,8 @@ import { readdirSync } from 'node:fs' import { join } from 'node:path' -import { definePrompt, LoopEngine, parseVerdict, promptInstructions, renderTask, STACK_TRADEOFFS } from '@gemstack/ai-autopilot' +import { definePrompt, LoopEngine, parseVerdict, promptInstructions, renderTask } from '@gemstack/ai-autopilot' import type { - ArchitectAlternative, - ArchitectContext, - ArchitectDecision, - ArchitectPlan, BuildContext, - DecisionLedger, DeployContext, DeployOutcome, DeployTarget, @@ -28,93 +23,28 @@ import { parseAwaitGate, type ParsedAwaitGate } from './turn-gate.js' * Driver-backed {@link https://github.com/gemstack-land/gemstack | Bootstrap} steps. * * These implement the injectable steps of ai-autopilot's `Bootstrap` by running - * everything *through* a {@link DriverSession} (option A, #166): the architect is - * a structured decision the driver returns as JSON; build / improve are prompts - * that let the wrapped agent's own loop do the work; the checklist re-prompts and - * gates on the `{ blockers }` verdict the agent ends its output with. Reusing the - * `Bootstrap` spine keeps scope, narration, the decisions ledger, the loop gate, - * and deploy for free; only *who runs the inner loop* changes. + * everything *through* a {@link DriverSession} (option A, #166): build / improve + * are prompts that let the wrapped agent's own loop do the work; the checklist + * re-prompts and gates on the `{ blockers }` verdict the agent ends its output + * with. Reusing the `Bootstrap` spine keeps scope, narration, the loop gate, and + * deploy for free; only *who runs the inner loop* changes. */ const ZERO_USAGE = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } -/** - * Compose the architect prompt for an intent. Exported so callers can override. - * `steer` is an optional extra constraint (e.g. the user overrode the stack at the - * plan-approval gate, #304), inserted right after the intent so it frames the whole - * decision. - */ -export function architectPrompt(intent: string, steer?: string): string { - return [ - 'You are the architect for a new app. Decide the stack and the key architectural choices.', - `What the user wants: ${intent}`, - ...(steer ? [steer] : []), - 'Prefer a modern, well-supported stack. Keep the choices minimal and justified.', - 'Justify the stack honestly: give its real PROS and its CONS, and name the main', - 'alternative you rejected and why it lost. This is shown to the user as the rationale.', - STACK_TRADEOFFS, - ARCHITECT_JSON_SHAPE, - ].join('\n') -} - -/** The architect's answer contract, restated on every architect (re-)prompt. */ -const ARCHITECT_JSON_SHAPE = [ - 'Respond with ONLY a fenced ```json block of the shape:', - '{ "stack": "", "narration": "", "decisions": [{ "choice": "", "why": "" }], "pros": [""], "cons": [""], "alternatives": [{ "option": "", "whyNot": "" }] }', -].join('\n') - /** * Resolve an agent-authored await gate (#337/#339) to the user's answer text. * Wired by the run (see `resolveAwaitGate` there); absent on a headless run. */ export type AwaitResolver = (gate: ParsedAwaitGate, round: number) => Promise -/** How many times an architect turn may stop to ask before its latest text is parsed as-is. */ -const MAX_ARCHITECT_AWAIT_ROUNDS = 5 - -/** - * Run an architect prompt, honoring agent-authored await gates (#356): an - * architect turn that stops to ask (e.g. the #326 unclear-scope flow) is resolved - * through `resolveAwait` and re-prompted with the answer, instead of the question - * being swallowed into a stub plan by {@link parseArchitectPlan}'s fallbacks. - * Without a resolver (headless) the first turn's text is returned unchanged. - */ -async function architectTurns( - session: DriverSession, - firstPrompt: string, - opts: { system?: string | undefined; signal?: AbortSignal | undefined; resolveAwait?: AwaitResolver | undefined }, -): Promise { - const promptOpts = { - ...(opts.system ? { system: opts.system } : {}), - ...(opts.signal ? { signal: opts.signal } : {}), - } - let turn = await session.prompt(firstPrompt, promptOpts) - if (!opts.resolveAwait) return turn.text - for (let round = 0; round < MAX_ARCHITECT_AWAIT_ROUNDS; round++) { - const gate = parseAwaitGate(turn.text) - if (!gate) return turn.text - const answer = await opts.resolveAwait(gate, round) - turn = await session.prompt( - [ - `You paused to ask: "${gate.title}". The user chose: ${answer}.`, - 'Continue the architect decision with that answer, and do not ask again unless a genuinely new choice comes up.', - ARCHITECT_JSON_SHAPE, - ].join('\n'), - promptOpts, - ) - } - return turn.text -} - -/** Compose the build prompt from the architect's plan. */ -export function buildPrompt(plan: ArchitectPlan, intent: string): string { +/** Compose the build prompt for an intent. The stack is the agent's call (#545). */ +export function buildPrompt(intent: string): string { return [ `Build this app end to end: ${intent}`, - `Stack: ${plan.stack}`, - plan.narration, 'The workspace may be empty — if so, scaffold the whole project from scratch:', 'create package.json with scripts, all config, and every source file, install', - 'the dependencies, and make the app run. Follow the stack conventions.', + 'the dependencies, and make the app run.', 'When done, summarize what you built in one short paragraph.', ].join('\n') } @@ -126,11 +56,9 @@ export function buildPrompt(plan: ArchitectPlan, intent: string): string { * pointed the framework at a project that already exists (#185). Chosen when the * workspace already holds source at build time. */ -export function extendPrompt(plan: ArchitectPlan, intent: string): string { +export function extendPrompt(intent: string): string { return [ `Work within the existing codebase in this workspace to deliver: ${intent}`, - `Detected stack: ${plan.stack}`, - plan.narration, 'This project already exists — do NOT re-scaffold or rebuild it, and do not', 'replace its structure or swap its stack. Read the existing code first, follow', 'its conventions, and make the smallest coherent set of changes that adds what', @@ -145,11 +73,9 @@ export function extendPrompt(plan: ArchitectPlan, intent: string): string { * {@link improvePrompt} ("smallest changes / no unrelated features") would * wrongly discourage scaffolding (#182). */ -export function scaffoldPrompt(plan: ArchitectPlan, intent: string): string { +export function scaffoldPrompt(intent: string): string { return [ `The workspace is empty — no app exists here yet. You must create the entire app now from scratch: ${intent}`, - `Stack: ${plan.stack}`, - plan.narration, 'This is a from-scratch build, not an edit: do not wait for existing code, and do', 'not refuse because the directory is empty — that is expected. Scaffold the full', 'project (package.json with scripts, config, and every source file), install', @@ -224,46 +150,6 @@ export interface DriverStepOptions { system?: string } -/** - * The architect step: ask the driver for a structured stack decision and parse - * it. A single small structured decision, exactly what option A reserves this - * shape for (returned as JSON by the black box rather than a separate agent). - */ -export function driverArchitect( - session: DriverSession, - opts: { prompt?: (intent: string) => string; resolveAwait?: AwaitResolver } & DriverStepOptions = {}, -): (ctx: ArchitectContext) => Promise { - const compose = opts.prompt ?? architectPrompt - return async ctx => { - const text = await architectTurns(session, compose(ctx.intent), { - system: opts.system, - signal: ctx.signal, - resolveAwait: opts.resolveAwait, - }) - return parseArchitectPlan(text, ctx.intent) - } -} - -/** - * Re-run the architect after the user overrode the stack at the plan-approval gate - * (#304): same intent, same app goal, but steered to build around the alternative - * they picked instead of the stack originally chosen. Returns a fresh plan. - */ -export async function reArchitect( - session: DriverSession, - ctx: ArchitectContext, - fromStack: string, - toOption: string, - opts: { resolveAwait?: AwaitResolver } = {}, -): Promise { - const steer = `The user reviewed your first choice (${fromStack}) and prefers ${toOption}. Re-decide the stack around ${toOption}, keeping the same app goal.` - const text = await architectTurns(session, architectPrompt(ctx.intent, steer), { - signal: ctx.signal, - resolveAwait: opts.resolveAwait, - }) - return parseArchitectPlan(text, ctx.intent) -} - /** * Resume the build after the agent stopped to ask (#337): the turn-boundary choice * gate re-prompts the driver with the user's pick so it continues from the decision @@ -295,7 +181,7 @@ export function continueAfterChoice( export function driverBuild( session: DriverSession, opts: { - prompt?: (plan: ArchitectPlan, intent: string) => string + prompt?: (intent: string) => string /** * Guarantee the build produced files: after the build turn, if the workspace * is still empty (the agent stalled instead of scaffolding), re-prompt once @@ -318,10 +204,10 @@ export function driverBuild( // greenfield path and stays deterministic. A caller-supplied prompt wins. const existing = opts.verifyWorkspace === true && !isWorkspaceEmpty(session.cwd) const firstPrompt = composeOverride - ? composeOverride(ctx.plan, ctx.intent) + ? composeOverride(ctx.intent) : existing - ? extendPrompt(ctx.plan, ctx.intent) - : buildPrompt(ctx.plan, ctx.intent) + ? extendPrompt(ctx.intent) + : buildPrompt(ctx.intent) const subtask: PlannedSubtask = { id: 'build-1', description: existing ? 'Extend the existing codebase' : 'Build with the wrapped agent', @@ -342,7 +228,7 @@ export function driverBuild( if (opts.verifyWorkspace && isWorkspaceEmpty(session.cwd) && !parseAwaitGate(turn.text)) { const retry: PlannedSubtask = { id: 'build-2', description: 'Scaffold the app from scratch (workspace was empty)' } ctx.onEvent({ type: 'dispatch-start', subtask: retry }) - turn = await session.prompt(scaffoldPrompt(ctx.plan, ctx.intent), { ...promptOpts, ...signalOpt }) + turn = await session.prompt(scaffoldPrompt(ctx.intent), { ...promptOpts, ...signalOpt }) const retryResult: SubtaskResult = { subtask: retry, text: turn.text, ok: true, usage: ZERO_USAGE } results.push(retryResult) ctx.onEvent({ type: 'dispatch-result', result: retryResult }) @@ -444,7 +330,7 @@ export function driverImprove( const compose = opts.prompt ?? improvePrompt return async ctx => { const scaffold = opts.verifyWorkspace && isWorkspaceEmpty(session.cwd) - const text = scaffold ? scaffoldPrompt(ctx.plan, ctx.intent) : compose(ctx.blockers) + const text = scaffold ? scaffoldPrompt(ctx.intent) : compose(ctx.blockers) await session.prompt(text, { ...(opts.system ? { system: opts.system } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}), @@ -456,20 +342,20 @@ export function driverImprove( * Materialize a domain preset's {@link Prompt} bodies into driver-backed * {@link LoopPrompt}s so its loops can run through the wrapped agent. Each pass is * one fresh {@link DriverSession.prompt} call (the driver's fresh-context unit), - * prompted with the composed instructions (body + decisions briefing) and the - * rendered {@link renderTask | loop event}, returning the agent's text. + * prompted with the prompt body and the rendered {@link renderTask | loop event}, + * returning the agent's text. */ export function driverLoopPrompts( session: DriverSession, prompts: readonly Prompt[], - opts: { ledger?: DecisionLedger; signal?: AbortSignal } & DriverStepOptions = {}, + opts: { signal?: AbortSignal } & DriverStepOptions = {}, ): LoopPrompt[] { return prompts.map(prompt => definePrompt({ id: prompt.id, passes: prompt.passes, run: async ctx => { - const instructions = promptInstructions(prompt, opts.ledger ? { ledger: opts.ledger } : {}) + const instructions = promptInstructions(prompt) const framing = opts.system ? `${opts.system}\n\n${instructions}` : instructions const turn = await session.prompt(`${framing}\n\n${renderTask(ctx.event)}`, { ...(opts.signal ? { signal: opts.signal } : {}), @@ -508,74 +394,3 @@ export function deployWith( } } -/** - * Parse the architect's JSON out of a driver turn, with safe fallbacks so a - * loose reply never crashes the flow. Exported for testing. - */ -export function parseArchitectPlan(text: string, intent: string): ArchitectPlan { - const obj = lastJsonObject(text) - const stack = typeof obj?.['stack'] === 'string' && obj['stack'].trim() ? obj['stack'].trim() : `A sensible stack for: ${intent}` - const narration = - typeof obj?.['narration'] === 'string' && obj['narration'].trim() ? obj['narration'].trim() : `Building: ${intent}` - const decisions = Array.isArray(obj?.['decisions']) - ? (obj['decisions'] as unknown[]).flatMap(coerceDecision) - : [] - const pros = coerceStrings(obj?.['pros']) - const cons = coerceStrings(obj?.['cons']) - const alternatives = Array.isArray(obj?.['alternatives']) - ? (obj['alternatives'] as unknown[]).flatMap(coerceAlternative) - : [] - // Omit empty rationale fields so a plan without them stays clean. - return { - stack, - narration, - decisions, - ...(pros.length ? { pros } : {}), - ...(cons.length ? { cons } : {}), - ...(alternatives.length ? { alternatives } : {}), - } -} - -function coerceDecision(value: unknown): ArchitectDecision[] { - if (typeof value !== 'object' || value === null) return [] - const obj = value as Record - const choice = typeof obj['choice'] === 'string' ? obj['choice'].trim() : '' - const why = typeof obj['why'] === 'string' ? obj['why'].trim() : '' - return choice && why ? [{ choice, why }] : [] -} - -function coerceStrings(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((v): v is string => typeof v === 'string' && v.trim() !== '').map(v => v.trim()) -} - -function coerceAlternative(value: unknown): ArchitectAlternative[] { - if (typeof value !== 'object' || value === null) return [] - const obj = value as Record - const option = typeof obj['option'] === 'string' ? obj['option'].trim() : '' - const whyNot = typeof obj['whyNot'] === 'string' ? obj['whyNot'].trim() : '' - return option && whyNot ? [{ option, whyNot }] : [] -} - -const FENCE = /```(?:[a-zA-Z0-9]*)\n([\s\S]*?)```/g - -/** Extract the last JSON object from text: last fenced block, else a trailing `{...}`. */ -function lastJsonObject(text: string): Record | undefined { - if (!text) return undefined - const candidates: string[] = [] - for (const m of text.matchAll(FENCE)) candidates.push(m[1]!) - const open = text.lastIndexOf('{') - const close = text.lastIndexOf('}') - if (open !== -1 && close > open) candidates.push(text.slice(open, close + 1)) - for (let i = candidates.length - 1; i >= 0; i--) { - try { - const parsed = JSON.parse(candidates[i]!.trim()) - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record - } - } catch { - // try the next candidate - } - } - return undefined -} diff --git a/packages/framework/src/ux-preset.ts b/packages/framework/src/ux-preset.ts index 2f759041..287a8298 100644 --- a/packages/framework/src/ux-preset.ts +++ b/packages/framework/src/ux-preset.ts @@ -3,8 +3,7 @@ import { renderPresetPrompt, type PresetParam } from './preset-params.js' /** * The [UX] preset (#472): Rom's usability review, shipped like [Research] (#331) * as a direct interactive prompt rather than a build run — it reviews existing UI - * from a user's perspective, so it skips the scope -> architect -> build - * scaffolding. It enumerates every finding as a `showChoices()` list, stops at + * from a user's perspective, so it skips the scope -> build scaffolding. It enumerates every finding as a `showChoices()` list, stops at * `` for the user to accept proposals, then works on the accepted ones. * `` is the user-facing blank (defaults to `this PR`); `` is the * agent-facing turn-gate macro (#339/#340) the dashboard resolves. Keep it in sync