mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
1 parent
4ec30fe145
commit
e0ea54768c
5 files changed
+93
-7
No files matched your search
+6
-1
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user