Skip to content
Draft
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-wrap-prompt.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ 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`: bootstrap mode (#297/#457) — a new project from an empty dir; stop for a plan first. */
bootstrap: boolean
buildEvent?: string | undefined
maxPasses?: number
Expand Down
3 changes: 2 additions & 1 deletion packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ export {
renderSystemPrompt,
SYSTEM_PROMPT_TEMPLATE,
SYSTEM_PROMPT_FILE,
BOOTSTRAP_PREAMBLE,
wrapBootstrapPrompt,
BOOTSTRAP_ARCHITECT_NOTE,
type SystemPromptOptions,
type TfContext,
type RenderedSystemPrompt,
Expand Down
19 changes: 19 additions & 0 deletions packages/framework/src/prompt-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FrameworkEvent, { kind: 'driver' }> => 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' }] })
Expand Down
12 changes: 7 additions & 5 deletions packages/framework/src/prompt-run.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -84,12 +84,14 @@ 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, bootstrap: opts.bootstrap })
const promptBlock = systemPromptBlock({ antiLazyPill: opts.antiLazyPill, user: opts.systemPrompt, tf, context: opts.context })
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
// the built-in prompt off, the raw prompt is sent as-is.
const firstPrompt = opts.antiLazyPill === false ? opts.prompt : renderSystemPrompt(tf).user
// the built-in prompt off, the raw prompt is sent as-is. Bootstrap mode (#297/#457)
// rewords it into instructions so the first turn analyzes and awaits before building.
const basePrompt = opts.antiLazyPill === false ? opts.prompt : renderSystemPrompt(tf).user
const firstPrompt = opts.bootstrap ? wrapBootstrapPrompt(basePrompt) : basePrompt

const linkTemplate = opts.sessionLink
const literalLink = linkTemplate && !hasSessionIdPlaceholder(linkTemplate) ? linkTemplate : undefined
Expand Down
19 changes: 19 additions & 0 deletions packages/framework/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ test('runFramework drives the whole flow through the driver, offline, to product
assert.equal(events.at(-1)!.kind, 'end')
})

test('runFramework reinforces only the architect turn in bootstrap mode (#297/#457)', async () => {
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({
Expand Down
25 changes: 16 additions & 9 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
/**
Expand Down Expand Up @@ -350,7 +351,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, bootstrap: opts.bootstrap })
const promptBlock = systemPromptBlock({ antiLazyPill: opts.antiLazyPill, user: opts.systemPrompt, tf, context: opts.context })
// 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 Expand Up @@ -544,10 +545,16 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
// unclear-scope flow): resolve it through the same gates instead of
// letting the stub-plan fallback swallow the question. Only when someone
// can answer, so a headless run stays byte-identical.
architect: planApprovalGate(driverArchitect(session, architectAwait), session, {
...gateDeps,
...architectAwait,
}),
architect: planApprovalGate(
driverArchitect(session, {
...architectAwait,
// Bootstrap mode (#297/#457): reword the architect's first turn to insist the
// plan is settled before any code — the plan-approval gate below then awaits it.
...(opts.bootstrap ? { prompt: (intent: string) => `${BOOTSTRAP_ARCHITECT_NOTE}\n\n${architectPrompt(intent)}` } : {}),
}),
session,
{ ...gateDeps, ...architectAwait },
),
build: agentAwaitGate(driverBuild(session, workspaceOpt), session, gateDeps),
checklist,
improve: driverImprove(session, workspaceOpt),
Expand Down
32 changes: 20 additions & 12 deletions packages/framework/src/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)', () => {
Expand Down
53 changes: 32 additions & 21 deletions packages/framework/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

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