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
5 changes: 5 additions & 0 deletions .changeset/bootstrap-system-prompt.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ Options:
--context <dir> Focus the agent on this directory (repeatable). Adds one
"Context: <dirs>" 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 <name> 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
Expand Down Expand Up @@ -224,6 +227,8 @@ export interface CliOptions {
eco: Required<EcoOptions>
/** `--context <dir>` (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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -978,6 +987,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
if (noBuiltinPrompt) io.out(`◆ built-in system prompt: off (${opts.vanilla ? 'vanilla' : 'the-framework.yml'})`)
else if (eco) io.out(`◆ eco: dropping ${Object.keys(eco).filter(k => 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),
Expand All @@ -997,6 +1007,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(noBuiltinPrompt ? { antiLazyPill: false } : {}),
...(eco ? { eco } : {}),
...(opts.context.length ? { context: opts.context } : {}),
...(opts.bootstrap ? { bootstrap: true } : {}),
...(modeList.includes('autopilot') ? { autopilot: true } : {}),
...((): { sessionLink?: string } => {
const link = chooseSessionLink(opts, fake)
Expand Down Expand Up @@ -1098,6 +1109,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
if (noBuiltinPrompt) io.out(`◆ built-in system prompt: off (${opts.vanilla ? 'vanilla' : 'the-framework.yml'})`)
else if (eco) io.out(`◆ eco: dropping ${Object.keys(eco).filter(k => 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,
Expand Down Expand Up @@ -1137,6 +1149,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(noBuiltinPrompt ? { antiLazyPill: false } : {}),
...(eco ? { eco } : {}),
...(opts.context.length ? { context: opts.context } : {}),
...(opts.bootstrap ? { bootstrap: true } : {}),
...((): { sessionLink?: string } => {
const link = chooseSessionLink(opts, fake)
return link ? { sessionLink: link } : {}
Expand Down
2 changes: 2 additions & 0 deletions packages/framework/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions packages/framework/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions packages/framework/src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export interface StartRunOptions {
eco?: EcoOptions
/** In-context directories (#439): each becomes a `--context <dir>` flag on the spawned run. */
context?: string[]
/** Bootstrap mode (#297/#448): a new project from an empty dir; maps to `--bootstrap`. */
bootstrap?: boolean
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export {
renderSystemPrompt,
SYSTEM_PROMPT_TEMPLATE,
SYSTEM_PROMPT_FILE,
BOOTSTRAP_PREAMBLE,
type SystemPromptOptions,
type TfContext,
type RenderedSystemPrompt,
Expand Down
4 changes: 3 additions & 1 deletion packages/framework/src/prompt-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -82,7 +84,7 @@ export async function runPrompt(opts: RunPromptOptions): Promise<RunPromptResult
prompt: opts.prompt,
params: { autopilot: opts.autopilot === true, ...(opts.eco ? { eco: opts.eco } : {}) },
}
const promptBlock = systemPromptBlock({ antiLazyPill: opts.antiLazyPill, user: opts.systemPrompt, tf, context: opts.context })
const promptBlock = systemPromptBlock({ antiLazyPill: opts.antiLazyPill, user: opts.systemPrompt, tf, context: opts.context, bootstrap: opts.bootstrap })
const system = [...(promptBlock ? [promptBlock] : []), AWAIT_PROTOCOL].join('\n\n')
// The template's `# User prompt` half carries the prompt (today it renders to
// exactly `opts.prompt`; any framing Rom adds around the slot rides along). With
Expand Down
7 changes: 6 additions & 1 deletion packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export interface RunFrameworkOptions {
eco?: EcoOptions
/** 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?: boolean
/**
* A user-picked Open Loop domain preset ({loops, prompts, skills}) to run the
* build under (#251). Its skills (and their personas) frame every phase, and
Expand Down Expand Up @@ -345,7 +350,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
prompt: opts.intent,
params: { autopilot: opts.modes?.includes('autopilot') ?? false, ...(opts.eco ? { eco: opts.eco } : {}) },
}
const promptBlock = systemPromptBlock({ antiLazyPill: opts.antiLazyPill, user: opts.systemPrompt, tf, context: opts.context })
const promptBlock = systemPromptBlock({ antiLazyPill: opts.antiLazyPill, user: opts.systemPrompt, tf, context: opts.context, bootstrap: opts.bootstrap })
// The await protocol (#337) concretizes the pill's showChoices()/AWAIT macros into a
// signal the turn-boundary gate can detect, so it rides along with the pill.
const system = [
Expand Down
16 changes: 16 additions & 0 deletions packages/framework/src/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
BOOTSTRAP_PREAMBLE,
loadUserSystemPrompt,
renderSystemPrompt,
systemPromptBlock,
Expand Down Expand Up @@ -98,6 +99,21 @@ 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('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'))
Expand Down
25 changes: 25 additions & 0 deletions packages/framework/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -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)
Expand Down
Loading