Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/existing-project-extend-framing.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ export {
parseArchitectPlan,
architectPrompt,
buildPrompt,
extendPrompt,
improvePrompt,
PRODUCTION_GRADE_PROMPT,
isWorkspaceEmpty,
type DriverStepOptions,
} from './steps.js'
export {
Expand Down
48 changes: 48 additions & 0 deletions packages/framework/src/steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
driverBuild,
driverChecklist,
driverImprove,
extendPrompt,
isWorkspaceEmpty,
parseArchitectPlan,
} from './steps.js'
Expand Down Expand Up @@ -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 {}' })
Expand Down
39 changes: 36 additions & 3 deletions packages/framework/src/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,17 +202,30 @@ export function driverBuild(
verifyWorkspace?: boolean
} & DriverStepOptions = {},
): (ctx: BuildContext) => Promise<SupervisorRun> {
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]! })

Expand Down
Loading