diff --git a/.changeset/data-persona-prisma.md b/.changeset/data-persona-prisma.md new file mode 100644 index 0000000..3175239 --- /dev/null +++ b/.changeset/data-persona-prisma.md @@ -0,0 +1,7 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Point the flagship data persona at Prisma (installable) instead of the unpublished universal-orm + +The bootstrap data persona told the agent to build the data layer on `universal-orm`, which isn't installable (`@universal-orm/core` 404s on npm), so from-scratch live builds stalled sanity-checking the stack and produced nothing. It now defaults to Prisma with concrete install/init steps (schema-first, migrations derived from the schema, a fully typed client), and the architect default no longer names an unpublished package. The persona export is renamed `universalOrmModeler` -> `dataModeler` (persona name `data-modeler`). Closes #181. diff --git a/.changeset/scaffold-empty-workspace.md b/.changeset/scaffold-empty-workspace.md new file mode 100644 index 0000000..d821051 --- /dev/null +++ b/.changeset/scaffold-empty-workspace.md @@ -0,0 +1,7 @@ +--- +'@gemstack/framework': minor +--- + +Recover from-scratch builds: scaffold an empty workspace instead of only polishing + +The full-fledged loop assumed an app already existed, so a from-scratch run could end at the framework's default "Welcome" page. The build step now verifies it produced files and hard re-prompts to scaffold from scratch if the workspace is still empty; the improve step switches to a "create the whole app from scratch" directive when the workspace is empty (instead of "smallest change / no unrelated features"); and the default `--max-passes` is raised from 3 to 5 so a from-scratch build has room to recover. Also clarifies the dashboard/terminal end status ("finished", not "done", so it reads as separate from the production-grade badge). Closes #182. diff --git a/examples/autopilot-quickstart/src/autopilot.test.ts b/examples/autopilot-quickstart/src/autopilot.test.ts index daf7d82..d15bb59 100644 --- a/examples/autopilot-quickstart/src/autopilot.test.ts +++ b/examples/autopilot-quickstart/src/autopilot.test.ts @@ -11,7 +11,7 @@ describe('autopilot quickstart: the four layers compose end-to-end', () => { assert.equal(result.run.plan.length, 3) assert.deepEqual( result.run.plan.map(s => s.worker).sort(), - ['ui-intent-designer', 'universal-orm-modeler', 'vike-page-builder'], + ['data-modeler', 'ui-intent-designer', 'vike-page-builder'], ) assert.ok(result.run.results.every(r => r.ok), 'every subtask succeeded') diff --git a/examples/autopilot-quickstart/src/autopilot.ts b/examples/autopilot-quickstart/src/autopilot.ts index b1a4294..8f0d96a 100644 --- a/examples/autopilot-quickstart/src/autopilot.ts +++ b/examples/autopilot-quickstart/src/autopilot.ts @@ -37,7 +37,7 @@ export const TASK = 'Add a paginated Orders page backed by an orders table' /** Each subtask, the persona that should own it, and the file it writes. */ const WORK = [ { - worker: 'universal-orm-modeler', + worker: 'data-modeler', description: 'Define the orders schema and a migration', file: 'database/schema.ts', contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n", diff --git a/examples/bootstrap-quickstart/src/bootstrap.test.ts b/examples/bootstrap-quickstart/src/bootstrap.test.ts index ea05a9b..a1b62da 100644 --- a/examples/bootstrap-quickstart/src/bootstrap.test.ts +++ b/examples/bootstrap-quickstart/src/bootstrap.test.ts @@ -12,7 +12,7 @@ describe('bootstrap capstone: the whole epic composes end-to-end (offline)', () assert.equal(detection.framework, 'Vike') // Architect: chose the stack and recorded its choices to the ledger. - assert.match(result.plan.stack, /Vike \+ universal-orm/) + assert.match(result.plan.stack, /Vike \+ Prisma/) assert.equal(result.plan.decisions.length, 2) // Build: each preset persona wrote its file into the sandbox. diff --git a/examples/bootstrap-quickstart/src/bootstrap.ts b/examples/bootstrap-quickstart/src/bootstrap.ts index cbadc75..64ed037 100644 --- a/examples/bootstrap-quickstart/src/bootstrap.ts +++ b/examples/bootstrap-quickstart/src/bootstrap.ts @@ -54,12 +54,12 @@ import { export const INTENT = 'A paginated Orders page backed by an orders table, with sign-in.' /** The project we detect a framework from — Vike here, so the Vike preset wins. */ -const PROJECT_DEPS = { 'vike-react': '1.0.0', react: '18.0.0', '@universal-orm/core': '1.0.0' } +const PROJECT_DEPS = { 'vike-react': '1.0.0', react: '18.0.0', '@prisma/client': '1.0.0' } /** Each build subtask, the persona that owns it, and the file it writes. */ const WORK = [ { - worker: 'universal-orm-modeler', + worker: 'data-modeler', description: 'Define the orders schema and a migration', file: 'database/schema.ts', contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n", @@ -80,10 +80,10 @@ const WORK = [ /** The architect's structured decision (what `agentArchitect` parses). */ const ARCHITECT_PLAN = { - stack: 'Vike + universal-orm on Postgres, with vike-auth', - narration: 'Server-rendered orders app: Vike pages, a universal-orm data layer, sessions via vike-auth.', + 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: 'universal-orm on Postgres', why: 'the orders catalog is relational and needs typed queries' }, + { 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' }, ], } @@ -237,10 +237,10 @@ export async function runCapstone(write: (line: string) => void = () => {}): Pro // Scale mode: generate CODE-OVERVIEW.md from the scaffold (a material change). const maintainer = new CodeOverviewMaintainer({ regenerate: () => ({ - summary: 'A server-rendered orders app on Vike + universal-orm.', + summary: 'A server-rendered orders app on Vike + Prisma.', sections: [ { title: 'Structure', body: '- `pages/orders/` — the paginated orders page\n- `database/` — the orders schema + migrations' }, - { title: 'Conventions', body: 'Data goes through the universal-orm model builder; pages stay thin.' }, + { title: 'Conventions', body: 'Data goes through the Prisma client; pages stay thin.' }, ], }), }) diff --git a/examples/bootstrap-quickstart/src/live.ts b/examples/bootstrap-quickstart/src/live.ts index aed4cc4..601f1f6 100644 --- a/examples/bootstrap-quickstart/src/live.ts +++ b/examples/bootstrap-quickstart/src/live.ts @@ -47,12 +47,12 @@ import { INTENT, formatBootstrapEvent, type CapstoneResult } from './bootstrap.j const MODEL = process.env['GEMSTACK_MODEL'] ?? 'anthropic/claude-haiku-4-5-20251001' /** The project we detect a framework from — Vike here, so the Vike preset wins. */ -const PROJECT_DEPS = { 'vike-react': '1.0.0', react: '18.0.0', '@universal-orm/core': '1.0.0' } +const PROJECT_DEPS = { 'vike-react': '1.0.0', react: '18.0.0', '@prisma/client': '1.0.0' } /** The build plan: three subtasks, each owned by a preset persona (by name). The real * worker decides the file contents; only the decomposition is fixed. */ const WORK = [ - { worker: 'universal-orm-modeler', description: 'Define the orders schema and a migration in database/schema.ts' }, + { worker: 'data-modeler', description: 'Define the orders schema and a migration in database/schema.ts' }, { worker: 'vike-page-builder', description: 'Build pages/orders/+Page.jsx: a server-rendered, paginated list of orders' }, { worker: 'ui-intent-designer', description: 'Express the orders list as intent (a +config.js meta), not hardcoded markup' }, ] as const @@ -165,7 +165,7 @@ export async function runLiveCapstone(write: (line: string) => void = () => {}): // Scale mode: generate CODE-OVERVIEW.md from the real scaffold (a material change). const maintainer = new CodeOverviewMaintainer({ regenerate: () => ({ - summary: 'A server-rendered orders app on Vike + universal-orm.', + summary: 'A server-rendered orders app on Vike + Prisma.', sections: [{ title: 'Structure', body: Object.keys(files).map(f => `- \`${f}\``).join('\n') }], }), }) diff --git a/packages/ai-autopilot/src/bootstrap/steps.ts b/packages/ai-autopilot/src/bootstrap/steps.ts index aa8c0bd..e444fbe 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.ts @@ -25,8 +25,9 @@ export interface ArchitectAgentOptions { 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. Default to the GemStack stack (Vike + universal-orm) -unless the intent clearly calls for something else. Narrate what you are building +not one who asks permission. Default to the GemStack stack (Vike + Prisma) +unless the intent clearly calls for something else. 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.` diff --git a/packages/ai-autopilot/src/bootstrap/types.ts b/packages/ai-autopilot/src/bootstrap/types.ts index 115fad5..a96ff5a 100644 --- a/packages/ai-autopilot/src/bootstrap/types.ts +++ b/packages/ai-autopilot/src/bootstrap/types.ts @@ -10,7 +10,7 @@ import type { Verdict } from '../loop/verdict.js' * scope → architect → build → full-fledged loop * * - **Scope** is the one and only interrogation: prototype vs full, plus intent. - * - **Architect** picks the stack (Vike + universal-orm), narrates it, and + * - **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, * streaming narration; the caller can interrupt via an `AbortSignal`. @@ -94,7 +94,7 @@ export interface DeployTarget { /** 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 + universal-orm, Postgres, vike-auth"). */ + /** 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 diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index e31cc0a..c82a4dc 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -13,12 +13,12 @@ * - {@link agentSynthesizer} / {@link defaultSynthesize} — combine results * * Personas add the stack-aware knowledge layer: reusable roles that know the - * GemStack stack (Vike/Next + universal-orm), materialized into worker agents. + * GemStack stack (Vike/Next + Prisma), materialized into worker agents. * * - {@link definePersona} — define a stack-aware role * - {@link personaAgent} / {@link personaWorkers} — materialize personas for a run * - {@link personaRoster} — describe personas to a planner - * - {@link stackPersonas} — the built-in Vike + universal-orm personas + * - {@link stackPersonas} — the built-in Vike + Prisma personas * - {@link sharedPersonas} — the framework-neutral core (data layer + intent UI) * * The runner is the pluggable execution seam: a workspace (filesystem + shell + @@ -106,7 +106,7 @@ export { personaRoster, vikePageBuilder, nextPageBuilder, - universalOrmModeler, + dataModeler, uiIntentDesigner, sharedPersonas, stackPersonas, diff --git a/packages/ai-autopilot/src/personas/compose.test.ts b/packages/ai-autopilot/src/personas/compose.test.ts index 78abc43..c4e6aba 100644 --- a/packages/ai-autopilot/src/personas/compose.test.ts +++ b/packages/ai-autopilot/src/personas/compose.test.ts @@ -91,7 +91,7 @@ describe('personaWorkers', () => { const workers = personaWorkers(stackPersonas) assert.deepEqual( Object.keys(workers).sort(), - ['ui-intent-designer', 'universal-orm-modeler', 'vike-page-builder'], + ['data-modeler', 'ui-intent-designer', 'vike-page-builder'], ) }) @@ -105,7 +105,7 @@ describe('personaRoster', () => { it('lists each persona name + role for a planner to route on', () => { const roster = personaRoster(stackPersonas) assert.match(roster, /`vike-page-builder`/) - assert.match(roster, /`universal-orm-modeler`/) + assert.match(roster, /`data-modeler`/) assert.match(roster, /`ui-intent-designer`/) assert.match(roster, /worker/) }) diff --git a/packages/ai-autopilot/src/personas/index.ts b/packages/ai-autopilot/src/personas/index.ts index ec968c9..32e495c 100644 --- a/packages/ai-autopilot/src/personas/index.ts +++ b/packages/ai-autopilot/src/personas/index.ts @@ -19,7 +19,7 @@ export { export { vikePageBuilder, nextPageBuilder, - universalOrmModeler, + dataModeler, uiIntentDesigner, sharedPersonas, stackPersonas, diff --git a/packages/ai-autopilot/src/personas/library.ts b/packages/ai-autopilot/src/personas/library.ts index 7d5a8c3..e9a82f0 100644 --- a/packages/ai-autopilot/src/personas/library.ts +++ b/packages/ai-autopilot/src/personas/library.ts @@ -3,7 +3,7 @@ import type { Persona } from './types.js' /** * The built-in, stack-aware personas — the opinionated knowledge that makes - * autopilot know the GemStack stack (Vike + universal-orm) instead of guessing. + * autopilot know the GemStack stack (Vike + Prisma) instead of guessing. * Each carries conventions-level guidance; detailed how-to arrives via skills * attached to a persona at use time. */ @@ -56,21 +56,31 @@ Do not reach for \`getServerSideProps\`/\`getStaticProps\` (that is the older Pa Router); use the App Router data model.`, }) -/** Defines schema, derives migrations, and writes queries on universal-orm. */ -export const universalOrmModeler: Persona = definePersona({ - name: 'universal-orm-modeler', - role: 'Models data with universal-orm: schema first, migrations derived, typed queries', - appliesTo: ['@universal-orm/*', 'universal-orm'], - systemPrompt: `You own the data layer with universal-orm, which is schema-first and -adapter-agnostic (a native engine and Prisma/Drizzle adapters share one API). +/** Defines schema, derives migrations, and writes typed queries with Prisma (the default ORM). */ +export const dataModeler: Persona = definePersona({ + name: 'data-modeler', + 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 +another published SQL ORM (e.g. Drizzle). Never assume an unpublished/private ORM +is installable: use only packages that resolve on npm. + +Set up Prisma concretely, do not investigate whether it exists: +- Install: \`npm install -D prisma\` and \`npm install @prisma/client\`. +- Init once: \`npx prisma init --datasource-provider postgresql\` (or sqlite for a + quick start). This creates \`prisma/schema.prisma\` and a \`DATABASE_URL\` in \`.env\`. +- Define your models in \`prisma/schema.prisma\`, then \`npx prisma migrate dev --name + \` to derive and apply a migration, and \`npx prisma generate\` for the client. +- Import the typed client from \`@prisma/client\` and query through it. Conventions to follow: -- The schema is the source of truth. Define models/tables in the schema; derive - migrations from it rather than hand-writing DDL. Regenerate the typed registry - after a schema change so queries stay typed. -- Reads and writes go through the model query builder, not raw SQL. Reach for - raw SQL only when the builder genuinely cannot express the query, and prefer - bulk/cursor helpers (chunked iteration, RETURNING on writes) over N+1 loops. +- The schema is the source of truth. Define models in the schema; derive + migrations from it rather than hand-writing DDL. Regenerate the client after a + schema change so queries stay typed. +- Reads and writes go through the typed client, not raw SQL. Reach for raw SQL + only when the client genuinely cannot express the query, and prefer batch + helpers over N+1 loops. - Keep the data layer separate from the framework: it does not import Vike or know about pages. A page's \`+data\` hook calls into it; it never calls back. - Migrations are forward-only and reviewed. Never edit an applied migration — @@ -117,7 +127,7 @@ to the decoupled implementation.`, * A preset adds its framework-specific page builder on top (see the presets seam). */ export const sharedPersonas: readonly Persona[] = Object.freeze([ - universalOrmModeler, + dataModeler, uiIntentDesigner, ]) diff --git a/packages/ai-autopilot/src/personas/types.ts b/packages/ai-autopilot/src/personas/types.ts index 044969b..0ad7021 100644 --- a/packages/ai-autopilot/src/personas/types.ts +++ b/packages/ai-autopilot/src/personas/types.ts @@ -5,7 +5,7 @@ import type { LoadedSkill } from '@gemstack/ai-skills' * A reusable, stack-aware role an agent can take on. A persona is *data*: a * name, a one-line role, a system-prompt fragment, and the skills/tools it * brings. It carries opinionated knowledge of the GemStack stack (Vike + - * universal-orm) so an autopilot run is not generic — it knows where pages + * Prisma) so an autopilot run is not generic — it knows where pages * live, how the schema drives migrations, and to express UI as intent. * * A persona is materialized into an `Agent` on demand (see `personaAgent`), diff --git a/packages/ai-autopilot/src/presets/library.test.ts b/packages/ai-autopilot/src/presets/library.test.ts index b8ced9c..a2d336b 100644 --- a/packages/ai-autopilot/src/presets/library.test.ts +++ b/packages/ai-autopilot/src/presets/library.test.ts @@ -20,7 +20,7 @@ describe('built-in presets', () => { describe('presetPersonas', () => { it('is the preset page builder followed by the shared neutral personas', () => { const names = presetPersonas(vikePreset).map(p => p.name) - assert.deepEqual(names, ['vike-page-builder', 'universal-orm-modeler', 'ui-intent-designer']) + assert.deepEqual(names, ['vike-page-builder', 'data-modeler', 'ui-intent-designer']) }) it('swaps only the page builder between frameworks — the rest of the stack is shared', () => { diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 42e88b3..cc699e0 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -34,7 +34,7 @@ Options: --cwd Workspace the agent builds in (default: current directory). --model Model to pass through to the wrapped agent. --scope How much app to build (default: full). - --max-passes Full-fledged loop pass budget (default: 3). + --max-passes Full-fledged loop pass budget (default: 5). --permission-mode Claude Code permission mode: default | acceptEdits | bypassPermissions | plan (default: acceptEdits). --dangerously-skip-permissions Bypass all agent permission checks (sandboxes only). diff --git a/packages/framework/src/dashboard/page.ts b/packages/framework/src/dashboard/page.ts index 3af3c41..441363e 100644 --- a/packages/framework/src/dashboard/page.ts +++ b/packages/framework/src/dashboard/page.ts @@ -185,7 +185,7 @@ function onEvent(fe) { else if (fe.kind === 'bootstrap') bootstrap(fe.event); else if (fe.kind === 'driver') driver(fe.event); else if (fe.kind === 'log') log(fe.message); - else if (fe.kind === 'end') { $('status').textContent = fe.ok ? '\\u25cf done' : '\\u25cf failed'; } + else if (fe.kind === 'end') { $('status').textContent = fe.ok ? '\\u25cf finished' : '\\u25cf failed'; } } function esc(s) { const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; } const src = new EventSource('/events'); diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts index 798862b..5f62aa2 100644 --- a/packages/framework/src/events.ts +++ b/packages/framework/src/events.ts @@ -51,7 +51,7 @@ export function formatFrameworkEvent(event: FrameworkEvent): string { case 'bootstrap': return formatBootstrapEvent(event.event) case 'end': - return event.ok ? '✓ done' : `✗ failed: ${event.detail ?? 'unknown error'}` + return event.ok ? '✓ finished' : `✗ failed: ${event.detail ?? 'unknown error'}` } } diff --git a/packages/framework/src/fake-script.ts b/packages/framework/src/fake-script.ts index e57d575..c5e51f6 100644 --- a/packages/framework/src/fake-script.ts +++ b/packages/framework/src/fake-script.ts @@ -3,7 +3,7 @@ import { FakeDriver, type FakeTurn } from './driver/index.js' import type { DeployDecision } from './run.js' /** - * The deterministic `--fake` scenario: a small Vike + universal-orm orders app. + * 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 @@ -16,7 +16,7 @@ export const FAKE_INTENT = 'A paginated orders page backed by an orders table, w /** Deps that make the Vike preset win detection in the demo. */ export const FAKE_SIGNALS: FrameworkSignals = { - dependencies: { 'vike-react': '1.0.0', react: '18.0.0', '@universal-orm/core': '1.0.0' }, + dependencies: { 'vike-react': '1.0.0', react: '18.0.0', '@prisma/client': '5.0.0' }, } /** The deploy decision narrated at the end of the demo. */ @@ -27,10 +27,10 @@ export const FAKE_DEPLOY: DeployDecision = { } const ARCHITECT = { - stack: 'Vike + universal-orm on Postgres, with vike-auth', - narration: 'Server-rendered orders app: Vike pages, a universal-orm data layer, sessions via vike-auth.', + 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: 'universal-orm on Postgres', why: 'the orders catalog is relational and needs typed queries' }, + { 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' }, ], } diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index fd1fe64..94c4a8d 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { runFramework } from './run.js' +import { DEFAULT_MAX_PASSES, runFramework } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import type { FrameworkEvent } from './events.js' @@ -97,6 +97,11 @@ test('runFramework shows a literal session link immediately (no template)', asyn assert.equal(session.sessionLink, 'https://code.example.com/live') }) +test('the default pass budget is raised for from-scratch builds (#182)', () => { + // 3 was too low: the first passes go to bootstrapping an empty workspace. + assert.equal(DEFAULT_MAX_PASSES, 5) +}) + test('runFramework prototype scope skips the full-fledged loop', async () => { const { result } = await runFramework({ intent: 'a quick landing page', diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 1695e75..254dcf6 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -19,6 +19,13 @@ import type { Driver, DriverEvent, DriverSession } from './driver/index.js' import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove } from './steps.js' import { hasSessionIdPlaceholder, resolveSessionLink, type FrameworkEvent } from './events.js' +/** + * The framework's default full-fledged pass budget. Higher than ai-autopilot's + * base of 3 because a from-scratch build spends its first pass or two just + * bootstrapping an empty workspace before there is anything to polish (#182). + */ +export const DEFAULT_MAX_PASSES = 5 + /** The deploy decision to narrate at the end (plan-only in v1: it does not ship). */ export interface DeployDecision { render: 'ssr' | 'ssg' | 'spa' @@ -68,7 +75,7 @@ export interface RunFrameworkOptions { model?: string /** Signals for preset detection (deps/files). Default: none, so the flagship preset wins. */ signals?: FrameworkSignals - /** Max full-fledged passes. Default 3 (ai-autopilot's default). */ + /** Max full-fledged passes. Default {@link DEFAULT_MAX_PASSES} (5). */ maxPasses?: number /** A deploy decision to narrate at the end. Omit to skip the deploy phase. */ deploy?: DeployDecision @@ -212,20 +219,26 @@ export async function runFramework(opts: RunFrameworkOptions): Promise emit({ kind: 'bootstrap', event }), steps: { scope: () => ({ scope: opts.scope ?? 'full', intent: opts.intent }), architect: driverArchitect(session), - build: driverBuild(session), + build: driverBuild(session, verifyWorkspace ? { verifyWorkspace: true } : {}), checklist, - improve: driverImprove(session), + improve: driverImprove(session, verifyWorkspace ? { verifyWorkspace: true } : {}), ...(opts.deploy && opts.deployTarget ? { deploy: deployWith(opts.deploy, opts.deployTarget) } : opts.deploy diff --git a/packages/framework/src/steps.test.ts b/packages/framework/src/steps.test.ts index aed05e4..195f6f1 100644 --- a/packages/framework/src/steps.test.ts +++ b/packages/framework/src/steps.test.ts @@ -1,8 +1,30 @@ import { strict as assert } from 'node:assert' 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, type DeployTarget, type SupervisorEvent } from '@gemstack/ai-autopilot' import { FakeDriver } from './driver/index.js' -import { deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove, parseArchitectPlan } from './steps.js' +import { + deployWith, + driverArchitect, + driverBuild, + driverChecklist, + driverImprove, + isWorkspaceEmpty, + parseArchitectPlan, +} from './steps.js' + +/** Make a throwaway workspace dir, seeded with the given relative files. */ +function makeWorkspace(files: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'fw-steps-')) + for (const [rel, contents] of Object.entries(files)) { + const full = join(dir, rel) + mkdirSync(join(full, '..'), { recursive: true }) + writeFileSync(full, contents) + } + return dir +} const PLAN = { stack: 'Vike + universal-orm', narration: 'orders app', decisions: [] } @@ -83,3 +105,83 @@ test('driverImprove prompts the driver with the blockers', async () => { await driverImprove(session)({ pass: 1, plan: PLAN, intent: 'x', blockers: ['add auth'] }) assert.match(session.prompts[0]!, /add auth/) }) + +test('isWorkspaceEmpty: true for an empty dir and one with only noise, false with a source file', () => { + const empty = makeWorkspace() + const noise = makeWorkspace({ + 'package-lock.json': '{}', + '.gitignore': 'node_modules', + 'node_modules/dep/index.js': 'x', + }) + const built = makeWorkspace({ 'src/index.ts': 'export {}' }) + try { + assert.equal(isWorkspaceEmpty(empty), true) + assert.equal(isWorkspaceEmpty(noise), true) + assert.equal(isWorkspaceEmpty(built), false) + assert.equal(isWorkspaceEmpty(join(empty, 'does-not-exist')), true) + } finally { + for (const d of [empty, noise, built]) rmSync(d, { recursive: true, force: true }) + } +}) + +test('driverBuild re-prompts to scaffold from scratch when the workspace stays empty (#182)', async () => { + const cwd = makeWorkspace() // empty: the agent produced nothing + try { + const session = await new FakeDriver({ + turns: [{ text: 'thinking about the stack' }, { text: 'scaffolded the whole app' }], + }).start({ cwd }) + const events: SupervisorEvent[] = [] + const run = await driverBuild(session, { verifyWorkspace: true })({ + plan: PLAN, + scope: 'full', + intent: 'a blog', + onEvent: e => events.push(e), + }) + // Two dispatches: the build, then the hard from-scratch retry. + assert.equal(session.prompts.length, 2) + assert.match(session.prompts[1]!, /from scratch|empty/i) + assert.equal(run.plan.length, 2) + assert.deepEqual( + events.map(e => e.type), + ['plan', 'dispatch-start', 'dispatch-result', 'dispatch-start', 'dispatch-result', 'synthesize'], + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + +test('driverBuild does not re-prompt when the build produced files', async () => { + const cwd = makeWorkspace({ 'package.json': '{}', 'pages/index.tsx': 'export default () => null' }) + 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: () => {}, + }) + assert.equal(session.prompts.length, 1) + assert.equal(run.plan.length, 1) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + +test('driverImprove scaffolds from scratch when the workspace is empty, else fixes blockers (#182)', async () => { + const emptyCwd = makeWorkspace() + 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'] }) + // 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'] }) + assert.match(s2.prompts[0]!, /add auth/) + } finally { + rmSync(emptyCwd, { recursive: true, force: true }) + rmSync(builtCwd, { recursive: true, force: true }) + } +}) diff --git a/packages/framework/src/steps.ts b/packages/framework/src/steps.ts index 9cd22cd..422a58e 100644 --- a/packages/framework/src/steps.ts +++ b/packages/framework/src/steps.ts @@ -1,3 +1,5 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' import { parseVerdict } from '@gemstack/ai-autopilot' import type { ArchitectContext, @@ -46,11 +48,31 @@ export function buildPrompt(plan: ArchitectPlan, intent: string): string { `Build this app end to end: ${intent}`, `Stack: ${plan.stack}`, plan.narration, - 'Create every file needed and make the app run. Follow the stack conventions.', + '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.', 'When done, summarize what you built in one short paragraph.', ].join('\n') } +/** + * A hard "the app does not exist yet — create it from scratch" directive. Used + * when the workspace is empty at build or improve time, where the normal + * {@link improvePrompt} ("smallest changes / no unrelated features") would + * wrongly discourage scaffolding (#182). + */ +export function scaffoldPrompt(plan: ArchitectPlan, 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', + 'dependencies, and do not stop until the requested features exist and the app runs.', + ].join('\n') +} + /** The default production-grade checklist prompt. Ends with a `{ blockers }` verdict. */ export const PRODUCTION_GRADE_PROMPT = [ 'Review the app in this workspace against a production-grade checklist:', @@ -66,10 +88,52 @@ export function improvePrompt(blockers: readonly string[]): string { return [ 'Address these blockers in the app, then stop:', ...blockers.map(b => `- ${b}`), - 'Make the smallest changes that clear them. Do not add unrelated features.', + 'Make the changes needed to clear them, and only those — but do whatever they', + 'require, including adding missing features, files, or dependencies. Do not chase', + 'unrelated polish.', ].join('\n') } +const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', '.cache', '.vite']) +const IGNORED_FILES = new Set([ + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + '.gitignore', + '.npmrc', + '.DS_Store', +]) + +/** + * Whether a workspace holds no app yet — no source file the agent could have + * produced. Used to detect a build that stalled without scaffolding (#182): + * lockfiles, dotfiles, and dependency/output dirs do not count. Best-effort and + * cheap: it stops at the first real file and never throws. + */ +export function isWorkspaceEmpty(dir: string): boolean { + return !hasSourceFile(dir, 0) +} + +function hasSourceFile(dir: string, depth: number): boolean { + if (depth > 6) return false + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return false // unreadable / missing dir: treat as empty. + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue + if (hasSourceFile(join(dir, entry.name), depth + 1)) return true + } else if (entry.isFile()) { + if (IGNORED_FILES.has(entry.name) || entry.name.startsWith('.')) continue + return true + } + } + return false +} + /** Options shared by the driver-backed steps. */ export interface DriverStepOptions { /** Extra per-step framing appended to the session system prompt. */ @@ -102,23 +166,52 @@ export function driverArchitect( */ export function driverBuild( session: DriverSession, - opts: { prompt?: (plan: ArchitectPlan, intent: string) => string } & DriverStepOptions = {}, + opts: { + prompt?: (plan: ArchitectPlan, 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 + * with a hard from-scratch directive (#182). Off by default; the runner + * enables it for real drivers (the fake driver writes no files, so it stays + * off there). + */ + verifyWorkspace?: boolean + } & DriverStepOptions = {}, ): (ctx: BuildContext) => Promise { const compose = opts.prompt ?? buildPrompt + const promptOpts = { + ...(opts.system ? { system: opts.system } : {}), + } return async ctx => { + const signalOpt = ctx.signal ? { signal: ctx.signal } : {} const subtask: PlannedSubtask = { id: 'build-1', description: `Build with the wrapped agent` } ctx.onEvent({ type: 'plan', task: ctx.intent, subtasks: [subtask] }) ctx.onEvent({ type: 'dispatch-start', subtask }) - const turn = await session.prompt(compose(ctx.plan, ctx.intent), { - ...(opts.system ? { system: opts.system } : {}), - ...(ctx.signal ? { signal: ctx.signal } : {}), - }) + let turn = await session.prompt(compose(ctx.plan, ctx.intent), { ...promptOpts, ...signalOpt }) + const results: SubtaskResult[] = [{ subtask, text: turn.text, ok: true, usage: ZERO_USAGE }] + ctx.onEvent({ type: 'dispatch-result', result: results[0]! }) - const result: SubtaskResult = { subtask, text: turn.text, ok: true, usage: ZERO_USAGE } - ctx.onEvent({ type: 'dispatch-result', result }) - ctx.onEvent({ type: 'synthesize', results: [result] }) - return { text: turn.text, plan: [subtask], results: [result], usage: ZERO_USAGE, stoppedEarly: false } + // #182: the build must actually produce an app. If nothing landed on disk, + // the agent stalled (e.g. sanity-checking the stack) — re-prompt once with a + // hard "create it from scratch" directive so an empty-dir run starts building. + if (opts.verifyWorkspace && isWorkspaceEmpty(session.cwd)) { + 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 }) + const retryResult: SubtaskResult = { subtask: retry, text: turn.text, ok: true, usage: ZERO_USAGE } + results.push(retryResult) + ctx.onEvent({ type: 'dispatch-result', result: retryResult }) + } + + ctx.onEvent({ type: 'synthesize', results }) + return { + text: turn.text, + plan: results.map(r => r.subtask), + results, + usage: ZERO_USAGE, + stoppedEarly: false, + } } } @@ -146,11 +239,23 @@ export function driverChecklist( /** The improve step: a fresh invocation that fixes the current blockers. */ export function driverImprove( session: DriverSession, - opts: { prompt?: (blockers: readonly string[]) => string } & DriverStepOptions = {}, + opts: { + prompt?: (blockers: readonly string[]) => string + /** + * When the workspace is still empty at improve time, switch from the + * blocker-polish prompt to a hard "scaffold the whole app from scratch" + * directive (#182) — otherwise "smallest changes / no unrelated features" + * blocks the agent from building the app that does not exist yet. Off by + * default; the runner enables it for real drivers. + */ + verifyWorkspace?: boolean + } & DriverStepOptions = {}, ): (ctx: LoopPassContext) => Promise { const compose = opts.prompt ?? improvePrompt return async ctx => { - await session.prompt(compose(ctx.blockers), { + const scaffold = opts.verifyWorkspace && isWorkspaceEmpty(session.cwd) + const text = scaffold ? scaffoldPrompt(ctx.plan, ctx.intent) : compose(ctx.blockers) + await session.prompt(text, { ...(opts.system ? { system: opts.system } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}), })