v1.91.355.0 ship: poll readers in-program, 32 KB returns, gated fix program, unattended push defers the guard offer, calls meter (GSTA-167 calls PR 1) (#3662)

* ship: poll readers in-program, never wait on wakes; 32 KB returns; gated fix program; unattended push defers the guard offer; calls meter (GSTA-167 calls PR 1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (inventory lib/ship-release.ts as a GSTACK_SESSION_KIND reader)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ship-budget): document calls and dotted --step notes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ship: judge treats a missing severity as invalid; Capy passes rendered briefs via cat (replay C1-34 breaks)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep the Capy skill under its context budget after the main merge

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-31 07:22:56 +00:00
1 parent a77fae2837
commit b6cb70aea1
22 files changed
+249 -55

No files matched your search

+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [1.91.355.0] - 2026-10-31
**A driven `/ship` on Capy no longer pays one agent turn per finished reviewer, and it can count its own calls.** Replays of gstack-evals #34 and #36 (GSTA-167) made 30 to about 92 parent calls against a target of 12. In the 9-reviewer replay, 23 of 25 waits were the parent waking once per finished reviewer, because the driver told it to end its turn on `wait` while reviewers ran. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged.
### Changed
- **Readers are polled inside the program** (`ship/DRIVER.md`, Capy skill). A program returns 200 s after its first line and the next call resumes the poll; the parent never ends its turn while readers run. Capy polls with one `tools.subagent.list` per tick, starts readers a create call rejected once slots free, and reports a reader stuck for 20 minutes instead of dropping it. `wait` (now 200 s) is only for tests, pregate and CI when no reader runs. Lanes, pregate and `finish` run in the background.
- **A program returns at most 32 KB**; longer reads such as `review-army.md` split at headings, each byte once, before first use. Phase 2 used to return about 98 KB of sections at once, and truncated reads cost extra calls.
- **A fix runs as one gated program**: the failing test, the fix, `--tests-only` and the next pass, each only if the last step passed. Step 7's audit, which may write tests, settles before the review pass reads the tree.
- **An unattended `push` defers the one-time pre-push guard offer** (`REDACT_PREPUSH: … deferred=unattended`) instead of stopping. It writes no marker and installs no hook, so the next interactive session still gets the offer.
### Added
- **`gstack-ship-budget calls --events <file>`** counts a run's parent calls from the host's transcript items: `CALLS: total= decision= keepalive= idle_gaps_over_300s= max_gap_s=`. A wait, or a program that only reads status (plus at most one bash for its note), counts as a keep-alive.
### Fixed
- **`gstack-ship-budget note --step 9.1`** records under step 9 with the sub-step as its label. Before, it was refused, and replays lost two notes to that error.
- **`gstack-review-scope render`** accepts `{"deferred": [...]}` as well as the bare array.
- **`gstack-review-dispatch judge`** treats a judged line with no severity, or `"severity": null`, as invalid and prints `JUDGMENT_REJECTED`. Before, it crashed with a TypeError, and a replay spent three calls recovering.
- **The Capy skill passes a rendered brief as bash `cat` prints it.** A file read cut the long lines of a 231,589-byte outside-voice brief, so `sent` reported `BRIEF_MISMATCH` and the reader had to be sent again.
## [1.91.354.0] - 2026-10-31
**On Claude Code, `/land-and-deploy` loads about 15 KB less skill text per session.** Step 7's canary instructions (the canary script, third-party dashboard actions, Aside setup and the browser fallback) moved out of `land-and-deploy/SKILL.md` into an on-demand `sections/canary.md`, which the skill reads only when it reaches the canary step or a browser or third-party site action. `SKILL.md` drops from 51,992 to 32,216 bytes. Codex and Factory renders inline every section, so they stay about the same size. In a paid before/after run (Opus 5.5, 10 tasks, 3 trials per arm; GSTA-161), mean loaded skill bytes fell from 109,334 to 93,828 (-14%), the pass rate held (after 97%, before 80%, a second before panel 87%), and mean cost per session stayed flat. Pass rates are graded on the transcript. These numbers are exploratory, from one eval suite.
+1 -1
View File
@@ -1 +1 @@
1.91.354.0
1.91.355.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.354.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.355.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+2
View File
@@ -224,6 +224,8 @@ function cmdRender(rest: string[]): number {
let noted: ScopedFinding[] = [];
try {
if (deferredFile) deferred = JSON.parse(fs.readFileSync(deferredFile, 'utf8'));
const wrapped = deferred as unknown as { deferred?: unknown };
if (!Array.isArray(deferred) && Array.isArray(wrapped?.deferred)) deferred = wrapped.deferred as DeferredRecord[];
const findingsFile = flag(rest, '--findings');
if (findingsFile) noted = (readFindings(findingsFile, 'classified findings') as ScopedFinding[]).filter(f => f.scope === 'note');
} catch (e: any) { return refuse('render', 'REVIEW_SCOPE_INPUT', e.message); }
+3 -2
View File
@@ -23,13 +23,14 @@
* resume --record <path>|--run <id> [--reading <n>] [--meter <m>] [--session <id>] [--token-cap <n> --approved-by <who> --words <text>]
* [--rebuild-from-record]
* close --run|--record [--reading <n>] [--approved-by <who> --words <text>]
* calls --events <file> [--since <iso>] [--until <iso>] (the host's transcript items: one CALLS: line, lib/ship-calls.ts)
*
* Exit: 0 continue/delegate · 3 stop (the next step does not start) · 2 usage
* or validation (stderr). Logic: lib/ship-budget.ts, lib/ship-budget-cli.ts,
* lib/ship-handoff.ts.
*/
import { BudgetError } from '../lib/ship-budget';
import { cmdCheck, cmdClose, cmdNote, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli';
import { cmdCalls, cmdCheck, cmdClose, cmdNote, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli';
import { cmdHandoff, cmdLaunch, cmdReconcile, cmdReturn, cmdVerifyHandoff } from '../lib/ship-handoff';
const [command, ...rest] = process.argv.slice(2);
@@ -37,7 +38,7 @@ const ctx = { env: process.env, cwd: process.cwd() };
const commands: Record<string, () => Outcome> = {
start: () => cmdStart(rest, ctx), note: () => cmdNote(rest, ctx), check: () => cmdCheck(rest, ctx), settle: () => cmdSettle(rest, ctx), release: () => cmdSettle(rest, ctx, true),
handoff: () => cmdHandoff(rest, ctx), launch: () => cmdLaunch(rest, ctx), 'verify-handoff': () => cmdVerifyHandoff(rest, ctx), return: () => cmdReturn(rest, ctx),
reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx),
reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest),
};
if (!command || command === '--help' || command === '-h' || !commands[command]) {
const lines = (await Bun.file(import.meta.path).text()).split('\n');
+1 -1
View File
@@ -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 <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam 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 <n> --credits <c>`, 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`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base <base>` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start <skill>` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation <id>` from check's `RESERVATION_ID:` line); (3) `BEFORE`, the counter's tokens and credits; (4) `tools.subagent.create`, one reader each, `machine: "shared"`, reasoning from its `effort=` (`full` the lane's, `medium` medium, `light` low), its brief copied verbatim from the section that dispatches it; audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so they add `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits); (5) a poll of `tools.subagent.get` with `transcript: "none"` every ~20 s for at most 240 s, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); (6) `gstack-ship-budget note --step <n> --reading <AFTER> --credits <c> --dispatched-at <BEFORE> --dispatched-credits <c> --readers <n>`; (7) return only each reader's final output, or what still runs. The parent never walks the dispatch steps call by call. Waits: never let 5 minutes pass between calls. During tests, pregate, readers and CI, re-issue `wait` with `duration_seconds: 240` and the operation ids: a call after a longer gap bills the whole context fresh (about 40x a cached call), and a ping costs a few percent of that. A small waiting child saves nothing: the parent's cache expires during its wait too.
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base <base>` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start <skill>` once per row the step logs (admission mode: `--reservation` from `RESERVATION_ID:`); (3) `BEFORE`; (4) `tools.subagent.create`, one reader each, `machine: "shared"`, reasoning from its `effort=` (`full` the lane's, `medium` medium, `light` low), its brief copied verbatim from the section that dispatches it (a rendered file via bash `cat`, never a file read); audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so they add `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits); (5) a poll: one `tools.subagent.list` per ~20 s tick (a `tools.subagent.get` with `transcript: "none"` only on a status change), 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); start readers listed under `rejected` with `tools.subagent.start` as slots free; report one stuck 20 minutes; (6) `gstack-ship-budget note --record <rec> --step <n> --reading <AFTER> --credits <c> --dispatched-at <BEFORE> --dispatched-credits <c> --readers <n>`; (7) return only each reader's final output. The parent never walks the dispatch steps call by call. Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the poll program. 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 <file>` on your jam's transcript since /ship started. A small waiting child saves nothing.
## Plan, approve, ship
+11
View File
@@ -36,6 +36,17 @@ is written, so `gstack-review-log --start` takes no `--reservation`, and `close`
unreserved dispatch. `close` prints `BUDGET_TIMELINE:`, one entry per note. The admission mode
below is what `ship/SKILL.md` runs; it is unchanged.
A dotted `--step` such as `9.1` records under its parent step (`9`) with the sub-step as its label,
unless `--label` is given.
**Counting calls.** `gstack-ship-budget calls --events <file> [--since <iso>] [--until <iso>]` reads
the host's transcript items (a JSON array, or `{ "items": [...] }` as Capy's `tools.thread.get`
transcript returns) and prints
`CALLS: total= decision= keepalive= idle_gaps_over_300s= max_gap_s= first= last= source=`. Every tool
item is one parent call; a `wait`, or a program that only reads status (plus at most one bash for its
note), is a keep-alive, and a gap over 300 s between calls outlives the prompt cache
(`lib/ship-calls.ts`).
**Credits.** Every command that takes `--reading` also takes `--credits <cost.credits>` from the
same `thread.get`. Tokens count a cached read the same as a fresh one; credits are what the run
costs, including the full-context re-bill after a gap over five minutes (about 40x a cached call,
+2 -1
View File
@@ -43,7 +43,8 @@ export function recordSent(ledger: string, reader: string, sentText: string): {
/** Severity ranks across the voices' vocabularies; a label with no severity (FIXABLE, INVESTIGATE) ranks 0. */
const RANKS: Record<string, number> = { critical: 4, p0: 4, high: 3, p1: 3, medium: 2, p2: 2, informational: 2, low: 1, p3: 1, fixable: 0, investigate: 0 };
export function rank(label: string): number | null {
export function rank(label: unknown): number | null {
if (typeof label !== 'string') return null;
const r = RANKS[label.trim().replace(/^\[|\]$/g, '').toLowerCase()];
return r === undefined ? null : r;
}
+19 -2
View File
@@ -18,6 +18,7 @@ import {
runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger,
type Ledger, type MeterKind, type Next, type Note,
} from './ship-budget';
import { callsLine, countCalls } from './ship-calls';
import { claudeConfigDir, loadMeterState, newMeterState, nonceSessions, readClaudeUsage, saveMeterState, sessionJournals } from './claude-usage';
export type Opts = Record<string, string | true>;
@@ -377,8 +378,10 @@ const noteText = (n: Note) => `${n.step}${n.label ? `:${n.label}` : ''} used=${n
*/
export function cmdNote(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'step', 'label', 'reading', 'credits', 'dispatched-at', 'dispatched-credits', 'readers']);
const step = parseStep(str(o, 'step'));
const label = str(o, 'label');
const raw = str(o, 'step');
const parent = raw && !(SHIP_STEPS as readonly string[]).includes(raw) ? /^(\d+)(?:\.\d+)+$/.exec(raw)?.[1] : undefined;
const step = parseStep(parent && (SHIP_STEPS as readonly string[]).includes(parent) ? parent : raw);
const label = str(o, 'label') ?? (step !== raw ? raw : undefined);
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
if (l.closed_at) throw usageError(`run ${l.run} is closed`);
if (l.stopped) return { stdout: [budgetLine(l, { call: null, next: l.stopped.next, projected: 0, verdict: 'stop' }), `STOP: reason=${l.stopped.reason} at Step ${l.stopped.step}; resume first: gstack-ship-budget resume --record ${l.record}`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`], exit: 3 };
@@ -396,6 +399,20 @@ export function cmdNote(argv: string[], ctx: Ctx): Outcome {
});
}
/** calls: count parent calls from the host's transcript items (lib/ship-calls.ts). */
export function cmdCalls(argv: string[]): Outcome {
const o = parseArgs(argv, ['events', 'since', 'until']);
const file = str(o, 'events');
if (!file) throw usageError('calls needs --events <file of the host\'s transcript items>');
let doc: unknown;
try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { throw usageError(`calls --events ${file}: ${e.message}`); }
const d = doc as { items?: unknown; cursor?: unknown };
const items = Array.isArray(doc) ? doc : Array.isArray(d.items) ? d.items : undefined;
if (!items) throw usageError(`calls --events ${file}: expected a JSON array of transcript items or { items: [...] }`);
const c = countCalls(items, { since: str(o, 'since'), until: str(o, 'until') });
return { stdout: [callsLine(c, typeof d.cursor === 'string' ? d.cursor : 'none')], exit: 0 };
}
/** resume: re-attach after a stop or a machine change; rebuild or restore a lost ledger; journal a cap raise. */
export function cmdResume(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['record', 'run', 'reading', 'credits', 'prior-reading', 'meter', 'session', 'nonce', 'token-cap', 'approved-by', 'words'], ['rebuild-from-record']);
+40
View File
@@ -0,0 +1,40 @@
/**
* ship-calls — a driven /ship's parent calls, counted from the host's transcript events
* (Capy: tools.thread.get transcript items), so acceptance never rests on the agent's own
* count (GSTA-167 calls plan, C9). Every tool item is one parent call. A keep-alive is a
* wait, or a program that only reads status (subagent, operation or thread reads) plus at
* most one bash for its budget note; everything else is a decision call. A gap over 300 s
* between calls outlives the prompt cache, so the next call is billed fresh.
*/
export interface TranscriptItem { source?: string; tool?: string; calls?: Array<{ tool?: string }>; createdAt?: string }
export interface CallCount { total: number; decision: number; keepalive: number; idleGaps: number; maxGapS: number; first: string | null; last: string | null }
const STATUS_READS = new Set(['subagent_get', 'subagent_list', 'operation_get', 'operation_list', 'thread_get']);
export const CACHE_TTL_S = 300;
function isKeepalive(item: TranscriptItem): boolean {
if (item.tool === 'wait') return true;
if (item.tool !== 'execute') return false;
const calls = (item.calls ?? []).map(c => c.tool ?? '');
const reads = calls.filter(t => STATUS_READS.has(t)).length;
const bash = calls.filter(t => t === 'bash').length;
return reads > 0 && reads + bash === calls.length && bash <= 1;
}
export function countCalls(items: readonly TranscriptItem[], window: { since?: string; until?: string } = {}): CallCount {
const calls = items.filter(i => i.source === 'tool' && i.createdAt
&& (!window.since || i.createdAt >= window.since) && (!window.until || i.createdAt <= window.until))
.sort((a, b) => a.createdAt!.localeCompare(b.createdAt!));
let idleGaps = 0, maxGapS = 0;
for (let k = 1; k < calls.length; k++) {
const gap = Math.round((Date.parse(calls[k]!.createdAt!) - Date.parse(calls[k - 1]!.createdAt!)) / 1000);
if (gap > CACHE_TTL_S) idleGaps++;
maxGapS = Math.max(maxGapS, gap);
}
const keepalive = calls.filter(isKeepalive).length;
return { total: calls.length, decision: calls.length - keepalive, keepalive, idleGaps, maxGapS,
first: calls[0]?.createdAt ?? null, last: calls[calls.length - 1]?.createdAt ?? null };
}
export const callsLine = (c: CallCount, source: string) =>
`CALLS: total=${c.total} decision=${c.decision} keepalive=${c.keepalive} idle_gaps_over_300s=${c.idleGaps} max_gap_s=${c.maxGapS} first=${c.first ?? 'none'} last=${c.last ?? 'none'} source=${source}`;
+3 -2
View File
@@ -221,8 +221,9 @@ export function push(argv: readonly string[], cwd: string): Report {
if (branch !== flag('--branch')) return r.stop(`HEAD is on ${branch}, not the branch Step 1 saved (${flag('--branch')}): check out the saved branch, then rerun push`);
const guard = bin(cwd, 'gstack-config', ['get', 'redact_prepush_hook'], 30_000).stdout.trim() || 'false';
const prompted = fs.existsSync(path.join(resolveStateRoot(process.env), '.redact-prepush-prompted'));
r.add(`REDACT_PREPUSH: ${guard} prompted=${prompted ? 'yes' : 'no'}`);
if (guard !== 'true' && !prompted) return r.stop('Step 17\'s one-time pre-push guard offer is unanswered: run the guard block and ask it, then rerun push');
const deferred = guard !== 'true' && !prompted && process.env.GSTACK_SESSION_KIND === 'unattended';
r.add(`REDACT_PREPUSH: ${guard} prompted=${prompted ? 'yes' : 'no'}${deferred ? ' deferred=unattended' : ''}`);
if (guard !== 'true' && !prompted && !deferred) return r.stop('Step 17\'s one-time pre-push guard offer is unanswered: run the guard block and ask it, then rerun push');
const gateOpen = Number(flag('--gate-open') ?? '0');
if (gateOpen > 0) {
if (!hostedOrigin(cwd)) return r.stop('open-PR lookup unavailable: origin is a local path, not a hosted remote gh can map; it must succeed before pushing open gate findings');
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.354",
"version": "1.91.355",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+17 -18
View File
@@ -7,7 +7,7 @@ Read this instead of `ship/SKILL.md` when a parent agent on any host runs /ship
the `GSTACK_LAUNCH` assignments your installer printed (`. <the GSTACK_LAUNCH_FILE it printed>`), never
`export $(...)`, `env`, `printenv` or a bare `set` in a tool fence.
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a shell
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a
script) that runs the bins, reads, dispatches and waits, returning only result lines and reader
outputs; print them verbatim. The model keeps only lead judgment, fixes, CHANGELOG and PR prose, the
gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
@@ -29,17 +29,19 @@ file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` r
small model at low effort; their findings go to the PR body.
**Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's
section read or a fix-check. A pass that fixes something repeats Phases 3-4.
section read or a fix-check, plus a keep-alive per 200 s waited. A fix repeats Phases 3-4.
**Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends
with `gstack-ship-budget note --step <last step> --reading R --credits C`, R and C read in that program
(Capy: your own jam's `cost.tokens` and `cost.credits`). No check per step, no reservation, no meter-only
with `gstack-ship-budget note --step <last step> --reading R --credits C --record <rec>`, R and C read in that program
(Capy: your jam's `cost.tokens`, `cost.credits`). No check per step, no reservation, no meter-only
call, no handoff; under `--token-cap` a note past the cap prints `verdict=stop`, the one stop check.
A dispatching program takes `BEFORE` (R and C) as its readers start and passes
`--dispatched-at <R> --dispatched-credits <C> --readers <n>` with the reading after the last settles.
**Waits.** No call waits past 240 s: a program returns whatever still runs at 240 s; the next call resumes (Capy: `wait` 240 s on it). A small
waiting child saves nothing. Past 100 minutes, stop.
**Waits.** No call waits past 200 s: a program returns what still runs 200 s after its first line; the next
call resumes the poll. Lanes, pregate and `finish` run in background. Never end the turn while
readers run (each wakes it). A small waiting child saves nothing. Past 100 minutes, stop. Returns:
≤32 KB per program; split longer reads at headings, each byte once, before first use.
**One program per pass.** Each review pass, Step 11 and the Step 14.5 audit is one program, and the
parent makes no other call while it runs: (1) `gstack-review-plan --base <base>` with the pass's
@@ -49,8 +51,7 @@ flags (its `DISPATCH:` lines; Step 14.5 has one reader, `docs`); (2) `gstack-rev
(5) a poll until every reader settles (status `done` or `failed`, or a final summary; a fresh `idle`
has not run); (6) `note` with the reading pair; a later batch (the Red Team,
given the specialists' collected lines) repeats (3)-(6); (7) the collect: return only each reader's
final output and status, via `collect`. The next call applies the section's merge, judgment, fixes and persistence;
it never walks the dispatch steps call by call.
final output and status, via `collect`; it never walks the dispatch steps call by call.
## 1. Load: Step 0
@@ -60,16 +61,15 @@ The install check and this file.
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (the test commands AGENTS.md or
CLAUDE.md names): `STATUS:`, `DIFF:`, `PR:`, `CARRY:`, `SCOPE:`, `MERGE:`, `DISTRIBUTION:`,
`POLICY:`, `RUNTIME_LINES:`, `PREGATE:`, `LANE:`. `CARRY: carried` ends the run on its `GSTACK_RESULT:`
CLAUDE.md names). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
is `count unavailable`. In-branch failures stop the ship; after a repair rerun with `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the project's eval selector and pre-merge command (detached past 30 min); else
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Steps 6.5 and 10 run only if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map, the
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. The same program reads
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read
`sections/review-army.md`, `sections/adversarial.md`, `sections/documentation.md`, the todos and commits
files, and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is `needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
@@ -77,7 +77,7 @@ Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never re
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written), Step 9.2.1's QA reader (that step of
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Step 9's plan and start, Step 9.1's
specialists and Step 9.2's Red Team as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
@@ -88,7 +88,7 @@ Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
`JUDGMENT: ok` before any fix), fixes and persistence in
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
`scoped` reruns Step 5 (`--tests-only`) and Phases 3-4; `done`: the same program runs Step 11 from
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated; `done`: the same program runs Step 11 from
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
prints `ADVERSARIAL: skipped (review plan)`).
@@ -110,17 +110,16 @@ One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit features in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
Commit in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
(pushed or ALREADY_PUSHED). Its pre-push offer STOP runs Step 17's guard block (`HOOK_STATE:`,
`HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`); unattended takes the recommended install. Never force-push.
(pushed or ALREADY_PUSHED). Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block; with `CAPY_PR: on` (Step 19-C) What changed, Why, Blast radius, Approvals (`gstack-approvals render`); Summary, Test Coverage, Pre-Landing Review (`REVIEW_LOOP:`, `Gate edits:`, deferred, notes), Exploratory QA, Plan Completion, Verification Results, Issues, TODOS, Documentation, Test plan. `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; put both in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
+17 -18
View File
@@ -5,7 +5,7 @@ Read this instead of `ship/SKILL.md` when a parent agent on any host runs /ship
the `GSTACK_LAUNCH` assignments your installer printed (`. <the GSTACK_LAUNCH_FILE it printed>`), never
`export $(...)`, `env`, `printenv` or a bare `set` in a tool fence.
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a shell
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a
script) that runs the bins, reads, dispatches and waits, returning only result lines and reader
outputs; print them verbatim. The model keeps only lead judgment, fixes, CHANGELOG and PR prose, the
gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
@@ -27,17 +27,19 @@ file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` r
small model at low effort; their findings go to the PR body.
**Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's
section read or a fix-check. A pass that fixes something repeats Phases 3-4.
section read or a fix-check, plus a keep-alive per 200 s waited. A fix repeats Phases 3-4.
**Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends
with `gstack-ship-budget note --step <last step> --reading R --credits C`, R and C read in that program
(Capy: your own jam's `cost.tokens` and `cost.credits`). No check per step, no reservation, no meter-only
with `gstack-ship-budget note --step <last step> --reading R --credits C --record <rec>`, R and C read in that program
(Capy: your jam's `cost.tokens`, `cost.credits`). No check per step, no reservation, no meter-only
call, no handoff; under `--token-cap` a note past the cap prints `verdict=stop`, the one stop check.
A dispatching program takes `BEFORE` (R and C) as its readers start and passes
`--dispatched-at <R> --dispatched-credits <C> --readers <n>` with the reading after the last settles.
**Waits.** No call waits past 240 s: a program returns whatever still runs at 240 s; the next call resumes (Capy: `wait` 240 s on it). A small
waiting child saves nothing. Past 100 minutes, stop.
**Waits.** No call waits past 200 s: a program returns what still runs 200 s after its first line; the next
call resumes the poll. Lanes, pregate and `finish` run in background. Never end the turn while
readers run (each wakes it). A small waiting child saves nothing. Past 100 minutes, stop. Returns:
≤32 KB per program; split longer reads at headings, each byte once, before first use.
**One program per pass.** Each review pass, Step 11 and the Step 14.5 audit is one program, and the
parent makes no other call while it runs: (1) `gstack-review-plan --base <base>` with the pass's
@@ -47,8 +49,7 @@ flags (its `DISPATCH:` lines; Step 14.5 has one reader, `docs`); (2) `gstack-rev
(5) a poll until every reader settles (status `done` or `failed`, or a final summary; a fresh `idle`
has not run); (6) `note` with the reading pair; a later batch (the Red Team,
given the specialists' collected lines) repeats (3)-(6); (7) the collect: return only each reader's
final output and status, via `collect`. The next call applies the section's merge, judgment, fixes and persistence;
it never walks the dispatch steps call by call.
final output and status, via `collect`; it never walks the dispatch steps call by call.
## 1. Load: Step 0
@@ -58,16 +59,15 @@ The install check and this file.
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (the test commands AGENTS.md or
CLAUDE.md names): `STATUS:`, `DIFF:`, `PR:`, `CARRY:`, `SCOPE:`, `MERGE:`, `DISTRIBUTION:`,
`POLICY:`, `RUNTIME_LINES:`, `PREGATE:`, `LANE:`. `CARRY: carried` ends the run on its `GSTACK_RESULT:`
CLAUDE.md names). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
is `count unavailable`. In-branch failures stop the ship; after a repair rerun with `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the project's eval selector and pre-merge command (detached past 30 min); else
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Steps 6.5 and 10 run only if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map, the
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. The same program reads
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read
`sections/review-army.md`, `sections/adversarial.md`, `sections/documentation.md`, the todos and commits
files, and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is `needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
@@ -75,7 +75,7 @@ Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never re
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written), Step 9.2.1's QA reader (that step of
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Step 9's plan and start, Step 9.1's
specialists and Step 9.2's Red Team as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
@@ -86,7 +86,7 @@ Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
`JUDGMENT: ok` before any fix), fixes and persistence in
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
`scoped` reruns Step 5 (`--tests-only`) and Phases 3-4; `done`: the same program runs Step 11 from
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated; `done`: the same program runs Step 11 from
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
prints `ADVERSARIAL: skipped (review plan)`).
@@ -108,17 +108,16 @@ One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit features in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
Commit in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
(pushed or ALREADY_PUSHED). Its pre-push offer STOP runs Step 17's guard block (`HOOK_STATE:`,
`HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`); unattended takes the recommended install. Never force-push.
(pushed or ALREADY_PUSHED). Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block; with `CAPY_PR: on` (Step 19-C) What changed, Why, Blast radius, Approvals (`gstack-approvals render`); Summary, Test Coverage, Pre-Landing Review (`REVIEW_LOOP:`, `Gate edits:`, deferred, notes), Exploratory QA, Plan Completion, Verification Results, Issues, TODOS, Documentation, Test plan. `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; put both in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
+1 -1
View File
@@ -58,7 +58,7 @@ export function shipFixture(base: Record<string, string> = {}, feat: Record<stri
must(gitIn(work, ['add', '-A']), 'add feat'); must(gitIn(work, ['commit', '-qm', 'feat']), 'feat commit');
const { GSTACK_SESSION_KIND: _k, GSTACK_HEADLESS: _h, OPENCLAW_SESSION: _o, CI: _c, GITHUB_ACTIONS: _g, ...ambient } = process.env;
const env = { ...ambient, PATH: `${stub}:${process.env.PATH}`, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com',
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GSTACK_SESSION_KIND: 'interactive' };
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
const fx: ShipFx = {
root, work, origin, state,
+2
View File
@@ -224,6 +224,8 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
expect(parseFindings('r', 'FIXABLE: race in a.ts:3\nINVESTIGATE: odd lock\nRecommendation: Fix the race because x\n').map(f => f.label)).toEqual(['FIXABLE', 'INVESTIGATE']);
const m: MergedLine[] = [{ id: 'r#1', reader: 'r', label: 'FIXABLE', raw: 'x', output_sha256: 'h' }];
expect(judge(m, [{ id: 'r#1', severity: 'severe' }]).invalid).toEqual(['r#1']);
// Replay C1-34 (GSTA-193): a judged line with "severity": null crashed judge with a TypeError; it is an invalid line.
expect(judge(m, [{ id: 'r#1', severity: null as unknown as string }, { id: 'lead#1' } as unknown as { id: string; severity: string }]).invalid).toEqual(['r#1', 'lead#1']);
expect(judge(m, [{ id: 'r#1', severity: 'INFORMATIONAL', reason: 'a real defect' }]).ok).toBe(true);
});
+4
View File
@@ -288,6 +288,10 @@ describe('gstack-review-scope (bin)', () => {
const badRender = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
expect(badRender.status).toBe(EXIT.fail);
expect(badRender.err).toContain('expected the JSON array defer --out writes');
fs.writeFileSync(d, '{"deferred":[]}');
const wrapped = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
expect(wrapped.status, wrapped.err).toBe(EXIT.ok);
expect(wrapped.out).toBe('');
});
test('classify sees a non-ASCII changed path under its own name, so a CRITICAL finding there is fixed, not deferred to an issue', () => {
+1
View File
@@ -26,6 +26,7 @@ const SESSION_KIND_INVENTORY: Record<string, string> = {
'bin/gstack-plan-reality': 'records the kind (:58)',
'bin/gstack-ship-receipt': 'records the kind (:143, :322)',
'lib/autoplan-run.ts': 'records the kind (:110)',
'lib/ship-release.ts': 'an unattended push defers the pre-push guard offer (:203)',
'bin/gstack-brain-enqueue': 'an unattended session never spools for sync (:38)',
'hosts/claude/hooks/spawned-directive.ts': 'Claude Code only (:57)',
'hosts/claude/hooks/question-preference-hook.ts': 'Claude Code only (:506)',
+15
View File
@@ -414,6 +414,21 @@ describe('variant G: observe mode, note and credits (GSTA-167)', () => {
expect(close.out).toContain('BUDGET_TIMELINE: 6 used=200000 credits=50.5; 9:dispatch:specialist,red-team used=2200000 credits=300 readers=2 readers_tokens=2000000 readers_credits=249.5');
});
// Value: protects=a note at a sub-step id records under its ship step with the sub-step as its label, so a driven run never loses a program's reading to an id error; fails_when=note refuses 9.1 or 9.2.1 (replay 8G #34 broke 1, 9G broke 3: two notes lost), records under another step, or starts accepting ids with no ship step;
// why_new=sub-step ids were refused; seam=none; no_claim=which sub-steps the driver names
test('note --step 9.1 records under step 9 labelled 9.1; an explicit label wins; an id with no ship step is refused', () => {
const h = fresh();
const s = start(h, ['--mode', 'observe', '--token-cap', '400M', '--reading', '1000000', '--credits', '100']);
const sub = h.sb(['note', '--run', s.run, '--step', '9.1', '--reading', '1100000', '--credits', '110']);
expect(sub.code, sub.out + sub.err).toBe(0);
expect(sub.out).toMatch(/^NOTE: 9:9\.1 used=100000 credits=10$/m);
const qa = h.sb(['note', '--run', s.run, '--step', '9.2.1', '--label', 'qa', '--reading', '1200000', '--credits', '120']);
expect(qa.out).toMatch(/^NOTE: 9:qa used=200000 credits=20$/m);
const bad = h.sb(['note', '--run', s.run, '--step', '99.1', '--reading', '1300000', '--credits', '130']);
expect(bad.code).toBe(2);
expect(bad.err).toContain('is not a ship step id');
});
// Value: protects=the cap stays one hard stop in observe mode: a note past it stops with the resumable block, and a credits reading below the last is refused like a token reading; fails_when=observe never stops, stops below the cap, or accepts a credits regression;
// why_new=observe mode is new; seam=none; no_claim=what a stop costs
test('a note past the cap stops; at the cap it continues; a lower credits reading is refused', () => {
+58
View File
@@ -0,0 +1,58 @@
/**
* gstack-ship-budget calls (GSTA-167 calls plan, C9): count a driven /ship's parent calls from the
* host's own transcript events, not from the agent's say-so, and split keep-alive polls from
* decision calls; flag every gap over the 300 s prompt-cache lifetime.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { countCalls } from '../lib/ship-calls';
const BIN = path.resolve(import.meta.dir, '..', 'bin', 'gstack-ship-budget');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-calls-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const at = (s: string) => `2026-10-30T${s}.000Z`;
const exec = (t: string, calls: string[]) => ({ id: `x${t}`, source: 'tool', tool: 'execute', calls: calls.map(tool => ({ tool, durationMs: 1 })), text: '', createdAt: at(t) });
// Shapes copied from GSTA-189's transcript (tools.thread.get transcript items), times shortened.
const ITEMS = [
{ id: 'u', source: 'user', text: 'run /ship', createdAt: at('18:19:00') },
exec('18:19:35', ['bash', 'thread_get', 'bash']),
exec('18:23:17', ['bash']),
{ id: 'w1', source: 'tool', tool: 'wait', text: 'Waiting for preflight lanes to finish', createdAt: at('18:23:23') },
exec('18:31:23', ['bash', 'read', 'subagent_create', 'subagent_create']),
{ id: 'w2', source: 'tool', tool: 'wait', text: 'Waiting for specialist readers to finish', createdAt: at('18:31:27') },
exec('18:31:38', ['subagent_get', 'subagent_get']),
exec('18:33:36', ['subagent_list', 'subagent_get', 'bash']),
{ id: 'a', source: 'assistant', text: 'Phase 3 running', createdAt: at('18:33:40') },
{ id: 'm', source: 'tool', tool: 'message_agent', text: 'GSTA-167', createdAt: at('18:40:00') },
];
describe('countCalls', () => {
// Value: protects=acceptance counts calls from host events: each tool item is one call, a wait or a poll-only program (status reads plus at most one bash for the note) is a keep-alive, a lone bash is a decision, everything else is a decision call, and every gap over 300 s between calls is an idle gap; fails_when=user or assistant text is counted as a parent call, a poll with a note is counted as a decision, or a 6-minute gap is missed;
// why_new=calls were counted by hand from transcripts; seam=none; no_claim=the host's billing of a gap
test('splits decision calls from keep-alives and flags gaps over 300 s', () => {
const c = countCalls(ITEMS);
expect(c).toMatchObject({ total: 8, decision: 4, keepalive: 4, idleGaps: 2, maxGapS: 480 });
expect(c.first).toBe(at('18:19:35'));
expect(c.last).toBe(at('18:40:00'));
});
test('--since and --until bound the window', () => {
const c = countCalls(ITEMS, { since: at('18:31:00'), until: at('18:34:00') });
expect(c).toMatchObject({ total: 4, decision: 1, keepalive: 3, idleGaps: 0 });
});
});
describe('gstack-ship-budget calls', () => {
test('prints one CALLS line from an events file; a missing file is a usage error', () => {
const f = path.join(tmp, 'events.json');
fs.writeFileSync(f, JSON.stringify({ items: ITEMS, cursor: '01M6719TXDC28B5ZS6712P5AB7' }));
const r = spawnSync(BIN, ['calls', '--events', f], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.trim()).toBe(`CALLS: total=8 decision=4 keepalive=4 idle_gaps_over_300s=2 max_gap_s=480 first=${at('18:19:35')} last=${at('18:40:00')} source=01M6719TXDC28B5ZS6712P5AB7`);
const missing = spawnSync(BIN, ['calls', '--events', path.join(tmp, 'nope.json')], { encoding: 'utf8', timeout: 30_000 });
expect(missing.status).toBe(2);
});
});
+19 -7
View File
@@ -177,14 +177,26 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST
expect(doc).toContain('gstack-ship-budget close --reading R --credits C');
});
// Value: protects=no call waits past the 5-minute prompt cache: programs return within 240 s and the next call resumes the wait, with the choice of a ping over a waiting child stated where the parent and Capy read it; fails_when=a phase waits in one program until slow work ends (cache probe: a call after a 7-minute gap cost ~2,820 credits against ~70), the cadence goes back to 270 s (16 s from the cache lifetime), or the reason is dropped;
// why_new=wait hygiene is new; seam=none; no_claim=the host's cache lifetime
test('waits ping every 240 s in the driver and the Capy skill, and both say why a waiting child is not used', () => {
// Value: protects=no call waits past the 5-minute prompt cache and no turn ends while readers run: a program returns 200 s after its first line, the next call resumes the poll, long commands run in background, and Capy waits on operations only when no reader runs, with the choice of a ping over a waiting child stated where the parent and Capy read it; fails_when=a phase waits in one program until slow work ends (cache probe: a call after a 7-minute gap cost ~2,820 credits against ~70), the parent ends its turn on `wait` while readers run (GSTA-189: 23 of 25 waits were one wake per finished reader), the cadence drifts back toward the cache lifetime (240 s counted from the poll loop, or 270 s), or the reason is dropped;
// why_new=GSTA-167 calls plan C1; seam=none; no_claim=the host's cache lifetime or wake policy
test('waits return 200 s after a program starts and never end the turn while readers run, in the driver and the Capy skill', () => {
const waits = flat(doc.slice(doc.indexOf('**Waits.**'), doc.indexOf('\n\n', doc.indexOf('**Waits.**'))));
for (const s of ['No call waits past 240 s', 'the next call resumes', 'A small waiting child saves nothing', 'Past 100 minutes']) expect(waits).toContain(s);
const capy = read('capy/skill/gstack/SKILL.md');
for (const s of ['never let 5 minutes pass between calls', '`duration_seconds: 240`', 'A small waiting child saves nothing']) expect(capy).toContain(s);
for (const text of [doc, capy]) expect(text).not.toMatch(/\b270 ?s\b/);
for (const s of ['No call waits past 200 s', '200 s after its first line', 'the next call resumes the poll', 'run in background', 'Never end the turn while readers run',
'A small waiting child saves nothing', 'Past 100 minutes']) expect(waits).toContain(s);
const capy = flat(read('capy/skill/gstack/SKILL.md'));
for (const s of ['never let 5 minutes pass between calls', 'While readers run, never end the turn on `wait`', '`duration_seconds: 200`', 'one `tools.subagent.list` per ~20 s tick',
'returning 200 s after the program\'s first line', 'under `rejected` with `tools.subagent.start`', 'A small waiting child saves nothing']) expect(capy).toContain(s);
for (const text of [doc, capy]) expect(text).not.toMatch(/\b270 ?s\b|duration_seconds: 240|at most 240 s/);
});
// Value: protects=a program's return fits the host's output cap, a fix runs as one gated program, Step 7's test-writing audit settles before the pass reads the tree, and an unattended push never stops on the one-time pre-push offer; fails_when=Phase 2 returns review-army.md + adversarial.md + documentation.md (97,750 B) at once (replays 8G: truncated reads cost extra calls), a fix walks test, fix, tests and dispatch call by call (GSTA-189: 16 calls), Step 7 writes tests while specialists read (GSTA-164: a pregate refused mid-run), or push stops unattended (all three variant G replays);
// why_new=GSTA-167 calls plan C5, C6, C7 and round-2 High 11; seam=none; no_claim=the parent's adherence, which the replays' CALLS: lines measure
test('returns stay at or under 32 KB, a fix is one gated program, Step 7 settles first, and unattended push defers the pre-push offer', () => {
const f = flat(doc);
for (const s of ['Returns: ≤32 KB per program; split longer reads at headings, each byte once, before first use',
'one program runs the failing test, fix, `--tests-only` and Phase 3, each gated', 'Step 7 may write tests, so it settles first',
'Unattended, push defers the pre-push offer; else its STOP runs Step 17\'s guard block', 'plus a keep-alive per 200 s waited']) expect(f).toContain(s);
expect(flat(doc.slice(doc.indexOf('**Budget: observe.**')))).toContain('--reading R --credits C --record <rec>');
});
// Value: protects=a reader counts as settled only once it has run (status done or failed, or a final summary), in the driver, the Capy skill and the budget section, so a poll never closes a batch on a subagent that was just created; fails_when=the settled rule is dropped or lets a bare idle status through (replay 7e #36: the Red Team reservation settled actual=0 because a just-created subagent read idle);
+12
View File
@@ -265,6 +265,18 @@ describe('push (Step 17)', () => {
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'PUSH')).toMatch(/^PUSH: ALREADY_PUSHED /);
});
// Value: protects=an unattended push defers the one-time guard offer instead of stopping, and leaves the offer open for the next interactive session (no marker, no hook); fails_when=unattended push stops on the offer (replays 8G #34, 8G #36 and 9G each spent calls on it), writes .redact-prepush-prompted, or installs a hook;
// why_new=the offer stopped every unattended run; seam=gstack-config, git push; no_claim=the interactive guard block
test('unattended, an unanswered guard offer is deferred: it pushes, writes no marker and installs no hook', () => {
const f = fx();
const r = f.run(RELEASE, ['push', '--branch', 'feat'], { GSTACK_SESSION_KIND: 'unattended' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(lineOf(r.stdout, 'REDACT_PREPUSH')).toBe('REDACT_PREPUSH: false prompted=no deferred=unattended');
expect(lineOf(r.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/feat$/);
expect(fs.existsSync(path.join(f.state, '.redact-prepush-prompted'))).toBe(false);
expect(fs.existsSync(path.join(f.work, '.git', 'hooks', 'pre-push'))).toBe(false);
});
// Value: protects=a rejected push stops with the merge-and-reverify route and never force-pushes; fails_when=the bin retries with force or reports success;
// why_new=moved from Step 17's failure rules; seam=git push; no_claim=the merge
test('a non-fast-forward push stops with the merge route', () => {