diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index a8d34e55f..6bbb4e0c5 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections; Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and the operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship started. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship started. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/changelog.d/gsta-167-calls-2.md b/changelog.d/gsta-167-calls-2.md index 0448f0114..fc55804a1 100644 --- a/changelog.d/gsta-167-calls-2.md +++ b/changelog.d/gsta-167-calls-2.md @@ -4,3 +4,6 @@ - **`gstack-review-dispatch pass`** plans a pass and writes a staged manifest: audits marked first, then specialists with QA and the other audits, then the Red Team, then Step 11's voices. Each brief is rendered once its inputs exist, and the Red Team's findings file is the specialists' collected lines byte for byte. - **Briefs travel in chunks** (`gstack-review-dispatch chunk`) of at most 65,536 characters, so a brief over 1 MB, or one whose diff has invalid UTF-8, reaches the reader whole and is checked by `sent` before the reader starts. - **The Capy pass program** (`lib/capy-review-pass.js`, served by `gstack-review-dispatch program`) runs plan, review-log start, dispatch, poll, collect, the Red Team and the budget note in one call, returning within 200 s; the gstack Capy skill carries the loader. Each reader's intent is saved before its subagent is created, so a rerun after a lost create result finds the reader by title instead of starting it twice, and an unclear outcome stops the pass. A failed reader stops every rerun instead of leaving it polling, and a review-log start that failed is retried before any reader goes out. + +### Fixed +- **The pass program takes its input as the Capy skill writes it.** `extras` may be JSON in the input, not only a file path, and `cwd` names the checkout its commands run in. A reader that reports done a moment before its summary arrives is collected on a later poll instead of stopping the pass. Replay C2-34 (GSTA-193) lost 7 of its 15 calls to these three issues. diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 89ba9a833..aba3ee5de 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -9,11 +9,13 @@ const T0 = input.startedAt ?? (await now()); const deadline = T0 + (input.budgetMs ?? 200000); const tick = input.tickMs ?? 20000; const stuckMs = input.stuckMs ?? 20 * 60000; +const emptyGraceMs = 60000; +const emptyDone = {}; const RETURN_BYTES = 32000; const q = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; const lines = []; const sh = async (cmd) => { - const r = await tools.capy.bash({ command: (input.launch ? '. ' + q(input.launch) + ' && ' : '') + cmd, description: 'Ship review pass ' + input.pass, timeout: 300 }); + const r = await tools.capy.bash({ command: (input.launch ? '. ' + q(input.launch) + ' && ' : '') + (input.cwd ? 'cd ' + q(input.cwd) + ' && ' : '') + cmd, description: 'Ship review pass ' + input.pass, timeout: 300 }); return { code: r.exit_code, out: r.output || '' }; }; const bin = 'gstack-review-dispatch'; @@ -27,7 +29,11 @@ const stop = (why) => finish(['STOP: ' + why]); const passCmd = [bin, 'pass', '--base', q(input.base), '--out', q(input.out), '--pass', q(input.pass)]; if (input.ledger) passCmd.push('--ledger', q(input.ledger)); -if (input.extras) passCmd.push('--extras', q(input.extras)); +if (input.extras && typeof input.extras !== 'string') { + const file = input.out.replace(/\/+$/, '') + '/extras.json'; + await tools.capy.write({ path: file, content: JSON.stringify(Array.isArray(input.extras) ? input.extras : [input.extras]) }); + passCmd.push('--extras', q(file)); +} else if (input.extras) passCmd.push('--extras', q(input.extras)); if (input.learnings) passCmd.push('--learnings-file', q(input.learnings)); passCmd.push('--', ...(input.planFlags || []).map(q)); const pr = await sh(passCmd.join(' ')); @@ -113,7 +119,11 @@ try { const stuck = async () => r.state_at && (await now()) - Date.parse(r.state_at) > stuckMs; const g = t.status === 'done' || t.status === 'idle' ? await tools.subagent.get({ path: r.path, transcript: 'none' }) : null; if (!g || !g.summary) { - if (t.status === 'done') return stop(r.reader + ' (subagent ' + r.path + ') finished with no output'); + if (t.status === 'done') { + const seen = (emptyDone[r.reader] ??= await now()); + if ((await now()) - seen >= emptyGraceMs) return stop(r.reader + ' (subagent ' + r.path + ') finished with no output'); + continue; + } if (await stuck()) return stop(r.reader + ' (subagent ' + r.path + ') still ' + t.status + ' after ' + Math.round(stuckMs / 60000) + ' minutes with no output'); continue; } diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index a7afde00c..f36301c73 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -126,18 +126,20 @@ function repo(): { dir: string; env: NodeJS.ProcessEnv } { return { dir, env }; } -interface Sub { title: string; prompt: string; status: string; polls: number; model?: unknown; summary?: string } +interface Sub { title: string; prompt: string; status: string; polls: number; model?: unknown; summary?: string; late?: boolean } /** A fake Capy host: the shell is real (the repository's bins), subagents are scripted. */ -function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number } = {}) { +function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number; lateSummary?: boolean } = {}) { let clock = 0; const subs = new Map(); const creates: Sub[] = []; const starts: number[][] = []; const advance = () => { for (const s of subs.values()) { + if (s.late && !s.summary) { s.summary = o.output?.(s.title) ?? 'NO FINDINGS'; continue; } if (s.status !== 'working') continue; if (++s.polls < (o.pollsToFinish ?? 1)) continue; if (o.fail?.(s.title)) s.status = 'failed'; + else if (o.lateSummary && !s.late) { s.status = 'done'; s.late = true; } else { s.status = 'done'; s.summary = o.output?.(s.title) ?? 'NO FINDINGS'; } } }; @@ -371,6 +373,22 @@ describe('Capy pass program (fake host, real bins)', () => { expect(m.readers.some(r => r.effort === 'medium')).toBe(true); }, 180_000); + // Value: protects=the loader's input is filled as SKILL.md reads it: extras as JSON, the checkout named by cwd, and a reader whose summary lands a tick after its done status is collected, not stopped; fails_when=extras must be a file path (replay C2-34: BRIEF_RENDER_FAILED [object Object]), the bins run in the jam's directory instead of the checkout (C2-34: a wrapper launch file), or a done reader without its summary yet stops the pass (C2-34: "finished with no output", one extra call); + // why_new=replay C2-34 (GSTA-193) on PR 2; seam=none; no_claim=the host's status timing beyond one late summary + test('extras as JSON, the checkout as cwd, and a late summary all run through without a STOP', async () => { + const { dir, env } = repo(); + const elsewhere = tmp('rp-cwd-'); + const out = path.join(dir, '.state/run/p1'); + fs.mkdirSync(out, { recursive: true }); + fs.writeFileSync(path.join(out, 'qa.prompt.md'), 'QA brief'); + const h = host(elsewhere, env, { lateSummary: true }); + const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'], + extras: [{ name: 'qa', prompt_file: path.join(out, 'qa.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] }); + expect(res).not.toContain('STOP:'); + expect(res).toContain('NEXT: judge (Phase 4)'); + expect(h.creates.find(c => c.title === 'ship x13p1 audit/qa')!.prompt).toBe('QA brief'); + }, 180_000); + test('a plan with no reviewer pass dispatches nothing and passes NEXT through', async () => { const { dir, env } = repo(); const h = host(dir, env); @@ -394,7 +412,7 @@ describe('the Capy skill loader', () => { fs.writeFileSync(launch, ''); expect(spawnSync('bash', ['-c', 'gstack-ship-budget start --record "$1" --mode observe --reading 1000 --credits 1', '_', rec], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).status).toBe(0); const filled = code.split('').join(launch).split('').join('main').split('/p').join(path.join(dir, '.state/run/p1')) - .split('-p').join('x10p1').split('').join('jam_test').split('').join(rec).split('').join('anthropic/claude-opus-5-5'); + .split('-p').join('x10p1').split('').join('jam_test').split('').join(rec).split('').join('anthropic/claude-opus-5-5').split('').join(dir); expect(filled).not.toMatch(/<[a-z ]+>/); const h = host(dir, env); const res = await new AsyncFunction('tools', 'sleep', 'now', filled)(h.tools, h.sleep, h.now);