diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 0679abacb..39800291e 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -127,7 +127,12 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check 1. `gstack-ship-budget handoff --run --from 12 --to 22 --base origin/ --reading ` writes a brief (mode 0600, under the run directory) with the pins (HEAD, merge base, working tree, reviewed tree), the record with its six headings and start tokens, the decisions in scope - with the owner's words, the decision trail, the budget and the return contract. It reserves + with the owner's words, the decision trail, the budget and the return contract. Its `READ:` + lines are the child's whole reading list as line ranges: ship/SKILL.md's contract, the + segment's own steps, the self-check and Important Rules (never the preamble or section + index), and each segment section, review-army.md without Step 9.1's selection rules (the + review plan's `DISPATCH:` lines replace them). The return contract names `--to` as the + segment's last step, so a 12-22 child returns `--to 22`. It reserves the child's sub-cap: min(cap − used − reserved − unknown − the calibrated tail reserve and stop overhead, the segment estimate), or `--sub-cap`. A child handing off reserves its whole remaining sub-cap instead (cap − used − reserved − unknown − its stop overhead). diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index 4b07da8ae..783edf97f 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -62,6 +62,36 @@ export function segmentSections(from: string, to: string): string[] { return [...new Set(SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1).flatMap(s => STEP_SECTIONS[s] ?? []))]; } +const SKILL_ROOT = path.resolve(import.meta.dir, '..'); + +/** The exact line ranges a child of Steps from..to reads (GSTA-167): ship/SKILL.md's contract (never its preamble or section index), its own steps, the self-check and Important Rules; each segment section whole, except review-army.md's Step 9.1 selection rules, which the review plan's DISPATCH lines replace. */ +export function readRanges(from: string, to: string, root = SKILL_ROOT): string[] { + const read = (rel: string) => { try { const l = fs.readFileSync(path.join(root, rel), 'utf8').split('\n'); return l.at(-1) === '' ? l.slice(0, -1) : l; } catch { return null; } }; + const steps = new Set(SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1)); + const line = (rs: [number, number][]) => rs.sort((a, b) => a[0] - b[0]).reduce<[number, number][]>((m, r) => { const last = m.at(-1); if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1]); else m.push([...r]); return m; }, []).map(([a, b]) => `${a}-${b}`).join(','); + const out: string[] = []; + const skill = read('ship/SKILL.md'); + if (skill) { + const bounds = skill.flatMap((l, i) => /^(# |## (Step |Section |Important Rules))/.test(l) ? [i] : []); + const end = (i: number) => bounds.find(k => k > i) ?? skill.length; + const rs: [number, number][] = []; + skill.forEach((l, i) => { + const step = /^## Step ([\d.]+):/.exec(l)?.[1]; + if (l.startsWith('# Ship:') || (step && steps.has(step)) || l.startsWith('## Section self-check') || l.startsWith('## Important Rules')) rs.push([i + 1, end(i)]); + }); + out.push(`READ: ship/SKILL.md:${line(rs)}`); + } + for (const s of [...segmentSections(from, to), 'budget-handoff']) { + const rel = `ship/sections/${s}.md`; + const lines = read(rel); + if (!lines) continue; + const skipFrom = s === 'review-army' ? lines.findIndex(l => l.startsWith('### Detect stack and scope')) : -1; + const skipTo = skipFrom >= 0 ? lines.findIndex(l => l.startsWith('### Dispatch specialists by packet')) : -1; + out.push(`READ: ${rel}:${line(skipTo > skipFrom ? [[1, skipFrom], [skipTo + 1, lines.length]] : [[1, lines.length]])}`); + } + return out; +} + function brief(l: Ledger, h: Omit, pins: Record, recordText: string, o: Record): string { const reviews = recordSection(recordText, 'Reviews') ?? []; const tokens = [...new Set(reviews.join('\n').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? [])]; @@ -77,12 +107,12 @@ function brief(l: Ledger, h: Omit, pins: Record `3. Run /ship --continue for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. The context rule never stops you: \`verdict=delegate\` hands your remaining steps to a fresh child the same way (ship/sections/budget-handoff.md), and a \`CONTEXT_HIGH:\` note means continue. This run is at chain depth ${h.depth} of ${MAX_CHAIN_DEPTH}.`, block.launch_file ? `4. Source the launch file \`${block.launch_file}\` in every shell that runs a gstack command (its path only; never print its values).` : '4. No GSTACK_LAUNCH_FILE was named.', '', '## Read', '', - `Only ship/SKILL.md's step list and, under ship/sections/, ${[...segmentSections(h.from, h.to), 'budget-handoff'].map(s => s + '.md').join(', ')}; a conditional section only when its manifest.json trigger fires. Never read ship/sections/preamble.md in full or docs/ship-budget.md: budget-handoff.md is the whole budget procedure.`, '', + `Only ship/SKILL.md's step list and, under ship/sections/, ${[...segmentSections(h.from, h.to), 'budget-handoff'].map(s => s + '.md').join(', ')}; a conditional section only when its manifest.json trigger fires. Never read ship/sections/preamble.md in full or docs/ship-budget.md: budget-handoff.md is the whole budget procedure. Read only the \`READ:\` ranges below (\`path:first-last\`, from the gstack install root), in one parallel call; Step 9.1's selection rules are left out because the review plan's \`DISPATCH:\` lines replace them.`, '', ...readRanges(h.from, h.to), '', '## Record', '', `You are the only writer of \`${l.record}\` during this segment. It keeps its six headings (${RECORD_HEADINGS.join(', ')}). Original start tokens: ${tokens.join(', ') || 'none'}.`, '', '## Decisions and approvals in scope (verbatim from the record)', '', ...(recordSection(recordText, 'Decisions') ?? ['(none recorded)']), '', '## Budget', '', `Sub-cap ${h.sub_cap} ${block.unit} (meter=${block.meter}); the parent keeps a ${l.tail} tail reserve. Ledger: ${l.file}.`, '', '## Return contract', '', - `Finish with \`gstack-ship-budget return --run --to --changed \`. It writes ${h.return_file} (at most ${RETURN_MAX_LINES} lines: the steps done, new HEAD and tree, the record sections changed, the BUDGET line and GSTACK_RESULT) and prints the same lines. The parent reads the record, never your transcript.`, '', + `Your last step is Step ${h.to}. After it, finish with \`gstack-ship-budget return --run --to ${h.to} --changed \` (a child that stops earlier returns \`--status incomplete --to \`). It writes ${h.return_file} (at most ${RETURN_MAX_LINES} lines: the steps done, new HEAD and tree, the record sections changed, the BUDGET line and GSTACK_RESULT) and prints the same lines. The parent reads the record, never your transcript.`, '', ].join('\n'); } diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md index 457908a28..cee86c793 100644 --- a/ship/sections/budget-handoff.md +++ b/ship/sections/budget-handoff.md @@ -51,7 +51,8 @@ corrupt ledger takes `--rebuild-from-record`. **verdict=delegate.** The next segment (Steps 7–11.5, 12–22, or 1–22 at start) runs in a fresh child; you wait and advance only after reconciling it: 1. `gstack-ship-budget handoff --run --from 7 --to 11.5 --base origin/ --reading ` - (add `--reviewed-tree `, `--trail --trail-run `). It prints `HANDOFF:` and `RETURN_FILE:`. + (add `--reviewed-tree `, `--trail --trail-run `). It prints `HANDOFF:` and `RETURN_FILE:`; + the brief lists the child's `READ:` line ranges and names `--to` as its last step. 2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code `gstack-ship-budget launch --in ` (background, explicit permissions). @@ -60,7 +61,7 @@ fresh child; you wait and advance only after reconciling it: never stops on the context rule: after a completed step, `verdict=delegate` hands its remaining steps to a grandchild through these four steps (depth 3 at most); a `CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with - `gstack-ship-budget return --run --to --changed ` (at most 40 lines). + `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop; every check refuses until a regenerated child reconciles diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index 2dfe09e00..7a96e00b1 100644 --- a/ship/sections/budget-handoff.md.tmpl +++ b/ship/sections/budget-handoff.md.tmpl @@ -49,7 +49,8 @@ corrupt ledger takes `--rebuild-from-record`. **verdict=delegate.** The next segment (Steps 7–11.5, 12–22, or 1–22 at start) runs in a fresh child; you wait and advance only after reconciling it: 1. `gstack-ship-budget handoff --run --from 7 --to 11.5 --base origin/ --reading ` - (add `--reviewed-tree `, `--trail --trail-run `). It prints `HANDOFF:` and `RETURN_FILE:`. + (add `--reviewed-tree `, `--trail --trail-run `). It prints `HANDOFF:` and `RETURN_FILE:`; + the brief lists the child's `READ:` line ranges and names `--to` as its last step. 2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code `gstack-ship-budget launch --in ` (background, explicit permissions). @@ -58,7 +59,7 @@ fresh child; you wait and advance only after reconciling it: never stops on the context rule: after a completed step, `verdict=delegate` hands its remaining steps to a grandchild through these four steps (depth 3 at most); a `CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with - `gstack-ship-budget return --run --to --changed ` (at most 40 lines). + `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop; every check refuses until a regenerated child reconciles diff --git a/test/ship-handoff-brief.test.ts b/test/ship-handoff-brief.test.ts index 33be61e5e..651119cf6 100644 --- a/test/ship-handoff-brief.test.ts +++ b/test/ship-handoff-brief.test.ts @@ -10,6 +10,7 @@ import * as fs from 'node:fs'; import { segmentSections } from '../lib/ship-handoff'; import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; +const ROOT = `${import.meta.dir}/..`; const harnesses: Harness[] = []; const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); @@ -46,6 +47,54 @@ describe('the handoff brief names only the segment\'s sections', () => { expect(segmentSections('1', '22')).toEqual(['tests', 'greptile-early', 'test-coverage', 'plan-completion', 'review-army', 'adversarial', 'changelog', 'documentation', 'pr-body', 'capy-pr']); }); + // Value: protects=a handoff child reads exact line ranges: never ship/SKILL.md's preamble, the section index or another segment's steps, and never Step 9.1's selection rules the review plan replaces; fails_when=the brief names files without ranges, a range starts at line 1 of SKILL.md, or a gate heading falls outside the ranges; + // why_new=the tests above check file names only; seam=none; no_claim=that a child obeys the ranges + test('the brief prints READ: ranges per file that skip the preamble and Step 9.1\'s selection rules but keep every gate heading', () => { + const ranges = (text: string) => Object.fromEntries([...text.matchAll(/^READ: (\S+?):([\d,-]+)$/gm)].map(m => [m[1]!, m[2]!.split(',').map(r => r.split('-').map(Number) as [number, number])])); + const lineOf = (file: string, heading: string) => fs.readFileSync(`${ROOT}/${file}`, 'utf8').split('\n').findIndex(l => l.startsWith(heading)) + 1; + const inside = (rs: [number, number][], n: number) => n > 0 && rs.some(([a, b]) => a <= n && n <= b); + const total = (file: string) => fs.readFileSync(`${ROOT}/${file}`, 'utf8').replace(/\n$/, '').split('\n').length; + const seg = briefFor('7', '11.5'); + expect(seg).toMatch(/Read only the `READ:` ranges below/); + const r = ranges(seg); + const skill = r['ship/SKILL.md']!; + expect(skill[0]![0]).toBe(lineOf('ship/SKILL.md', '# Ship:')); + for (const h of ['## Step 11.5:', '## Important Rules', '### Ship control flow']) expect(inside(skill, lineOf('ship/SKILL.md', h)), h).toBe(true); + for (const h of ['## Preamble (run first)', '## Section index', '## Step 3:', '## Step 12:']) expect(inside(skill, lineOf('ship/SKILL.md', h)), h).toBe(false); + const army = r['ship/sections/review-army.md']!; + const A = 'ship/sections/review-army.md'; + for (const h of ['## Step 9: Pre-Landing Review', '### Core checklist', '## Design Review', '### Dispatch specialists by packet', '### Red Team dispatch', '## Step 9.4:', '### Decide whether to repeat Step 9']) expect(inside(army, lineOf(A, h)), h).toBe(true); + for (const h of ['### Detect stack and scope', '### Read specialist hit rates', '### Select specialists', '### Adaptive gating']) expect(inside(army, lineOf(A, h)), h).toBe(false); + expect(army.at(-1)![1]).toBe(total(A)); + for (const s of ['test-coverage', 'plan-completion', 'adversarial', 'budget-handoff']) expect(r[`ship/sections/${s}.md`]).toEqual([[1, total(`ship/sections/${s}.md`)]]); + expect(Object.keys(r).filter(k => k.includes('audit-') || k.includes('changelog'))).toEqual([]); + const late = ranges(briefFor('12', '22'))['ship/SKILL.md']!; + for (const h of ['## Step 12:', '## Step 14.5:', '## Step 17:', '## Step 22:', '## Important Rules']) expect(inside(late, lineOf('ship/SKILL.md', h)), h).toBe(true); + for (const h of ['## Step 3:', '## Step 11.5:']) expect(inside(late, lineOf('ship/SKILL.md', h)), h).toBe(false); + }); + + // Value: protects=a segment ending at Step 22 is returned at Step 22, so the parent's reconcile reads it complete; fails_when=the brief leaves the return step to the child's reading of "Steps 1-21" (GSTA-167: --to 21 on a 12-22 handoff reconciled child=incomplete and stopped the parent); + // why_new=no test follows a brief through return and reconcile at Step 22; seam=none; no_claim=what the child did between verify and return + test('a Steps 12-22 brief names Step 22 as the last step and its return command; following it reconciles complete', () => { + const h = fresh(); + const s = h.sb(['start', '--record', h.record(), '--token-cap', '400M', '--reading', '98000']); + const run = h.field(s.out, 'BUDGET_RUN', 'id'); + const ho = h.sb(['handoff', '--run', run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '1000000']); + expect(ho.code, ho.out + ho.err).toBe(0); + const file = ho.out.match(/^HANDOFF: (\S+) /m)![1]!; + const text = fs.readFileSync(file, 'utf8'); + expect(text).toContain('Your last step is Step 22'); + expect(text).toContain('gstack-ship-budget return --run --to 22'); + const v = h.sb(['verify-handoff', '--in', file, '--reading', '1100000']); + expect(v.code, v.out + v.err).toBe(0); + const child = h.field(v.out, 'BUDGET_RUN', 'id'); + const ret = h.sb(['return', '--run', child, '--to', '22', '--reading', '2000000']); + expect(ret.code, ret.out + ret.err).toBe(0); + const rec = h.sb(['reconcile', '--run', run, '--return', ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!, '--reading', '2100000']); + expect(rec.out).toContain('child=complete'); + expect(rec.code, rec.out).toBe(0); + }); + test('every section the brief can name is a registered ship section', () => { const manifest = JSON.parse(fs.readFileSync(`${import.meta.dir}/../ship/sections/manifest.json`, 'utf8')); const ids = new Set(manifest.sections.map((s: any) => s.id));