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)