mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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:
1 parent
a77fae2837
commit
b6cb70aea1
22 files changed
+249
-55
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user