diff --git a/.changeset/bootstrap-system-prompt.md b/.changeset/bootstrap-system-prompt.md new file mode 100644 index 0000000..6ce341c --- /dev/null +++ b/.changeset/bootstrap-system-prompt.md @@ -0,0 +1,5 @@ +--- +'@gemstack/framework': minor +--- + +Add bootstrap mode (`--bootstrap`, and `StartRunOptions.bootstrap` for the dashboard): starting a brand-new project from an empty directory. It prepends a forceful preamble above the built-in #326 prompt so the first turn stops for a plan (ranked interpretations or a PLAN file) instead of charging ahead and scaffolding code. The #326 template is left byte-identical; the preamble only raises its "Unclear scope" / "Large scope" rules above Claude Code's default decisiveness, which otherwise overrides them on the append path (#297, #448). diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index d373747..3e92a82 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -56,6 +56,11 @@ test('parseArgs collects repeatable --context directories (#439)', () => { assert.deepEqual(parseArgs(['--context', '/work/api', '--context', '/work/ui', 'x']).context, ['/work/api', '/work/ui']) }) +test('parseArgs reads --bootstrap (#297/#448)', () => { + assert.equal(parseArgs(['x']).bootstrap, false) + assert.equal(parseArgs(['--bootstrap', 'x']).bootstrap, true) +}) + test('parseArgs reads the maintain subcommand + its bounds (#298)', () => { const dflt = parseArgs(['x']) assert.equal(dflt.maintain, false) diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index e251110..4dc4a84 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -151,6 +151,9 @@ Options: --context Focus the agent on this directory (repeatable). Adds one "Context: " line to the system prompt; the agent can still reach every repo, this just narrows where it looks. + --bootstrap Bootstrap mode: a brand-new project from an empty dir. Makes + the first turn stop for a plan (interpretations / PLAN) before + writing any code, instead of charging ahead. --kind Build event kind the preset's review loop fires for, e.g. bug-fix or major-change (default: the-framework.yml's event, else the preset's own, else major-change). Selects which @@ -224,6 +227,8 @@ 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: boolean buildEvent?: string | undefined maxPasses?: number maxCost?: number @@ -282,6 +287,7 @@ export function parseArgs(argv: string[]): CliOptions { vanilla: false, eco: { autoPlanning: false, autoResearch: false, autoMaintenance: false }, context: [], + bootstrap: false, dashboard: true, relayServe: false, composeExtensions: false, @@ -333,6 +339,9 @@ export function parseArgs(argv: string[]): CliOptions { case '--vanilla': opts.vanilla = true break + case '--bootstrap': + opts.bootstrap = true + break case '--eco-auto-planning': opts.eco.autoPlanning = true break @@ -978,6 +987,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise eco[k as keyof EcoOptions]).join(', ')}`) if (opts.context.length) io.out(`◆ context: ${opts.context.join(', ')}`) + if (opts.bootstrap) io.out('◆ bootstrap: on (plan before building)') try { await runPrompt({ prompt: opts.directPrompt ? intent : renderResearchPrompt(intent), @@ -997,6 +1007,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { const link = chooseSessionLink(opts, fake) @@ -1098,6 +1109,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise eco[k as keyof EcoOptions]).join(', ')}`) if (opts.context.length) io.out(`◆ context: ${opts.context.join(', ')}`) + if (opts.bootstrap) io.out('◆ bootstrap: on (plan before building)') const runOpts: RunFrameworkOptions = { intent, @@ -1137,6 +1149,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { const link = chooseSessionLink(opts, fake) return link ? { sessionLink: link } : {} diff --git a/packages/framework/src/daemon.test.ts b/packages/framework/src/daemon.test.ts index 4da285b..18fd0de 100644 --- a/packages/framework/src/daemon.test.ts +++ b/packages/framework/src/daemon.test.ts @@ -55,6 +55,8 @@ test('startOptionFlags maps only enabled Global options to CLI flags (#314)', () '--context', '/work/ui', ]) + // Bootstrap mode (#297/#448): maps to --bootstrap. + assert.deepEqual(startOptionFlags({ bootstrap: true }), ['--bootstrap']) }) const logEvent = (message: string): FrameworkEvent => ({ kind: 'log', message }) diff --git a/packages/framework/src/daemon.ts b/packages/framework/src/daemon.ts index 7b24811..a4f221a 100644 --- a/packages/framework/src/daemon.ts +++ b/packages/framework/src/daemon.ts @@ -67,6 +67,7 @@ export function startOptionFlags(options: StartRunOptions): string[] { if (options.eco?.autoResearch) flags.push('--eco-auto-research') if (options.eco?.autoMaintenance) flags.push('--eco-auto-maintenance') for (const dir of options.context ?? []) if (typeof dir === 'string' && dir.trim()) flags.push('--context', dir) + if (options.bootstrap) flags.push('--bootstrap') return flags } diff --git a/packages/framework/src/dashboard/server.ts b/packages/framework/src/dashboard/server.ts index c1edddf..c7ea5d9 100644 --- a/packages/framework/src/dashboard/server.ts +++ b/packages/framework/src/dashboard/server.ts @@ -75,6 +75,8 @@ export interface StartRunOptions { eco?: EcoOptions /** In-context directories (#439): each becomes a `--context ` flag on the spawned run. */ context?: string[] + /** Bootstrap mode (#297/#448): a new project from an empty dir; maps to `--bootstrap`. */ + bootstrap?: boolean } /** diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 913721b..1c1624f 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -224,6 +224,7 @@ export { renderSystemPrompt, SYSTEM_PROMPT_TEMPLATE, SYSTEM_PROMPT_FILE, + BOOTSTRAP_PREAMBLE, type SystemPromptOptions, type TfContext, type RenderedSystemPrompt, diff --git a/packages/framework/src/prompt-run.ts b/packages/framework/src/prompt-run.ts index 330fca9..82cb1fb 100644 --- a/packages/framework/src/prompt-run.ts +++ b/packages/framework/src/prompt-run.ts @@ -44,6 +44,8 @@ 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?: boolean /** Stop the run once the agent has spent this much, in USD (#322). */ budgetUsd?: number /** Session link template for the dashboard, `{sessionId}` resolved when known. */ @@ -82,7 +84,7 @@ export async function runPrompt(opts: RunPromptOptions): Promise { 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('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('eco.autoPlanning drops only the Large scope section (#314)', () => { const { system } = renderSystemPrompt({ prompt: 'x', params: { eco: { autoPlanning: true } } }) assert.ok(!system.includes('## Large scope')) diff --git a/packages/framework/src/system-prompt.ts b/packages/framework/src/system-prompt.ts index ff0b655..8ed6d21 100644 --- a/packages/framework/src/system-prompt.ts +++ b/packages/framework/src/system-prompt.ts @@ -59,6 +59,22 @@ 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. + */ +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: + +- 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.` + /** * Eco fine-grained control (#314): each flag drops one whole `##` section from the * built-in #326 prompt to save tokens, letting the agent auto-handle that concern. @@ -186,6 +202,12 @@ 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 } /** @@ -202,6 +224,9 @@ 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)