diff --git a/.changeset/existing-project-extend-framing.md b/.changeset/existing-project-extend-framing.md new file mode 100644 index 0000000..19829e0 --- /dev/null +++ b/.changeset/existing-project-extend-framing.md @@ -0,0 +1,11 @@ +--- +'@gemstack/framework': minor +--- + +Extend an existing project instead of rebuilding it from scratch + +Pointed at a workspace that already has source, the build step now frames the wrapped agent to work *within* the existing codebase (read it, follow its conventions, add what was asked) rather than scaffold a fresh app. Greenfield runs (an empty workspace) are unchanged, and detection is gated on a real driver, so `--fake` stays deterministic. Combined with the live preset detection already wired from the real workspace, running in an existing project now detects its real stack and extends it. + +New exports: `extendPrompt`, `isWorkspaceEmpty`. + +Part of #110. Closes #185. diff --git a/packages/framework/README.md b/packages/framework/README.md index 2a52b08..99676d0 100644 --- a/packages/framework/README.md +++ b/packages/framework/README.md @@ -41,6 +41,12 @@ small structured JSON decision the agent returns; build and improve are prompts; the production-grade checklist gates on the `{ blockers }` verdict the agent ends its output with. +**From-scratch or existing project.** Point `--cwd` at an empty directory and the +agent scaffolds the whole app from scratch. Point it at a project that already has +source and the framework detects the real stack (from its `package.json` + marker +files) and frames the agent to **extend** the codebase — read it, follow its +conventions, add what was asked — instead of rebuilding it from scratch. + ## Library API ```ts diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 5559ae2..ebdc250 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -43,8 +43,10 @@ export { parseArchitectPlan, architectPrompt, buildPrompt, + extendPrompt, improvePrompt, PRODUCTION_GRADE_PROMPT, + isWorkspaceEmpty, type DriverStepOptions, } from './steps.js' export { diff --git a/packages/framework/src/steps.test.ts b/packages/framework/src/steps.test.ts index 098d6a1..bce629d 100644 --- a/packages/framework/src/steps.test.ts +++ b/packages/framework/src/steps.test.ts @@ -12,6 +12,7 @@ import { driverBuild, driverChecklist, driverImprove, + extendPrompt, isWorkspaceEmpty, parseArchitectPlan, } from './steps.js' @@ -200,6 +201,53 @@ test('driverBuild does not re-prompt when the build produced files', async () => } }) +test('driverBuild extends an existing project instead of rebuilding it (#185)', async () => { + const cwd = makeWorkspace({ 'package.json': '{}', 'src/index.ts': 'export {}' }) + try { + 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), + }) + // Existing codebase: extend framing, and no from-scratch scaffolding language. + assert.equal(session.prompts.length, 1) + assert.match(session.prompts[0]!, /existing codebase|do NOT re-scaffold/i) + assert.doesNotMatch(session.prompts[0]!, /scaffold the whole project|workspace may be empty/i) + const plan = events.find(e => e.type === 'plan') + assert.ok(plan?.type === 'plan') + assert.match(plan.subtasks[0]!.description, /existing codebase/i) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + +test('driverBuild uses greenfield framing for an empty workspace (#185)', async () => { + const cwd = makeWorkspace() // empty: a from-scratch build + 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: () => {}, + }) + assert.match(session.prompts[0]!, /Build this app end to end/i) + assert.doesNotMatch(session.prompts[0]!, /existing codebase/i) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + +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') + 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) +}) + 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 {}' }) diff --git a/packages/framework/src/steps.ts b/packages/framework/src/steps.ts index 7c172b7..97a90fe 100644 --- a/packages/framework/src/steps.ts +++ b/packages/framework/src/steps.ts @@ -59,6 +59,26 @@ export function buildPrompt(plan: ArchitectPlan, intent: string): string { ].join('\n') } +/** + * Framing for a run against an *existing* codebase: extend it, do not rebuild it. + * The greenfield {@link buildPrompt} tells the agent the workspace may be empty + * and to scaffold from scratch, which is the wrong instruction when the user + * 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 { + 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', + 'is asked; new files and dependencies are fine when the feature needs them.', + 'When done, summarize what you changed 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 @@ -182,17 +202,30 @@ export function driverBuild( verifyWorkspace?: boolean } & DriverStepOptions = {}, ): (ctx: BuildContext) => Promise { - const compose = opts.prompt ?? buildPrompt + const composeOverride = opts.prompt 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` } + // An existing project (a non-empty workspace at build time) is *extended*, not + // rebuilt from scratch (#185). Gated on verifyWorkspace so the fake driver + // (which writes nothing, so its workspace always reads empty) always takes the + // 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) + : existing + ? extendPrompt(ctx.plan, ctx.intent) + : buildPrompt(ctx.plan, ctx.intent) + const subtask: PlannedSubtask = { + id: 'build-1', + description: existing ? 'Extend the existing codebase' : 'Build with the wrapped agent', + } ctx.onEvent({ type: 'plan', task: ctx.intent, subtasks: [subtask] }) ctx.onEvent({ type: 'dispatch-start', subtask }) - let turn = await session.prompt(compose(ctx.plan, ctx.intent), { ...promptOpts, ...signalOpt }) + let turn = await session.prompt(firstPrompt, { ...promptOpts, ...signalOpt }) const results: SubtaskResult[] = [{ subtask, text: turn.text, ok: true, usage: ZERO_USAGE }] ctx.onEvent({ type: 'dispatch-result', result: results[0]! })