From 4090f70c540c4f580a8e83ace1a7b2e94c4cc561 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Mon, 13 Jul 2026 17:11:58 +0300 Subject: [PATCH] feat(framework): wrap the user prompt for bootstrap mode instead of a system-prompt preamble Closes #457. Part of #297. Rom's steer on #297: treat Claude Code as a black box and wrap the user prompt rather than injecting via --append-system-prompt. New wrapBootstrapPrompt() rewords a brand-new-project intent into instructions (analyze, showMarkdown the analysis + plan, await approval, no code first), with the original request preserved at the end. The direct-prompt path wraps its first user prompt; the build path prepends the lighter BOOTSTRAP_ARCHITECT_NOTE to the architect turn, whose plan-approval gate already awaits. Removes the BOOTSTRAP_PREAMBLE system-prompt export and the bootstrap option on systemPromptBlock. CLI --bootstrap and daemon mapping unchanged. --- .changeset/bootstrap-wrap-prompt.md | 5 ++ packages/framework/src/cli.ts | 2 +- packages/framework/src/index.ts | 3 +- packages/framework/src/prompt-run.test.ts | 19 +++++++ packages/framework/src/prompt-run.ts | 12 +++-- packages/framework/src/run.test.ts | 19 +++++++ packages/framework/src/run.ts | 25 +++++---- packages/framework/src/system-prompt.test.ts | 32 +++++++----- packages/framework/src/system-prompt.ts | 53 ++++++++++++-------- 9 files changed, 121 insertions(+), 49 deletions(-) create mode 100644 .changeset/bootstrap-wrap-prompt.md diff --git a/.changeset/bootstrap-wrap-prompt.md b/.changeset/bootstrap-wrap-prompt.md new file mode 100644 index 0000000..ab688f5 --- /dev/null +++ b/.changeset/bootstrap-wrap-prompt.md @@ -0,0 +1,5 @@ +--- +"@gemstack/framework": minor +--- + +Bootstrap mode (#297/#457) now rewords the user prompt into instructions instead of injecting a system-prompt preamble, per Rom's steer on #297 (treat Claude Code as a black box and wrap the user prompt; instructions carried in the prompt itself outweigh "contextual" system-prompt text the agent is tempted to skip). New `wrapBootstrapPrompt(userPrompt)` frames a brand-new-project run to analyze the request, `showMarkdown()` the analysis and plan, and await approval before writing any code, with the original request preserved at the end. The direct-prompt path wraps its first user prompt; the build path prepends a lighter `BOOTSTRAP_ARCHITECT_NOTE` to the architect turn (its plan-approval gate already awaits the plan). The old `BOOTSTRAP_PREAMBLE` system-prompt export and the `bootstrap` option on `systemPromptBlock` are removed. The `--bootstrap` CLI flag and its daemon mapping are unchanged. diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 4dc4a84..a3853ff 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -227,7 +227,7 @@ export interface CliOptions { eco: Required /** `--context ` (repeatable): in-context directories added as one `Context:` line (#439). */ context: string[] - /** `--bootstrap`: bootstrap mode (#297/#448) — a new project from an empty dir; stop for a plan first. */ + /** `--bootstrap`: bootstrap mode (#297/#457) — a new project from an empty dir; stop for a plan first. */ bootstrap: boolean buildEvent?: string | undefined maxPasses?: number diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 1c1624f..139a254 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -224,7 +224,8 @@ export { renderSystemPrompt, SYSTEM_PROMPT_TEMPLATE, SYSTEM_PROMPT_FILE, - BOOTSTRAP_PREAMBLE, + wrapBootstrapPrompt, + BOOTSTRAP_ARCHITECT_NOTE, type SystemPromptOptions, type TfContext, type RenderedSystemPrompt, diff --git a/packages/framework/src/prompt-run.test.ts b/packages/framework/src/prompt-run.test.ts index 41838f6..854c123 100644 --- a/packages/framework/src/prompt-run.test.ts +++ b/packages/framework/src/prompt-run.test.ts @@ -59,6 +59,25 @@ test('runPrompt surfaces the system prompt (#343); the user prompt rides a drive if (start.event.type === 'start') assert.match(start.event.prompt, /refactor the auth flow/) }) +test('runPrompt wraps the user prompt in bootstrap mode, not the system prompt (#297/#457)', async () => { + const events: FrameworkEvent[] = [] + const driver = new FakeDriver({ turns: [{ text: 'here is my analysis' }] }) + await runPrompt({ prompt: 'Build an instagram clone', driver, cwd: '/ws', bootstrap: true, onEvent: e => events.push(e) }) + + // The bootstrap framing rides the user prompt (Rom's steer), not the system channel. + const start = events.find( + (e): e is Extract => e.kind === 'driver' && e.event.type === 'start', + ) + assert.ok(start && start.event.type === 'start') + if (start.event.type === 'start') { + assert.match(start.event.prompt, /brand-new project/) + assert.match(start.event.prompt, /showMarkdown\(\)/) + assert.match(start.event.prompt, /Build an instagram clone/) // original request preserved + } + const sys = events.find(e => e.kind === 'system-prompt') as { text: string } | undefined + assert.ok(sys && !sys.text.includes('brand-new project')) // system channel unchanged +}) + test('runPrompt honors eco flags in the emitted system prompt (#314)', async () => { const events: FrameworkEvent[] = [] const driver = new FakeDriver({ turns: [{ text: 'done' }] }) diff --git a/packages/framework/src/prompt-run.ts b/packages/framework/src/prompt-run.ts index 82cb1fb..e84a7bc 100644 --- a/packages/framework/src/prompt-run.ts +++ b/packages/framework/src/prompt-run.ts @@ -1,7 +1,7 @@ import type { Driver, DriverEvent, DriverSession } from './driver/index.js' import { hasSessionIdPlaceholder, resolveSessionLink, type ChoicePick, type ChoiceRequest, type FrameworkEvent } from './events.js' import { resolveAwaitGate } from './run.js' -import { renderSystemPrompt, systemPromptBlock, type EcoOptions, type TfContext } from './system-prompt.js' +import { renderSystemPrompt, systemPromptBlock, wrapBootstrapPrompt, type EcoOptions, type TfContext } from './system-prompt.js' import { AWAIT_PROTOCOL, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, parseMarkdownViews } from './turn-gate.js' import { UsageMeter } from './usage.js' @@ -44,7 +44,7 @@ export interface RunPromptOptions { eco?: EcoOptions /** In-context directories (#439): added as one `Context:` line to the system prompt. */ context?: readonly string[] - /** Bootstrap mode (#297/#448): prepend the forceful preamble so the first turn stops for a plan. Default false. */ + /** Bootstrap mode (#297/#457): reword the user prompt into instructions so the first turn analyzes and awaits before building. Default false. */ bootstrap?: boolean /** Stop the run once the agent has spent this much, in USD (#322). */ budgetUsd?: number @@ -84,12 +84,14 @@ export async function runPrompt(opts: RunPromptOptions): Promise { + const events: FrameworkEvent[] = [] + await runFramework({ + intent: FAKE_INTENT, + driver: fakeDriver(), + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, + bootstrap: true, + onEvent: e => events.push(e), + }) + const prompts = events.flatMap(e => (e.kind === 'driver' && e.event.type === 'start' ? [e.event.prompt] : [])) + const architect = prompts.find(p => p.includes('You are the architect')) + assert.ok(architect, 'an architect prompt was sent') + assert.ok(architect!.includes('brand-new project')) // BOOTSTRAP_ARCHITECT_NOTE prepended + // The note is scoped to the architect: build/scaffold turns keep the raw intent so + // they actually build after approval, not re-analyze. + assert.equal(prompts.filter(p => p.includes('brand-new project')).length, 1) +}) + test('runFramework surfaces the wrapped agent real session id via session-update', async () => { const events: FrameworkEvent[] = [] await runFramework({ diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 164afbb..06cad36 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -37,12 +37,12 @@ import { import { snapshotWorkspace } from './sandbox.js' import type { Driver, DriverEvent, DriverSession } from './driver/index.js' import { memoryFraming, type LoadedMemory } from './memory.js' -import { systemPromptBlock, type EcoOptions, type TfContext } from './system-prompt.js' +import { systemPromptBlock, BOOTSTRAP_ARCHITECT_NOTE, type EcoOptions, type TfContext } from './system-prompt.js' import { AWAIT_PROTOCOL, CONFIRM_APPROVED, CONFIRM_DECLINED, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, parseMarkdownViews, type ParsedAwaitGate } from './turn-gate.js' // Value import from todo-loop.js is a benign cycle: todo-loop.js only calls // run.js's hoisted function declarations (requestChoices / resolveAwaitGate). import { runTodoLoop, type TodoLoopResult } from './todo-loop.js' -import { continueAfterChoice, decideDeploy, deployWith, domainLoopChecklist, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts, reArchitect, type AwaitResolver } from './steps.js' +import { architectPrompt, continueAfterChoice, decideDeploy, deployWith, domainLoopChecklist, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts, reArchitect, type AwaitResolver } from './steps.js' import { hasSessionIdPlaceholder, OPEN_LOOP_MODES, pickedIds, resolveSessionLink, type ChoicePick, type ChoiceRequest, type FrameworkEvent } from './events.js' import { UsageMeter } from './usage.js' @@ -126,8 +126,9 @@ export interface RunFrameworkOptions { /** In-context directories (#439): added as one `Context:` line to the system prompt. */ context?: readonly string[] /** - * Bootstrap mode (#297/#448): a brand-new project from an empty directory. Prepends a - * forceful preamble so the first turn stops for a plan instead of charging ahead. Default off. + * Bootstrap mode (#297/#457): a brand-new project from an empty directory. Rewords the + * architect's first turn to settle the plan before any code (the plan-approval gate then + * awaits it), instead of charging ahead. Default off. */ bootstrap?: boolean /** @@ -350,7 +351,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise `${BOOTSTRAP_ARCHITECT_NOTE}\n\n${architectPrompt(intent)}` } : {}), + }), + session, + { ...gateDeps, ...architectAwait }, + ), build: agentAwaitGate(driverBuild(session, workspaceOpt), session, gateDeps), checklist, improve: driverImprove(session, workspaceOpt), diff --git a/packages/framework/src/system-prompt.test.ts b/packages/framework/src/system-prompt.test.ts index 4e7634f..1bc4818 100644 --- a/packages/framework/src/system-prompt.test.ts +++ b/packages/framework/src/system-prompt.test.ts @@ -4,10 +4,11 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { - BOOTSTRAP_PREAMBLE, + BOOTSTRAP_ARCHITECT_NOTE, loadUserSystemPrompt, renderSystemPrompt, systemPromptBlock, + wrapBootstrapPrompt, SYSTEM_PROMPT_FILE, SYSTEM_PROMPT_TEMPLATE, } from './system-prompt.js' @@ -99,19 +100,26 @@ test('systemPromptBlock threads tf through to the template', () => { assert.ok(block.includes('postpone a deep refactor')) }) -test('systemPromptBlock prepends the bootstrap preamble above the built-in prompt (#297/#448)', () => { - const off = systemPromptBlock() - assert.ok(!off.includes(BOOTSTRAP_PREAMBLE)) // default off: no preamble - const on = systemPromptBlock({ bootstrap: true }) - assert.ok(on.startsWith(BOOTSTRAP_PREAMBLE)) // preamble first - assert.ok(on.includes('# System prompt')) // then the byte-identical #326 template - assert.ok(on.indexOf(BOOTSTRAP_PREAMBLE) < on.indexOf('# System prompt')) // preamble outranks it +test('wrapBootstrapPrompt rewords the intent into instructions and keeps the request (#297/#457)', () => { + const wrapped = wrapBootstrapPrompt(' Build an instagram clone ') + assert.ok(wrapped.includes('brand-new project')) // frames the empty-dir situation + assert.ok(wrapped.includes('showMarkdown()')) // Rom's action: analysis via showMarkdown + assert.ok(/await my approval/i.test(wrapped)) // stop and await before code + assert.ok(wrapped.trimEnd().endsWith('Build an instagram clone')) // original request rides at the end, trimmed }) -test('systemPromptBlock keeps the bootstrap preamble after the Context line (#439/#448)', () => { - const block = systemPromptBlock({ bootstrap: true, context: ['/work/api'] }) - assert.ok(block.startsWith('Context: /work/api')) // context frames everything - assert.ok(block.indexOf('Context: /work/api') < block.indexOf(BOOTSTRAP_PREAMBLE)) +test('BOOTSTRAP_ARCHITECT_NOTE reinforces plan-before-code without an await verb (#297/#457)', () => { + // The build path's architect turn answers with JSON that the plan-approval gate awaits, + // so the note must not itself demand showMarkdown/await (which would fight the JSON shape). + assert.ok(BOOTSTRAP_ARCHITECT_NOTE.includes('brand-new project')) + assert.ok(!BOOTSTRAP_ARCHITECT_NOTE.includes('showMarkdown')) +}) + +test('systemPromptBlock no longer carries a bootstrap preamble (#297/#457)', () => { + // Bootstrap moved from the system channel to the wrapped user prompt. + const block = systemPromptBlock({ context: ['/work/api'] }) + assert.ok(block.startsWith('Context: /work/api')) + assert.ok(!block.includes('brand-new project')) }) test('eco.autoPlanning drops only the Large scope section (#314)', () => { diff --git a/packages/framework/src/system-prompt.ts b/packages/framework/src/system-prompt.ts index 8ed6d21..5db49b4 100644 --- a/packages/framework/src/system-prompt.ts +++ b/packages/framework/src/system-prompt.ts @@ -60,20 +60,40 @@ Before starting to write code, measure "variability": \${{tf.prompt}}` /** - * Bootstrap mode's forceful preamble (#297/#448). The built-in #326 prompt already - * carries the "Unclear scope" / "Large scope" rules, but appended to Claude Code's own - * system prompt those lose to its default "be decisive, don't block the user" instinct — - * so a fresh-from-empty-dir build charges ahead instead of stopping for a plan (measured). - * This preamble states the override explicitly and forbids writing code before approval, - * which flips the behaviour without touching Rom's template. Prepended above the #326 - * prompt only when bootstrap mode is on. + * Bootstrap mode (#297/#457): reword the user's prompt into instructions rather than + * injecting a system-prompt preamble. Rom's steer (#297): treat Claude Code as a black + * box and wrap the user prompt — instructions carried in the prompt itself outweigh the + * "contextual" system-prompt text the agent is tempted to skip. The original request rides + * at the end unchanged; the framing tells the agent to analyze it and stop for approval + * before writing any code (a brand-new project from an empty directory is exactly where + * charging ahead is most costly). Used on the direct-prompt path (#331); the build path's + * architect turn gets the lighter {@link BOOTSTRAP_ARCHITECT_NOTE} instead. */ -export const BOOTSTRAP_PREAMBLE = `# Bootstrap mode - -You are starting a brand-new project from an empty directory. This takes precedence over any default tendency to act decisively or to start building right away: +export function wrapBootstrapPrompt(userPrompt: string): string { + return [ + 'This is a brand-new project, starting from an empty directory.', + '', + 'Before writing, scaffolding, or editing any file, or running any build or install command, treat my request below as something to analyze first, not a green light to start building:', + '', + '- Work out what I actually want: the plausible interpretations (sorted most-likely first), the key decisions (stack, scope, unknowns), and any open questions.', + '- Show me that analysis and the plan you propose with showMarkdown(), then stop and await my approval.', + '- Do not write any code until I have approved a plan.', + '', + 'My request:', + '', + userPrompt.trim(), + ].join('\n') +} -- Do NOT write, scaffold, or edit any file, and do NOT run build or install commands, until the user has approved a plan. -- Your first reply MUST be either a list of interpretations sorted by plausibility (when the scope is unclear) or a plan the user can approve (when the scope is large), then stop and await the user's answer.` +/** + * Bootstrap reinforcement for the build path's architect turn (#297/#457). The architect + * already answers with a plan JSON that the plan-approval gate (#304) shows and awaits, so + * here bootstrap only needs to insist the plan is right before any code is written; the full + * analyze / showMarkdown / await wording lives in {@link wrapBootstrapPrompt} for the + * direct-prompt path. Prepended to the architect prompt only when bootstrap mode is on. + */ +export const BOOTSTRAP_ARCHITECT_NOTE = + 'This is a brand-new project, starting from an empty directory. Get the plan right before any code is written: it is shown to the user for approval first, so do not scaffold or build anything yet.' /** * Eco fine-grained control (#314): each flag drops one whole `##` section from the @@ -202,12 +222,6 @@ export interface SystemPromptOptions { * the block. Empty/absent adds nothing. */ context?: readonly string[] | undefined - /** - * Bootstrap mode (#297/#448): starting a brand-new project from an empty directory. - * Prepends the forceful {@link BOOTSTRAP_PREAMBLE} above the built-in prompt so the - * first turn stops for a plan instead of charging ahead. Default off. - */ - bootstrap?: boolean | undefined } /** @@ -224,9 +238,6 @@ export function systemPromptBlock(opts: SystemPromptOptions = {}): string { // alone under `--vanilla`, where there is no built-in prompt to frame). const context = opts.context?.map(d => d.trim()).filter(Boolean) if (context && context.length) parts.push(`Context: ${context.join(', ')}`) - // Bootstrap's override sits above the #326 prompt so it frames (and outranks) its - // "Unclear scope" / "Large scope" rules. - if (opts.bootstrap) parts.push(BOOTSTRAP_PREAMBLE) if (opts.antiLazyPill !== false) parts.push(renderSystemPrompt(opts.tf).system) const user = opts.user?.trim() if (user) parts.push(user)