From 7d6f42cbdb2155f2c2962578958c3250c453f3b7 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 4 Jul 2026 21:41:40 +0300 Subject: [PATCH] feat(framework): extend an existing project instead of rebuilding it (#185) When the workspace already holds source, driverBuild now frames the wrapped agent to work within the existing codebase (read it, follow its conventions, add what was asked) via a new extendPrompt, instead of the greenfield 'scaffold from scratch' buildPrompt. Detection reuses isWorkspaceEmpty and is gated on verifyWorkspace, so the fake driver stays greenfield and deterministic. Greenfield (empty workspace) behavior is unchanged; improve already framed existing projects correctly. With live preset detection already wired from the real cwd, an existing project now detects its real stack and extends it. New exports: extendPrompt, isWorkspaceEmpty. Part of #110. Closes #185. --- .changeset/existing-project-extend-framing.md | 11 +++++ packages/framework/README.md | 6 +++ packages/framework/src/index.ts | 2 + packages/framework/src/steps.test.ts | 48 +++++++++++++++++++ packages/framework/src/steps.ts | 39 +++++++++++++-- 5 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 .changeset/existing-project-extend-framing.md 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]! })