feat(ship-budget): the handoff brief prints READ: line ranges and names its last step (GSTA-167)

A handoff child read all of ship/SKILL.md (preamble included) and all of
review-army.md. The brief now prints one READ: line per file with exact ranges:
SKILL.md's contract, the segment's own steps, the self-check and Important Rules;
each segment section whole, review-army.md without Step 9.1's selection rules the
review plan replaces. Its return contract names --to as the segment's last step,
so a 12-22 child returns --to 22 and reconciles complete instead of returning 21
(child=incomplete).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-30 00:09:27 +00:00
1 parent 4ec30fe145
commit e0ea54768c
5 files changed
+93 -7

No files matched your search

+6 -1
View File
@@ -127,7 +127,12 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check
1. `gstack-ship-budget handoff --run <id> --from 12 --to 22 --base origin/<base> --reading <n>` 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).
+32 -2
View File
@@ -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<string>(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<Handoff, 'sha'>, pins: Record<string, unknown>, recordText: string, o: Record<string, string | undefined>): 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<Handoff, 'sha'>, pins: Record<string, unknown>
`3. Run /ship --continue <this file> 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 <your child run> --to <last step done> --changed <record sections you 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 <your child run> --to ${h.to} --changed <record sections you changed>\` (a child that stops earlier returns \`--status incomplete --to <last step done>\`). 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');
}
+3 -2
View File
@@ -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 <id> --from 7 --to 11.5 --base origin/<base> --reading <n>`
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). It prints `HANDOFF:` and `RETURN_FILE:`.
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). 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 <HANDOFF file>"; on Claude Code
`gstack-ship-budget launch --in <HANDOFF file>` (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 <child> --to <step> --changed <sections>` (at most 40 lines).
`gstack-ship-budget return --run <child> --to <the brief's last step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>`. 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
+3 -2
View File
@@ -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 <id> --from 7 --to 11.5 --base origin/<base> --reading <n>`
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). It prints `HANDOFF:` and `RETURN_FILE:`.
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). 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 <HANDOFF file>"; on Claude Code
`gstack-ship-budget launch --in <HANDOFF file>` (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 <child> --to <step> --changed <sections>` (at most 40 lines).
`gstack-ship-budget return --run <child> --to <the brief's last step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>`. 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
+49
View File
@@ -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 <your child 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));