diff --git a/CHANGELOG.md b/CHANGELOG.md index 22077040a..619fa5e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.91.172.0] - 2026-10-20 + +**gstack now works the way Capy Captains run work: every decision goes to the Captain (or the person) in plain words, plan approvals arrive in a fixed before/after shape, each thread keeps a record of who approved what, and /ship's PR body shows before/after images of what changed.** Garry asked for this on 2026-10-20 ("make it much more obvious a/ how approvals work per thread, b/ for each plan approval i want a before/after, why it matters, what caused us to work on this, and big decisions you had to make, c/ when asking for merge guidance i want the PRs to show screenshots of what changed and why"). Everything below applies only when `GSTACK_CAPY=1` (the Capy launch line sets it). Other hosts keep their output, apart from one new `CAPY: off` STATUS line. + +### Added +- **`capy-asks` runtime block (`bin/gstack-skill-start`).** Every workflow started in Capy prints `CAPY: on` and one instruction block. A crew thread asks its Captain with a needs-you report, and a thread with no Captain asks the person with a Capy question; never as a bare question or gate codes. Each ask states what it is, why it matters, what each answer does and costs, the pick, and the one-word reply. Plan approvals use the Captain brief. Every answer is recorded. Standing approvals the Captain lists are cited instead of asked again. A fixed list always needs a fresh ask: product or behaviour decisions, spend over a stated cap, publishing, threshold, budget or trial-count changes, closing someone's contribution, and anything destructive. `GSTACK_CAPY_ASKS=0` drops the block for one session. No generated SKILL.md grows. +- **Captain brief (`gstack-owner-brief render --captain --out `, `lib/captain-brief.ts`).** It prints the plan ask in this order: a header with "Reply **rec**", then Before → after, Why it matters, What caused this work, Big decisions (read from the reviewed plan's sections, each capped with a pointer to the plan), and What we need from you, which lists every choice `rec` takes, in words. Gate ids, `gate_rev`, `rec_hash` and the exact `answer` command appear only on the last line, which is for the relaying agent. A plan missing a section renders "Not ready for approval" with no reply invitation and exits 1 (`CAPTAIN_BRIEF_INCOMPLETE`; `--allow-incomplete` previews). Under Capy, `gstack-autoplan` prints a `RELAY: gstack-owner-brief render --captain …` line next to every `GATE:` line and after `recommend`. +- **Per-thread approval record (`gstack-approvals`, `lib/approval-record.ts`).** `add --ask --words --by --scope [--pending] [--outcome approved|rejected|pending|standing]`, `import --autoplan --by ` (one row per answer event, typed or relayed, idempotent), `render` (the `## Approvals` table), `list`, `sync`, `path`. Rows live at `/projects//approvals/.jsonl` (a readable prefix plus a hash of the exact ref). Under Capy they are mirrored to the private project-user Drive folder (or `GSTACK_APPROVALS_DIR`), because the machine's state root is thrown away. Every field passes the secret scanner before anything is written (`APPROVAL_RECORD_SECRET` names the field and rule, never the text). The table escapes mentions, links, images, HTML and citation-shaped text, so the ship receipt never reads it as an approval citation. A fresh Capy machine with no record and no mirror says "Record unavailable", not "No approvals recorded" (`APPROVAL_RECORD_UNAVAILABLE`). +- **Before/after images (`gstack-pr-shots`, `lib/pr-shots.ts`).** Takes a tracked file (optionally one heading's section, via the same fence-aware parser as the brief, `lib/markdown-sections.ts`) at the merge base and in the working tree, or two captures inside the repository. It writes a stacked Before/After page at GitHub's 880 px column, with changed hunks only, a −/+ gutter as well as colour, and "⋯ N unchanged lines" gaps, so a change deep in a file stays visible. It also writes `text.md` (a collapsed unified diff for the PR body) and renders `pair.png` through `lib/aside-render.ts`. It reads text only and runs nothing. Inputs are size-capped, ANSI-stripped, refused when binary or outside the repository and state root, and secret-scanned before rendering; refs are resolved without option parsing. With no browser it prints `SHOT_PNG: unavailable (…)` and exits 0, so the caller can screenshot `shot.html` itself. +- **/ship Capy section (`ship/sections/capy-pr.md`).** When Step 19 prints `CAPY_PR: on`, the PR body opens (after the receipt) with `## What changed`: one to three captioned before/after images uploaded with Capy's PR asset tool, public repositories only, each with its text version under it. Then come `## Why` and `## Approvals` (`gstack-approvals render`), and the READY report attaches the key image. Outside Capy the section is never read. +- **Capy skill: "Working under a Captain" plus `capy/skill/gstack/captain.md`.** The skill now explains in one section how approvals flow, what standing approvals look like, what always needs a fresh ask, the record, the plan-ask shape and the merge-ask shape. `captain.md` has the worked commands. `autoplan/DRIVER.md`'s "On Capy" rules name the four plan sections, the brief and the import. + +### Changed +- **Owners refresh their Drive skill copy by hand.** The skill folder gained guidance ("Working under a Captain", `captain.md`); the bootstrapper is unchanged, so the wrapper stays at 2 and no stale-copy notice fires. Copy the folder over the Drive skill to pick it up: `cp -R ~/.local/share/gstack/capy/skill/gstack/. /skills/gstack/`. +- **Gate integrity (a gate edit): context-budget ceiling for `capy/skill/gstack` 2923 → 3498 tokens (`test/fixtures/context-budget.json`, that entry only)** for the new section; no other ceiling moved. +- **Gate integrity (a gate edit): ship's parity union cap `maxSizeRatio` 1.6488 → 1.6690 (`test/helpers/carve-guards.ts`).** The only growth is the on-demand `capy-pr` section plus a three-line `CAPY_PR` check and one manifest row, measured at 313,265 / 187,706. A non-Capy ship never loads the section. The skeleton stays under 81,000 bytes. No eval threshold, trial count, case kind or budget changed. + +### Tests +- `test/approval-record.test.ts`, `test/captain-brief.test.ts`, `test/pr-shots.test.ts`: branch keys and containment, secret refusal with nothing written and nothing echoed, citation-proof escaping checked against `parseCitations`, idempotent import of typed and relayed answers, mirror restore and the unavailable state, brief order, gate ids only on the agent line, `rec_hash`, incomplete and answered states, change-centred hunks (a change at line 240 of 300), HTML escaping and CSP, section extraction at a base commit, option-like refs, outside files and the no-browser path. `test/gstack-skill-start.test.ts` pins `CAPY` in the STATUS contract and the block's on/off/quiet behaviour. Ship goldens are regenerated. + ## [1.91.171.0] - 2026-10-20 **A restarted `/ios-qa` daemon resumes the device session instead of relaunching the app.** The app's boot token works once, so a new daemon process used to have no way back in except `devicectl process launch --terminate-existing`, which wiped the app's QA state. The daemon now writes the rotated StateServer bearer to `$GSTACK_STATE_ROOT/ios-qa-session.json` (mode 0600, through a fresh temp file) after every successful bootstrap, bound to the device UDID, bundle ID and StateServer port, and a new daemon tries it once on its first device request. The bearer goes only to the tunnel address `devicectl` reports for the bound UDID, after `/healthz` names the same bundle; there is no mDNS or remembered-address fallback. The daemon bootstraps normally, without sending the bearer, when the target UDID, app or port differs, another device or app answers, `devicectl` reports no address, or the file is not private to you (the file is removed unused). A bearer the app rejects is cleared before the bootstrap and never presented again, and the boot token is never written to disk. `/ios-qa` Phase 0 now describes this file instead of a cache no code wrote. Draft until verified on a real iPhone. diff --git a/VERSION b/VERSION index be80bdf59..9fd1b6616 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.171.0 +1.91.172.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4c0a5ad9d..49e96a177 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.171.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.172.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, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index ac4b2065b..a464c2fdb 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -50,6 +50,9 @@ line yourself; gstack never writes the plan. with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a generation is published at rest, after each bound voice and after each phase transition; restore with `resume --out --from /`, which never redispatches a bound voice. +- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; + relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), + then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`). ### Commands diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 0a30eaeb6..f73da6888 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -47,6 +47,9 @@ line yourself; gstack never writes the plan. with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a generation is published at rest, after each bound voice and after each phase transition; restore with `resume --out --from /`, which never redispatches a bound voice. +- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; + relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), + then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`). ### Commands diff --git a/bin/gstack-approvals b/bin/gstack-approvals new file mode 100755 index 000000000..e099e59d9 --- /dev/null +++ b/bin/gstack-approvals @@ -0,0 +1,117 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md). + * + * gstack-approvals add --ask --words --by --scope + * [--pending ] [--outcome approved|rejected|pending|standing] + * [--date YYYY-MM-DD] [--source ] [--branch ] [--json] + * gstack-approvals import --autoplan [--by ] [--branch ] [--json] + * gstack-approvals render [--no-heading] [--branch ] + * gstack-approvals list [--json] [--branch ] + * gstack-approvals sync [--branch ] + * gstack-approvals path [--branch ] + * + * Rows are kept per branch under the state root and, on Capy, mirrored to the + * private Drive folder. Every field is secret-scanned before it is written. + * `render` prints the `## Approvals` section /ship puts in the PR body. Logic: + * lib/approval-record.ts. + */ +import * as path from 'node:path'; +import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; +import { appendRows, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record'; +import { resultLine } from '../lib/result-codes'; + +const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md) + + add --ask --words --by --scope + [--pending ] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source ] + import --autoplan [--by ] + render [--no-heading] the ## Approvals section for a PR body or report + list [--json] + sync re-mirror the record (Capy Drive or GSTACK_APPROVALS_DIR) + path print the record and mirror paths + Every command takes [--branch ] (default: the checked-out branch). + +Example: + gstack-approvals add --ask "Approve the plan for the Capy approvals PR" --words "rec" \\ + --by "Garry (via GStack Minder)" --scope "the reviewed plan, every recommendation" + +${exitTableHelp()}`; + +const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan']); + +function parse(args: string[]): { flags: Record; bools: Set; error?: string } { + const flags: Record = {}; + const bools = new Set(); + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (VALUE_FLAGS.has(a)) { + const v = args[i + 1]; + if (v === undefined) return { flags, bools, error: `${a} needs a value` }; + flags[a] = v; i++; + } else if (a === '--json' || a === '--no-heading') bools.add(a); + else return { flags, bools, error: `unknown argument ${a}` }; + } + return { flags, bools }; +} + +function fail(cmd: string, e: RecordError): number { + process.stderr.write(resultLine(`gstack-approvals ${cmd}`, e.code, e.message) + '\n'); + if (e.code === 'APPROVAL_RECORD_INVALID') return EXIT.usage; + if (e.code === 'APPROVAL_RECORD_WRITE_FAILED' || e.code === 'APPROVAL_RECORD_UNAVAILABLE' || e.code === 'ARTIFACT_MALFORMED_JSONL') return EXIT.fail; + return EXIT.refused; +} + +function main(argv: string[]): number { + const [cmd, ...rest] = argv; + if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } + const { flags, bools, error } = parse(rest); + if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; } + const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] }); + if ('code' in loc) return fail(cmd, loc); + const json = bools.has('--json'); + const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`; + + if (cmd === 'add' || cmd === 'import') { + let rows; + if (cmd === 'add') { + const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch); + if ('code' in built) return fail(cmd, built); + rows = [built]; + } else { + if (!flags['--autoplan']) { process.stderr.write(`gstack-approvals: import needs --autoplan \n`); return EXIT.usage; } + const imported = importAutoplan(path.resolve(flags['--autoplan']), loc.branch, flags['--by']); + if ('code' in imported) return fail(cmd, imported); + rows = imported; + } + const res = appendRows(loc, rows); + if ('code' in res) return fail(cmd, res); + if (json) process.stdout.write(JSON.stringify({ added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null }) + '\n'); + else if (cmd === 'add') process.stdout.write(`APPROVAL_RECORDED: ${rows[0]!.id}${res.skipped ? ' (already present)' : ''} ${recorded(loc)}\n`); + else process.stdout.write(`APPROVAL_IMPORTED: n=${res.added.length} skipped=${res.skipped} ${recorded(loc)}\n`); + if (res.mirrorError) return fail(cmd, res.mirrorError); + return EXIT.ok; + } + if (cmd === 'render' || cmd === 'list') { + const read = readRecord(loc); + if (read.restored) process.stderr.write(`gstack-approvals: read the Drive mirror ${loc.mirror} (no local copy on this machine; gstack-approvals sync restores it)\n`); + if (cmd === 'list' && json) process.stdout.write(JSON.stringify({ branch: loc.branch, state: read.state, rows: read.rows }) + '\n'); + else if (cmd === 'list') for (const r of read.rows) process.stdout.write(`${r.date} ${r.id} ${r.outcome} ${JSON.stringify(r.ask)} words=${JSON.stringify(r.words)} by=${JSON.stringify(r.by)}\n`); + else process.stdout.write(renderRecord(read, loc, { heading: !bools.has('--no-heading') })); + if (read.state === 'unavailable') return fail(cmd, { code: 'APPROVAL_RECORD_UNAVAILABLE', message: `no record at ${loc.file} and no mirror` }); + return EXIT.ok; + } + if (cmd === 'sync') { + const s = syncRecord(loc); + process.stdout.write(`APPROVAL_SYNCED: rows=${s.rows} ${recorded(loc)}\n`); + return s.error ? fail(cmd, s.error) : EXIT.ok; + } + if (cmd === 'path') { + process.stdout.write(`RECORD: ${loc.file}\nMIRROR: ${loc.mirror ?? 'none'}\nBRANCH: ${loc.branch}\n`); + return EXIT.ok; + } + process.stderr.write(`gstack-approvals: unknown command ${cmd}\n${HELP}\n`); + return EXIT.usage; +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 23dba016a..07e74e3a5 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -36,6 +36,7 @@ import { ANSWERED_BY_RE } from '../lib/gate-list'; import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; import { exportRun } from '../lib/autoplan-export'; +import { capyRelayLine } from '../lib/captain-brief'; import { exportPublic } from '../lib/autoplan-public'; import { migrateRun } from '../lib/autoplan-migrate'; import { reviseRun } from '../lib/autoplan-revise'; @@ -184,13 +185,19 @@ function doNext(out: string, rest: string[], json: boolean): number { moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined; const status = r.state.status; const lines = [...initLines, ...r.lines]; - if (status === 'gate_pending') lines.push(`GATE: gate_rev=${r.state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`); + if (status === 'gate_pending') lines.push(...gateLines(r.state.gate_rev, out)); if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`); lines.push(resultLineFor(status, out)); emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, out }); return EXIT.ok; } +/** The gate pointer, plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */ +function gateLines(gateRev: number | undefined, out: string): string[] { + const relay = capyRelayLine(out); + return [`GATE: gate_rev=${gateRev} render with: gstack-gate render ${path.join(out, 'gate.json')}`, ...(relay ? [relay] : [])]; +} + function doSubmit(out: string, rest: string[], json: boolean): number { const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const record = flag(rest, '--record'); const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); const unavailable = flag(rest, '--unavailable'); const failed = flag(rest, '--failed'); @@ -204,7 +211,7 @@ function doSubmit(out: string, rest: string[], json: boolean): number { unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() }); const status = r.state.status; const lines = [...r.lines]; - if (status === 'gate_pending') lines.push(`GATE: gate_rev=${r.state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`); + if (status === 'gate_pending') lines.push(...gateLines(r.state.gate_rev, out)); if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`); lines.push(resultLineFor(status, out)); emit(json, lines, { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out }); @@ -235,7 +242,7 @@ function doClose(out: string, rest: string[], json: boolean): number { const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' }); moment = 'transition'; const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])]; - if (!currentPhase(state)) { finishGate(state); lines.push(`GATE: gate_rev=${state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`); } + if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state.gate_rev, out)); } else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); } writeState(state); lines.push(resultLineFor(state.status, out)); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 351539bb8..9d37b08e6 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -286,6 +286,17 @@ gstack_result_anchor() { STATE_MATRIX_INCOMPLETE) printf '%s' 'state-matrix-incomplete' ;; WTREE_UNAVAILABLE) printf '%s' 'wtree-unavailable' ;; REGEN_DEPS_MISSING) printf '%s' 'regen-deps-missing' ;; + APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;; + APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;; + APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;; + APPROVAL_RECORD_UNAVAILABLE) printf '%s' 'approval-record-unavailable' ;; + APPROVAL_RECORD_WRITE_FAILED) printf '%s' 'approval-record-write-failed' ;; + APPROVAL_IMPORT_UNANSWERED) printf '%s' 'approval-import-unanswered' ;; + CAPTAIN_BRIEF_INCOMPLETE) printf '%s' 'captain-brief-incomplete' ;; + PR_SHOTS_INPUT_NOT_FOUND) printf '%s' 'pr-shots-input-not-found' ;; + PR_SHOTS_INPUT_REFUSED) printf '%s' 'pr-shots-input-refused' ;; + PR_SHOTS_IDENTICAL) printf '%s' 'pr-shots-identical' ;; + PR_SHOTS_RENDER_FAILED) printf '%s' 'pr-shots-render-failed' ;; *) return 1 ;; esac } diff --git a/bin/gstack-owner-brief b/bin/gstack-owner-brief index e96b0d7ca..a6468e875 100755 --- a/bin/gstack-owner-brief +++ b/bin/gstack-owner-brief @@ -3,6 +3,7 @@ * gstack-owner-brief — the one-page owner brief at the autoplan gate (plan E3). * * gstack-owner-brief render --out [--page N] [--summary ] [--write] [--json] + * gstack-owner-brief render --captain --out [--allow-incomplete] [--write] [--json] * gstack-owner-brief close --out --plan [--date YYYY-MM-DD] [--json] * * `render` prints the plain-language summary (--summary , else /summary.md @@ -10,6 +11,10 @@ * decisions (gate list, seven per page, stable ids; auto items show the default * taken if unanswered, approval items stay pending) and points at review-record.md * for the full review; --write also saves brief.md in the run directory. + * `render --captain` prints the plan ask in the shape Capy Captains relay (lib/captain-brief.ts): header with + * the one-word reply, Before → after, Why it matters, What caused this work, Big decisions, What we need from + * you, and one trailing line for the relaying agent; a plan missing a section exits 1 + * (CAPTAIN_BRIEF_INCOMPLETE) unless --allow-incomplete. --write saves captain-brief.md. * `close` rewrites every `draft direction stands until the owner decides` line in * the plan to the decided option and date; pending lines stay and are reported * (DRAFT_DIRECTION_UNRESOLVED, exit 1). Logic: lib/owner-brief.ts. @@ -17,12 +22,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; +import { renderCaptainBrief } from '../lib/captain-brief'; import { BRIEF_FILE, closeDraftDirectionsFile, loadBriefInputs, renderBrief } from '../lib/owner-brief'; import { resultLine } from '../lib/result-codes'; const HELP = `gstack-owner-brief — one page for the owner: summary, URGENT block, numbered decisions (docs/unattended.md#owner-brief) render --out [--page N] [--summary ] [--write] [--json] + render --captain --out [--allow-incomplete] [--write] [--json] the plan ask for a Capy Captain (capy/skill/gstack/captain.md) close --out --plan [--date YYYY-MM-DD] [--json] ${exitTableHelp()}`; @@ -41,6 +48,15 @@ function main(argv: string[]): number { if ('error' in loaded) { process.stderr.write(resultLine(`gstack-owner-brief ${cmd}`, loaded.error.code, loaded.error.message) + '\n'); return loaded.error.code === 'BRIEF_INPUT_MISSING' ? EXIT.refused : EXIT.fail; } const json = rest.includes('--json'); + if (cmd === 'render' && rest.includes('--captain')) { + const brief = renderCaptainBrief(loaded.inputs, { outDir: path.resolve(out) }); + if (rest.includes('--write')) fs.writeFileSync(path.join(path.resolve(out), 'captain-brief.md'), brief.text); + if (json) process.stdout.write(JSON.stringify({ run: loaded.inputs.gate.run, gate_rev: loaded.inputs.gate.gate_rev, rec_hash: brief.recHash, pending: brief.pending, missing: brief.missing, brief: brief.text }) + '\n'); + else process.stdout.write(brief.text); + if (!brief.missing.length || !brief.pending) return EXIT.ok; + process.stderr.write(resultLine('gstack-owner-brief render --captain', 'CAPTAIN_BRIEF_INCOMPLETE', `missing: ${brief.missing.join(', ')}`) + '\n'); + return rest.includes('--allow-incomplete') ? EXIT.ok : EXIT.fail; + } if (cmd === 'render') { const pageText = flag(rest, '--page'); if (pageText !== undefined && !/^[0-9]+$/.test(pageText)) { process.stderr.write('gstack-owner-brief: --page needs a number\n'); return EXIT.usage; } diff --git a/bin/gstack-pr-shots b/bin/gstack-pr-shots new file mode 100755 index 000000000..a41052903 --- /dev/null +++ b/bin/gstack-pr-shots @@ -0,0 +1,113 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-pr-shots — a before/after image of a change, for a PR body (/ship's + * Capy section, capy/skill/gstack/captain.md). + * + * gstack-pr-shots --caption "" --path [--heading "
"] [--base ] + * gstack-pr-shots --caption "" --before --after [--kind terminal|prompt] + * [--title ] [--name ] [--out ] [--json] + * + * Writes shot.html (stacked Before/After, changed hunks only, −/+ gutter), text.md (the collapsed text + * version for the PR body) and, when a browser is available, pair.png; prints SHOT_HTML:, SHOT_TEXT: and + * SHOT_PNG: (a path, or `unavailable ()` with exit 0 so the caller screenshots shot.html itself). + * Inputs are read as data and secret-scanned before rendering. Logic: lib/pr-shots.ts. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { slugFromEnvironment } from '../lib/bin-context'; +import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; +import { pickEngine, render } from '../lib/aside-render'; +import { buildPage, defaultBase, pathSides, readCapture, resolveCommit, scanSides, type ShotError, type ShotKind, type Side } from '../lib/pr-shots'; +import { resultLine } from '../lib/result-codes'; +import { resolveStateRoot } from '../lib/state-root'; + +const HELP = `gstack-pr-shots — before/after image of a change for a PR body (capy/skill/gstack/captain.md) + + --caption required: what changed and why (shown under the title and in the text version) + --path a tracked file: base version vs working tree (source text, nothing executed) + [--heading ] only that Markdown section (fence-aware, same parser as the Captain brief) + [--base <ref>] default: the merge base with origin/HEAD + --before <file> --after <file> two captures (terminal output, a rendered prompt) inside the repo or state root + [--kind terminal|prompt] pane styling (default prompt; terminal is a dark panel) + [--title <text>] [--name <slug>] [--out <dir>] [--json] + +Example: + gstack-pr-shots --path ship/SKILL.md --heading "Step 19: Create PR/MR" --caption "PR body opens with What changed, Why and Approvals under Capy" + +${exitTableHelp()}`; + +const VALUE = new Set(['--caption', '--path', '--heading', '--base', '--before', '--after', '--kind', '--title', '--name', '--out']); + +function fail(e: ShotError): number { + process.stderr.write(resultLine('gstack-pr-shots', e.code, e.message) + '\n'); + return e.code === 'PR_SHOTS_IDENTICAL' || e.code === 'PR_SHOTS_RENDER_FAILED' ? EXIT.fail : EXIT.refused; +} + +async function main(argv: string[]): Promise<number> { + if (!argv.length || argv[0] === '--help' || argv[0] === '-h') { process.stdout.write(HELP + '\n'); return argv.length ? EXIT.ok : EXIT.usage; } + const f: Record<string, string> = {}; let json = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === '--json') { json = true; continue; } + if (!VALUE.has(a) || argv[i + 1] === undefined) { process.stderr.write(`gstack-pr-shots: ${VALUE.has(a) ? `${a} needs a value` : `unknown argument ${a}`}\n${HELP}\n`); return EXIT.usage; } + f[a] = argv[++i]!; + } + const usage = (m: string) => { process.stderr.write(`gstack-pr-shots: ${m}\n`); return EXIT.usage; }; + if (!f['--caption']?.trim()) return usage('--caption "<what changed and why>" is required'); + const byPath = f['--path'] !== undefined; const byFiles = f['--before'] !== undefined || f['--after'] !== undefined; + if (byPath === byFiles) return usage('give either --path <file> or both --before <file> --after <file>'); + if (byFiles && (!f['--before'] || !f['--after'])) return usage('--before and --after go together'); + if (byFiles && (f['--heading'] || f['--base'])) return usage('--heading and --base apply to --path only'); + const kind = (f['--kind'] ?? 'prompt') as ShotKind; + if (kind !== 'terminal' && kind !== 'prompt') return usage('--kind must be terminal or prompt'); + + const cwd = process.cwd(); + const stateRoot = resolveStateRoot(process.env); + const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' }); + const repo = top.status === 0 ? top.stdout.trim() : cwd; + let before: Side; let after: Side; + if (byPath) { + const base = f['--base'] ? resolveCommit(repo, f['--base']) : defaultBase(repo); + if (typeof base !== 'string') return fail(base); + const sides = pathSides(cwd, f['--path']!, base, f['--heading']); + if ('code' in sides) return fail(sides); + ({ before, after } = sides); + } else { + const b = readCapture(path.resolve(f['--before']!), [repo, stateRoot], 'before'); + if (typeof b !== 'string') return fail(b); + const a = readCapture(path.resolve(f['--after']!), [repo, stateRoot], 'after'); + if (typeof a !== 'string') return fail(a); + const what = kind === 'terminal' ? 'captured output' : 'captured text'; + before = { text: b, label: `Before · ${path.basename(f['--before']!)} · ${what}` }; + after = { text: a, label: `After · ${path.basename(f['--after']!)} · ${what}` }; + } + const title = f['--title'] ?? (byPath ? (f['--heading'] ? `${f['--path']} § ${f['--heading']}` : f['--path']!) : 'Before and after'); + const refused = scanSides([['before', before.text], ['after', after.text], ['caption', f['--caption']!], ['title', title]]); + if (refused) return fail(refused); + const page = buildPage({ before, after, title, caption: f['--caption']!.trim(), kind }); + if ('code' in page) return fail(page); + + const name = (f['--name'] ?? title).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '').slice(0, 60) || 'shot'; + const out = path.resolve(f['--out'] ?? path.join(stateRoot, 'projects', slugFromEnvironment(stateRoot, cwd) || 'unknown', 'pr-shots', name)); + fs.mkdirSync(out, { recursive: true, mode: 0o700 }); + for (const old of ['shot.html', 'text.md', 'pair.png']) fs.rmSync(path.join(out, old), { force: true }); + const html = path.join(out, 'shot.html'); const text = path.join(out, 'text.md'); const png = path.join(out, 'pair.png'); + fs.writeFileSync(html, page.html, { mode: 0o600 }); + fs.writeFileSync(text, page.text, { mode: 0o600 }); + const lines = [`SHOT_HTML: ${html}`, `SHOT_TEXT: ${text}`]; + let pngLine: string; let code = EXIT.ok; let pngPath: string | null = null; + const engine = pickEngine(); + if (!engine.engine) pngLine = `SHOT_PNG: unavailable (${engine.error}); screenshot ${html} at 880px wide with your own browser, or use ${text}`; + else { + const r = await render({ file: html, serveRoot: out, waitFor: { selector: '#shot' }, steps: [{ kind: 'screenshot', out: png, selector: '#shot', width: 912, deviceScaleFactor: 2 }] }); + if (r.ok && fs.existsSync(png)) { pngLine = `SHOT_PNG: ${png} engine=${r.engine}`; pngPath = png; } + else { pngLine = `SHOT_PNG: failed (${(r.error ?? 'no output').split('\n')[0]})`; code = fail({ code: 'PR_SHOTS_RENDER_FAILED', message: (r.error ?? 'no output').split('\n')[0]! }); } + } + lines.push(pngLine, `SHOT_CHANGED: ${page.changed} line` + (page.changed === 1 ? '' : 's')); + if (json) process.stdout.write(JSON.stringify({ html, text, png: pngPath, changed: page.changed, caption: f['--caption']!.trim() }) + '\n'); + else process.stdout.write(lines.join('\n') + '\n'); + return code; +} + +main(process.argv.slice(2)).then(c => { process.exitCode = c; }); diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index da9a7b26f..6592d5746 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -148,6 +148,13 @@ eval "$("$_BIN/gstack-repo-mode" 2>/dev/null)" 2>/dev/null || true REPO_MODE=${REPO_MODE:-unknown} echo "REPO_MODE: $REPO_MODE" echo "SESSION_KIND: $_SESSION_KIND$_SESSION_KIND_NOTE" +# Capy (capy/skill/gstack/captain.md): GSTACK_CAPY=1 comes from the Capy launch +# line. On, every decision goes through Capy (Captain or person) and the +# capy-asks block below says how; GSTACK_CAPY_ASKS=0 keeps the line but drops +# the block. +_CAPY=off +[ "${GSTACK_CAPY:-}" = "1" ] && _CAPY=on +if [ "$_CAPY" = "on" ] && [ "${GSTACK_CAPY_ASKS:-}" = "0" ]; then echo "CAPY: on (asks block off: GSTACK_CAPY_ASKS=0)"; else echo "CAPY: $_CAPY"; fi echo "STATE_ROOT: $_GH durable=$_DURABLE" echo "SESSIONS: ${_SESSIONS:-0}" # Install admission (field wave A2): an unattended or Capy-spawned run on an @@ -639,6 +646,17 @@ You are running unattended: a parent agent started this run and will read its ar EOI fi +# Capy asks (capy/skill/gstack/captain.md): who to ask and in what shape when +# gstack runs under Capy. Printed for every session kind, after the unattended +# block, so the pending gate items that block writes reach a person through +# the right channel. Generic policy only: a Captain's own standing approvals +# live in its rules, never here. +if [ "$_CAPY" = "on" ] && [ "${GSTACK_CAPY_ASKS:-}" != "0" ]; then + _emit_block capy-asks <<EOI +You run under Capy. Every decision this workflow cannot take itself (a pending gate item, a consent, an unrecommended question, a plan approval) goes through Capy's messaging, never as a bare question or gate codes: in a crew thread, a needs-you report to your Captain; with no Captain, a Capy question to the person. Write it in plain language: what it is, why it matters, what each answer does and costs, your pick, and the one-word reply. A plan approval is rendered with \`gstack-owner-brief render --captain --out <run dir>\` (before -> after, why it matters, what caused this work, big decisions, what we need) and relayed whole; fix the plan first if it exits CAPTAIN_BRIEF_INCOMPLETE. Record every answer with the person's exact words: \`gstack-approvals add\` (or \`gstack-approvals import --autoplan <run dir>\` after an autoplan answer). Cite standing approvals your Captain lists instead of asking again; always ask fresh for a product or behaviour decision, spend over a stated cap, anything published outside the repository, a threshold, budget or trial-count change, closing someone's contribution without landing it, and anything destructive. A pull request opened by /ship follows its Capy section (What changed with before/after images, Why, Approvals). Guide: capy/skill/gstack/captain.md in the gstack checkout. +EOI +fi + # Spawned-session rules (NOT one-time — every spawned session gets the full # behavioral instruction, per plan OV6 move-with-care). Keyed on the resolved # kind, not raw OPENCLAW_SESSION (#2733): the GSTACK_SESSION_KIND=spawned diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index b2c0852d7..9d3e0f522 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -40,6 +40,17 @@ For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render <dir>/gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent:<id> --owner-reply "<their words>"`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out <new dir> --from <that dir>` restores the newest valid checkpoint. +## Working under a Captain + +When you work for a Capy Captain (a crew thread) or straight for a person, gstack's decisions reach them through you. Every workflow started with the `GSTACK_LAUNCH` line prints `CAPY: on` and a `capy-asks` block that repeats these rules. The full guide, with worked examples and copy-paste templates, is [captain.md](captain.md) in this folder. + +- **How approvals flow.** A crew thread never asks the person directly. It sends the ask to its Captain as a needs-you report, and the Captain asks the person in plain words and brings back their exact reply. A thread with no Captain asks the person with a Capy question. An answer covers only the thread and scope it was asked for. +- **Standing approvals need no ask.** These are the ones your Captain lists in its rules (for example "merge when green, through a train"). Cite the rule instead of asking again. +- **These always need a fresh ask:** a product or behaviour decision, spend over a stated cap, anything published outside the repository, a change to a threshold, budget or trial count, closing someone's contribution without landing it, and anything destructive. +- **Record every answer.** Use `gstack-approvals add --ask … --words "<their exact words>" --by … --scope …`, or after an autoplan answer, `gstack-approvals import --autoplan <run dir> --by <name>`. `gstack-approvals render` prints the `## Approvals` table for your report and the PR body. Rows are secret-scanned, and on Capy they are mirrored to the private Drive folder. +- **Plan approvals** use `gstack-owner-brief render --captain --out <run dir>`. It prints before → after, why it matters, what caused this work, the big decisions (options, the pick, why, what would flip it) and what we need, with `rec` as the recommended one-word reply. Relay it whole. Gate codes appear only on its last line, which is for you. If it exits `CAPTAIN_BRIEF_INCOMPLETE`, add the missing plan sections first. +- **Merge asks.** /ship's PR body opens with `## What changed` (captioned before/after images from `gstack-pr-shots` or a UI screenshot, uploaded with Capy's PR asset tool), `## Why` and `## Approvals`. Your READY report attaches the key image. + ## Before any push or pull request In a repository with a sealed configuration (a `sealed` key in its `.gstack/pregate.json`), run `~/.local/share/gstack/bin/gstack-pregate sealed` before every push and before creating a pull request: Capy's pull-request tool may not run local git hooks, so this is the check that keeps sealed or eval output out of a public repository. A refusal names the file, line and rule; do not push until it passes. diff --git a/capy/skill/gstack/captain.md b/capy/skill/gstack/captain.md new file mode 100644 index 000000000..15e4beefa --- /dev/null +++ b/capy/skill/gstack/captain.md @@ -0,0 +1,121 @@ +# Working under a Captain: approvals, plan asks and merge asks + +This file belongs to gstack's Capy skill (the folder the owner copies into their Drive). It describes how a +Capy thread running gstack asks for decisions, records answers and asks for a merge. The rules apply whether +the thread reports to a Captain (a crew thread) or straight to a person. `gstack-skill-start` prints the +short form as the `capy-asks` block whenever `GSTACK_CAPY=1` (the Capy launch line sets it). +`GSTACK_CAPY_ASKS=0` drops that block for one session. + +## 1. How approvals work, per thread + +- **One path.** A crew thread asks its Captain: a needs-you report through Capy's agent channel, never a + question to the person. The Captain relays it in plain language and sends back the person's exact words. + A thread with no Captain asks the person directly with a Capy question. Either way, never use a bare + question, gate codes or slot numbers. +- **Scope.** An answer covers the thread and scope it was asked for. It is not a blanket approval for + later work. +- **Standing approvals** are the ones the Captain lists in its own rules, for example "merge when green, + through a merge train", "paid eval gates are pre-approved" or "a single-case flaky rerun". Cite the rule + (with its date and words) instead of asking again. gstack ships no standing approvals of its own. +- **Always a fresh ask:** + - a product or behaviour decision; + - spend over a stated cap; + - anything published outside the repository (advisories, releases, posts); + - a change to a threshold, budget, trial count or test kind; + - closing someone's contribution without landing it; + - anything destructive or irreversible. +- **What a gstack workflow does in Capy.** Launched with `GSTACK_SESSION_KIND=unattended`, a workflow takes + each recommended option, writes everything else as a pending gate item, and stops. Your job is to carry + those pending items to the Captain or person in the shape below, then feed the answer back. + +## 2. The approval record + +Every answer gets a row: the date, what was asked, the person's exact words, who said them (and who relayed +them), what the answer covers, what is still pending, and the outcome (approved, rejected, pending, or a +cited standing approval). + +```bash +gstack-approvals add --ask "Spend up to \$40 on a paid eval rerun for the ship cases" \ + --words "yes go" --by "Garry (via GStack Minder)" --scope "one rerun of the 6 ship cases, cap \$40" +gstack-approvals add --outcome standing --ask "Merge #3330 when green" \ + --words "yes merge when green" --by "Garry, 2026-10-16 (Captain rule)" --scope "every crew PR, through a train" +gstack-approvals import --autoplan <run dir> --by Garry # after gstack-autoplan answer; idempotent +gstack-approvals render # the ## Approvals section +``` + +- **Where it lives.** `<state root>/projects/<slug>/approvals/<branch key>.jsonl`, one file per branch, + because one thread ships one branch. On Capy the state root is thrown away with the machine, so every write + is also mirrored to the private project-user Drive folder (`<drive>/approvals/<slug>/`), or to + `GSTACK_APPROVALS_DIR`. `GSTACK_APPROVALS_MIRROR=0` turns the mirror off. Reads merge both copies, and + `gstack-approvals sync` restores the local copy. +- **The durable copies** are the `## Approvals` sections in the thread's report and in the PR body. Paste + `render` output into them; never type rows by hand. +- **Secret-free.** Every field passes gstack's secret scanner before it is written. A hit refuses the row + (`APPROVAL_RECORD_SECRET`, which names the field and rule, never the text). Write `[redacted]` in place of + the secret, or ask the person to restate. +- **Display only.** The rendered table escapes mentions, links, images, HTML and anything shaped like an + `Approved by … via …` citation. gstack's enforced approval classes still need their own citation line + (docs/ship-policy.md#approval-classes). +- An empty table means "No approvals recorded". On a fresh Capy machine with no record and no mirror it says + "Record unavailable" instead (`APPROVAL_RECORD_UNAVAILABLE`), because a lost record is not an empty one. + +## 3. Plan approvals: the shape + +Every plan or gate you bring for approval has these parts, in this order, written so someone outside the +project can follow it: + +1. **Before → after.** What the user or maintainer sees today and after, with concrete examples: a command + and its output, a screen, a cost or time number. +2. **Why it matters.** Who is hurt today and how much, with numbers where possible. +3. **What caused this work.** The trigger: an issue or PR link, a failed run, a user report, or the + person's request, quoted. +4. **Big decisions.** For each: the options considered, the pick and why, and what would flip it. +5. **What we need from you.** One decision, the options and their costs, the pick, and the one-word reply. + The recommended reply is the default (`rec` for an autoplan gate). Never offer "the plan as first + written" as the default. + +For an autoplan run, write parts 1 to 4 as `## Before → after`, `## Why it matters`, +`## What caused this work` and `## Big decisions` sections in the plan. Then, at the gate: + +```bash +gstack-owner-brief render --captain --out <run dir> +``` + +It prints a header line ("Decision needed … Reply **rec** …"), the four plan sections (each capped, with a +pointer to the plan for the rest), and "What we need from you", which lists every choice `rec` takes, in +words. The last line, "For the relaying agent", carries the run, `gate_rev`, `rec_hash` and the exact +`gstack-autoplan answer` command. Relay everything above that line. When the reply comes back: + +```bash +gstack-autoplan answer --out <run dir> --gate-rev <n> --reply rec --rec-hash <h> \ + --by agent:<your thread> --owner-reply "<their exact words>" +gstack-approvals import --autoplan <run dir> --by <name> +``` + +If the person answers in words ("split it into two PRs"), map the words to tokens, answer, and confirm in one +line what you recorded ("Recorded: split into two PRs; everything else as recommended"). A plan missing a +section renders "Not ready for approval" with no reply invitation, and exits 1 (`CAPTAIN_BRIEF_INCOMPLETE`): +add the sections, run `gstack-autoplan revise --plan <file>`, and render again. For a decision that is not an +autoplan gate, write the same five parts by hand. + +## 4. Merge asks: the shape + +Every PR you send READY on, and the READY report itself, has: + +1. **What changed.** One to three before/after images, highest-signal first, each with a one-line caption + (what changed and why) and a collapsed text version under it. + - UI: screenshots at the base and on the branch, the same size. + - Skill or prompt: `gstack-pr-shots --path <file> --heading "<section>" --caption "<…>"`. + - CLI: `gstack-pr-shots --before <capture> --after <capture> --kind terminal --caption "<…>"`, where the + captures are files inside the repository. + - Internal: the source section or test output that best shows the change. + `gstack-pr-shots` reads text only, runs nothing, refuses secret-matching input, and prints `SHOT_PNG:`. + With no browser that line says `unavailable`: screenshot the printed `shot.html` with your own browser. + Upload each PNG with Capy's PR asset tool (`tools.pr.uploadAsset`), only for public repositories, and embed + the URL it returns with the caption as alt text. +2. **Why.** The cause and the effect the user will see, in two or three sentences. +3. **Approvals.** `gstack-approvals render`. +4. **Evidence.** Checks green at the head, gate or train results, flaky cases. + +The READY report attaches the key image (Capy message attachment), so the Captain can show it inline. +/ship does all of this when `GSTACK_CAPY=1` (its `capy-pr` section). Outside Capy, the PR body is unchanged. diff --git a/docs/capy-install.md b/docs/capy-install.md index 27b7e8ecf..fb9e865cb 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -381,6 +381,17 @@ the baked `~/.bun/bin` reaches workflows through the `GSTACK_LAUNCH` line. The project Setup is shared by every thread in the project, so changing it is the project's decision. +## Working under a Captain + +In a Capy session (`GSTACK_CAPY=1`, set by the launch line) every workflow +prints `CAPY: on` and a `capy-asks` block: decisions go through Capy (a crew +thread asks its Captain, otherwise the person), plan approvals are rendered +with `gstack-owner-brief render --captain`, answers are recorded with +`gstack-approvals`, and /ship's PR body opens with before/after images +(`gstack-pr-shots`), Why and Approvals. The guide is +[`capy/skill/gstack/captain.md`](../capy/skill/gstack/captain.md); +`GSTACK_CAPY_ASKS=0` drops the block for one session. + ## Raising the supported minimum `capy/support.env` holds `GSTACK_MIN_REVISION` and `GSTACK_CAPY_MIN_WRAPPER`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7e7ce4741..027d071eb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1618,6 +1618,116 @@ and launch with the `GSTACK_LAUNCH` line it prints. --- +## Capy: approvals, Captain briefs and PR screenshots + +These come from the commands gstack's Capy adaptations add: `gstack-approvals` (the +per-thread approval record), `gstack-owner-brief render --captain` (the plan ask in +the before/after shape) and `gstack-pr-shots` (before/after images for a PR body). +The guide is `capy/skill/gstack/captain.md`. + +<a id="approval-record-invalid"></a> +### `gstack-approvals: ... (APPROVAL_RECORD_INVALID)` + +**Meaning.** A row is missing a required field (`--ask`, `--words`, `--scope`, +`--by`), holds a value outside its enum (`--outcome approved|rejected|pending|standing`, +a `--date` that is not a real `YYYY-MM-DD` day) or exceeds a length cap (ask, scope and +pending 300 characters, words 2,000). Exit 2. Nothing was written. + +**Fix.** Rerun with every required flag inside the caps the line names. + +<a id="approval-record-secret"></a> +### `gstack-approvals: ... (APPROVAL_RECORD_SECRET)` + +**Meaning.** A field matched gstack's secret scanner (the same one the push guard +uses). The line names the field and the detector rule, never the matched text. Exit 3. +Nothing was written, locally or to the Drive mirror. + +**Fix.** Record the words with the secret replaced by `[redacted]`, or ask the person to +restate them. Rotate the secret if it was real. + +<a id="approval-record-no-branch"></a> +### `gstack-approvals: ... (APPROVAL_RECORD_NO_BRANCH)` + +**Meaning.** The record is kept per branch (one Capy thread ships one branch), and +HEAD is detached or the working directory is not a git repository. Exit 3. + +**Fix.** Check out the branch the work ships from, or pass `--branch <name>`. + +<a id="approval-record-unavailable"></a> +### `gstack-approvals: ... (APPROVAL_RECORD_UNAVAILABLE)` + +**Meaning.** There is no record on this machine, the state root is ephemeral (a Capy +machine), and no Drive mirror could be resolved, so "no approvals" cannot be told apart +from "the record was lost with the old machine". `render` still prints the section, with +"Record unavailable on this machine" instead of "No approvals recorded". Exit 1. + +**Fix.** Run where the record lives, copy the rows back from the thread's report, or make +the private Drive mirror resolvable (one `~/.capy/drive/project-user-*` folder, or +`GSTACK_APPROVALS_DIR`). + +<a id="approval-record-write-failed"></a> +### `gstack-approvals: ... (APPROVAL_RECORD_WRITE_FAILED)` + +**Meaning.** The local record or its Drive mirror could not be written; the line names +the path and the error. A failed local append wrote nothing. A failed mirror write +leaves the local row in place. Exit 1. + +**Fix.** Make the named directory writable, then rerun `gstack-approvals sync` to mirror again. + +<a id="approval-import-unanswered"></a> +### `gstack-approvals import: ... (APPROVAL_IMPORT_UNANSWERED)` + +**Meaning.** The autoplan run has no answered gate (every decision is still pending), +so there is nothing to import. Exit 3. + +**Fix.** Answer the gate (`gstack-autoplan answer ... --by human|agent:<id>`), then +import again. Imports are idempotent, so a retry never records an answer twice. + +<a id="captain-brief-incomplete"></a> +### `gstack-owner-brief render --captain: ... (CAPTAIN_BRIEF_INCOMPLETE)` + +**Meaning.** The plan is missing at least one section the brief needs: `## Before → after`, +`## Why it matters`, `## What caused this work`, `## Big decisions`. The brief still prints, +but it opens with "Not ready for approval" and has no reply invitation. Exit 1. + +**Fix.** Add the named sections to the plan (`gstack-autoplan revise --plan <file>` if +the run is reviewing it) and render again. `--allow-incomplete` exits 0 for a preview, +but the text still says it is not ready. + +<a id="pr-shots-input-not-found"></a> +### `gstack-pr-shots: ... (PR_SHOTS_INPUT_NOT_FOUND)` + +**Meaning.** An input is missing: a `--before/--after` file, the `--base` ref, the +`--path` at both the base and the working tree, or the `--heading` on both sides. Exit 3. + +**Fix.** Check the ref, path or heading named on the line. + +<a id="pr-shots-input-refused"></a> +### `gstack-pr-shots: ... (PR_SHOTS_INPUT_REFUSED)` + +**Meaning.** An input was refused before anything was rendered, because PR images +are public. It matched the secret scanner (the line names the side, line and rule), +is binary, exceeds 512 KiB, lies outside the repository and the state root, or names a +ref starting with `-`. Exit 3. + +**Fix.** Take the secret out of the capture, or picture a tracked text file. + +<a id="pr-shots-identical"></a> +### `gstack-pr-shots: ... (PR_SHOTS_IDENTICAL)` + +**Meaning.** Before and after are the same text, so there is no change to picture. Exit 1. + +**Fix.** Picture a file or section this branch changed (`git diff --stat origin/<base>...HEAD`). + +<a id="pr-shots-render-failed"></a> +### `gstack-pr-shots: ... (PR_SHOTS_RENDER_FAILED)` + +**Meaning.** A browser was available but rendering `shot.html` failed; the line names the +renderer's error. `shot.html` and `text.md` are still written. Exit 1. With no browser at +all the command prints `SHOT_PNG: unavailable (...)` and exits 0 instead. + +**Fix.** Screenshot `shot.html` with your own browser at 880 px wide, or use `text.md`. + ## Browser <a id="browse-runtime-version-skew"></a> diff --git a/lib/approval-record.ts b/lib/approval-record.ts new file mode 100644 index 000000000..ff061a8fc --- /dev/null +++ b/lib/approval-record.ts @@ -0,0 +1,256 @@ +/** + * approval-record — the per-thread approval record gstack keeps for Capy + * Captains (capy/skill/gstack/captain.md): one row per answer, with the date, + * what was asked, the person's exact words, who said them, what the answer + * covers, what is still pending and the outcome. bin/gstack-approvals is the + * CLI; /ship pastes `render` into the PR body as `## Approvals` and a crew + * thread pastes it into its report. + * + * The record is display and audit only. It is not an approval citation + * (lib/approval-citations.ts stays the one enforced mechanism): every cell is + * escaped so the rendered table cannot be parsed as an `Approved by ...` + * citation, a mention, an image or hidden HTML. + * + * Storage: `<state root>/projects/<slug>/approvals/<branch key>.jsonl`, the + * branch key being a readable prefix plus a hash of the exact ref (no two + * branches share a file, and the key is always one contained path segment). + * On Capy (GSTACK_CAPY=1) the state root is thrown away with the machine, so + * every write is mirrored to the private project-user Drive folder + * (`<drive>/approvals/<slug>/<branch key>.jsonl`) or to GSTACK_APPROVALS_DIR; + * GSTACK_APPROVALS_MIRROR=0 turns the mirror off. Reads take the union of both. + * Every field passes the secret scanner before anything is written. + */ +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { capyDriveFolder } from './autoplan-checkpoint'; +import { reviewedPlan } from './captain-brief'; +import { slugFromEnvironment } from './bin-context'; +import { firstTitle } from './markdown-sections'; +import { scan } from './redact-engine'; +import type { ResultCodeName } from './result-codes'; +import { resolveStateRoot } from './state-root'; + +export const OUTCOMES = ['approved', 'rejected', 'pending', 'standing'] as const; +export type Outcome = typeof OUTCOMES[number]; +export const CAPS = { ask: 300, words: 2000, scope: 300, pending: 300, by: 120, source: 200 } as const; + +export interface ApprovalRow { + schema_version: 1; id: string; date: string; ask: string; words: string; by: string; + scope: string; pending: string; outcome: Outcome; source: string; branch: string; recorded_at: string; +} +export interface StoreLocation { file: string; mirror?: string; branch: string; slug: string; ephemeral: boolean } +export interface RecordError { code: ResultCodeName; message: string } +export type Env = NodeJS.ProcessEnv; + +const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }); + +/** One contained path segment for a branch: readable prefix plus a hash of the exact ref. */ +export function branchKey(branch: string): string { + const readable = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, 60) || 'branch'; + return `${readable}-${createHash('sha256').update(branch).digest('hex').slice(0, 10)}`; +} + +function contained(root: string, file: string): boolean { + const rel = path.relative(root, file); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +/** Where this branch's record lives, and its mirror. */ +export function locate(o: { cwd: string; env: Env; branch?: string }): StoreLocation | RecordError { + let branch = o.branch?.trim(); + if (!branch) { + const r = git(o.cwd, ['symbolic-ref', '--short', '-q', 'HEAD']); + branch = r.status === 0 ? r.stdout.trim() : ''; + } + if (!branch) return { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no branch at ${o.cwd} (detached HEAD or not a git repository)` }; + const stateRoot = resolveStateRoot(o.env); + const slug = slugFromEnvironment(stateRoot, o.cwd) || 'unknown'; + const dir = path.join(stateRoot, 'projects', slug, 'approvals'); + const file = path.join(dir, `${branchKey(branch)}.jsonl`); + if (!contained(stateRoot, file)) return { code: 'APPROVAL_RECORD_INVALID', message: `record path escapes the state root: ${file}` }; + let mirrorRoot: string | undefined; + if (o.env.GSTACK_APPROVALS_DIR) mirrorRoot = o.env.GSTACK_APPROVALS_DIR; + else if (o.env.GSTACK_CAPY === '1' && o.env.GSTACK_APPROVALS_MIRROR !== '0') { + const top = git(o.cwd, ['rev-parse', '--show-toplevel']); + const folder = capyDriveFolder(o.env, top.status === 0 ? path.basename(top.stdout.trim()) : undefined); + if (folder) mirrorRoot = path.join(path.dirname(folder), 'approvals'); + } + const mirror = mirrorRoot ? path.join(mirrorRoot, slug, `${branchKey(branch)}.jsonl`) : undefined; + if (mirror && !contained(mirrorRoot!, mirror)) return { code: 'APPROVAL_RECORD_INVALID', message: `mirror path escapes ${mirrorRoot}` }; + return { file, mirror, branch, slug, ephemeral: o.env.GSTACK_EPHEMERAL === '1' }; +} + +function readRows(file: string | undefined): ApprovalRow[] { + if (!file || !fs.existsSync(file)) return []; + const rows: ApprovalRow[] = []; + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + if (!line.trim()) continue; + try { const r = JSON.parse(line); if (r && r.schema_version === 1 && typeof r.id === 'string') rows.push(r as ApprovalRow); } catch { /* a torn tail line is skipped, never repaired */ } + } + return rows; +} + +export interface ReadResult { rows: ApprovalRow[]; state: 'present' | 'empty' | 'unavailable'; restored: boolean } + +/** The union of the local record and its mirror (local wins on the same id), oldest first. */ +export function readRecord(loc: StoreLocation): ReadResult { + const local = readRows(loc.file); + const mirrored = readRows(loc.mirror); + const byId = new Map<string, ApprovalRow>(); + for (const r of [...mirrored, ...local]) byId.set(r.id, r); + const rows = [...byId.values()].sort((a, b) => a.recorded_at.localeCompare(b.recorded_at)); + const known = fs.existsSync(loc.file) || (loc.mirror !== undefined && fs.existsSync(loc.mirror)); + const state = rows.length ? 'present' : known || !loc.ephemeral || loc.mirror ? 'empty' : 'unavailable'; + return { rows, state, restored: !fs.existsSync(loc.file) && mirrored.length > 0 }; +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +function validDate(d: string): boolean { + if (!DATE_RE.test(d)) return false; + const t = Date.parse(`${d}T00:00:00Z`); + return !Number.isNaN(t) && new Date(t).toISOString().slice(0, 10) === d; +} + +export interface NewRow { ask: string; words: string; by: string; scope: string; pending?: string; outcome?: string; date?: string; source?: string } + +/** Validate and secret-scan one row; the error names the field and rule, never the text. */ +export function buildRow(input: NewRow, branch: string, now = new Date()): ApprovalRow | RecordError { + const fields = { ask: input.ask, words: input.words, by: input.by, scope: input.scope, pending: input.pending ?? 'none', source: input.source ?? 'manual' }; + for (const [k, v] of Object.entries(fields)) { + if (typeof v !== 'string' || !v.trim()) return { code: 'APPROVAL_RECORD_INVALID', message: `--${k} is required` }; + const cap = CAPS[k as keyof typeof CAPS]; + if (v.length > cap) return { code: 'APPROVAL_RECORD_INVALID', message: `--${k} is ${v.length} characters (cap ${cap})` }; + } + const outcome = (input.outcome ?? 'approved') as Outcome; + if (!OUTCOMES.includes(outcome)) return { code: 'APPROVAL_RECORD_INVALID', message: `--outcome must be one of ${OUTCOMES.join('|')}` }; + const date = input.date ?? now.toISOString().slice(0, 10); + if (!validDate(date)) return { code: 'APPROVAL_RECORD_INVALID', message: `--date must be a real YYYY-MM-DD day` }; + for (const [k, v] of Object.entries(fields)) { + const s = scan(v); + const hits = s.findings.filter(f => f.severity === 'HIGH' || f.severity === 'MEDIUM'); + if (s.oversize || hits.length) return { code: 'APPROVAL_RECORD_SECRET', message: `field ${k}: ${s.oversize ? 'oversize input' : [...new Set(hits.map(f => f.id))].join(', ')}` }; + } + const id = 'a-' + createHash('sha256').update([fields.source, date, fields.ask, fields.words, fields.by].join('\u0000')).digest('hex').slice(0, 12); + return { schema_version: 1, id, date, ask: fields.ask.trim(), words: fields.words.trim(), by: fields.by.trim(), scope: fields.scope.trim(), pending: fields.pending.trim(), outcome, source: fields.source, branch, recorded_at: now.toISOString() }; +} + +function writeMirror(loc: StoreLocation, rows: ApprovalRow[]): RecordError | undefined { + if (!loc.mirror) return undefined; + try { + fs.mkdirSync(path.dirname(loc.mirror), { recursive: true, mode: 0o700 }); + const tmp = `${loc.mirror}.${process.pid}.tmp`; + fs.writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''), { mode: 0o600 }); + fs.renameSync(tmp, loc.mirror); + return undefined; + } catch (e: any) { return { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `mirror ${loc.mirror}: ${e.code ?? e.message} (the local row is kept)` }; } +} + +export interface AppendResult { added: ApprovalRow[]; skipped: number; mirrorError?: RecordError } + +/** Append rows not already present (by id) as single-line appends, then mirror the merged record. */ +export function appendRows(loc: StoreLocation, rows: ApprovalRow[]): AppendResult | RecordError { + const have = new Set(readRecord(loc).rows.map(r => r.id)); + const fresh = rows.filter(r => !have.has(r.id)); + try { + fs.mkdirSync(path.dirname(loc.file), { recursive: true, mode: 0o700 }); + for (const r of fresh) fs.appendFileSync(loc.file, JSON.stringify(r) + '\n', { encoding: 'utf8', mode: 0o600 }); + } catch (e: any) { return { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `${loc.file}: ${e.code ?? e.message}` }; } + const mirrorError = writeMirror(loc, readRecord(loc).rows); + return { added: fresh, skipped: rows.length - fresh.length, ...(mirrorError ? { mirrorError } : {}) }; +} + +/** Re-mirror the merged record (after a failed mirror write, or to restore a local copy from the mirror). */ +export function syncRecord(loc: StoreLocation): { rows: number; error?: RecordError } { + const { rows } = readRecord(loc); + try { + fs.mkdirSync(path.dirname(loc.file), { recursive: true, mode: 0o700 }); + const tmp = `${loc.file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''), { mode: 0o600 }); + fs.renameSync(tmp, loc.file); + } catch (e: any) { return { rows: rows.length, error: { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `${loc.file}: ${e.code ?? e.message}` } }; } + const error = writeMirror(loc, rows); + return { rows: rows.length, ...(error ? { error } : {}) }; +} + +/** One Markdown table cell: no row breaks, mentions, images, links, HTML, code spans or citation-shaped text. */ +export function cell(text: string, cap = 400): string { + const clipped = text.length > cap ? text.slice(0, cap - 1).trimEnd() + '…' : text; + return clipped + .replace(/\r?\n+/g, ' ') + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/@/g, '@').replace(/\`/g, '`').replace(/\[/g, '[').replace(/\]/g, ']').replace(/!/g, '!') + .replace(/\|/g, '\\|') + .replace(/(approved)(\s+)(by)/gi, '$1 $3'); +} + +export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?: boolean } = {}): string { + const head = o.heading === false ? [] : ['## Approvals', '']; + if (read.state === 'unavailable') return [...head, `Record unavailable on this machine: no approval record for branch ${cell(loc.branch)} here, and the state root is ephemeral with no Drive mirror, so this is not proof that nothing was approved.`, ''].join('\n'); + if (!read.rows.length) return [...head, `No approvals recorded for branch ${cell(loc.branch)}.`, ''].join('\n'); + const lines = ['| Date | Asked | Exact words | Outcome | Covers | Still pending |', '|---|---|---|---|---|---|']; + for (const r of read.rows) { + const outcome = r.outcome === 'standing' ? 'standing approval' : r.outcome; + lines.push(`| ${r.date} | ${cell(r.ask)} | “${cell(r.words, 280)}” (${cell(r.by)}) | ${outcome} | ${cell(r.scope)} | ${cell(r.pending)} |`); + } + return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n'); +} + +interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string } + +const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim(); + +/** + * Rows for every answered gate in an autoplan run: one row per answer event (gate_rev, time, answerer), covering + * the items it settled. Carried-forward answers were recorded when first given and are skipped. Ids are stable, + * so a rerun (or a retry after an interrupted import) adds nothing twice. + */ +export function importAutoplan(runDir: string, branch: string, by?: string): ApprovalRow[] | RecordError { + const file = path.join(runDir, 'decisions.jsonl'); + if (!fs.existsSync(file)) return { code: 'BRIEF_INPUT_MISSING', message: `decisions.jsonl not in ${runDir}` }; + const rows: DecisionLike[] = []; + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + if (!line.trim()) continue; + try { rows.push(JSON.parse(line)); } catch { return { code: 'ARTIFACT_MALFORMED_JSONL', message: `decisions.jsonl has a line that is not JSON` }; } + } + const run = (() => { try { return JSON.parse(fs.readFileSync(path.join(runDir, 'run.json'), 'utf8')); } catch { return {}; } })(); + const plan = reviewedPlan(runDir, run); + const planTitle = firstTitle(plan.text) ?? 'the plan'; + const planSha = plan.sha; + const runId = String(run.run ?? run.id ?? path.basename(runDir)); + const answered = rows.filter(r => r.status && r.status !== 'pending' && r.answered_at && !r.carried_from); + if (!answered.length) return { code: 'APPROVAL_IMPORT_UNANSWERED', message: `no answered gate in ${runDir}` }; + const groups = new Map<string, DecisionLike[]>(); + for (const r of answered) { + const k = [r.gate_rev, r.answered_at, r.answered_by].join('|'); + groups.set(k, [...(groups.get(k) ?? []), r]); + } + const out: ApprovalRow[] = []; + for (const group of groups.values()) { + const first = group[0]!; + const rev = first.gate_rev ?? 0; + const p1 = group.find(r => (r.label ?? '').toLowerCase() === 'p1' || (r.id ?? '').endsWith('-p1')); + const outcome: Outcome = !p1 ? 'pending' : p1.chosen === 'a' || p1.chosen === 'b' ? 'approved' : p1.chosen === 'e' ? 'rejected' : 'pending'; + const items = group.filter(r => r !== p1); + const changed = items.filter(r => r.chosen && r.recommended && r.chosen !== r.recommended); + const stillPending = rows.filter(r => r.gate_rev === rev && r.status === 'pending'); + const answerer = first.answered_by ?? 'unknown'; + const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`; + const scope = `the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`; + const built = buildRow({ + ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask), + words: first.owner_reply ?? first.reply ?? '(no words recorded)', + by: who.slice(0, CAPS.by), + scope: scope.slice(0, CAPS.scope), + pending: (stillPending.length ? `${stillPending.length} decision(s) unanswered: ${stillPending.map(r => stripRef(r.title ?? '')).join('; ')}` : outcome === 'pending' ? 'the plan approval itself' : 'none').slice(0, CAPS.pending), + outcome, + date: first.answered_at!.slice(0, 10), + source: `autoplan:${runId}@gate_rev=${rev}`, + }, branch, new Date(first.answered_at!)); + if ('code' in built) return built; + out.push(built); + } + return out; +} + diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index 0c831028c..98b381f4f 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -24,6 +24,7 @@ import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHAN import type { Phase } from './autoplan-prompts'; import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, voiceTerminal, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state'; import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget'; +import { capyRelayLine } from './captain-brief'; import { previousReview, reviewRows, settledIds } from './autoplan-confirm'; import { phaseClose } from './autoplan-timing'; import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list'; @@ -280,7 +281,8 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string, journalRow(state.out, { event: 'recommended', gate_rev: gateRev, item: itemId, option, basis, by, rec_rev: list.rec_rev, rec_hash: hash }); exportRun(state); return [`RECOMMENDED: ${itemId}${option} by=${by} basis=${JSON.stringify(basis)} p1=${p1.recommended} rec_hash=${hash}`, - `RELAY: gstack-gate render ${gateFile} --all (a rec rendered before this is refused; answer with --rec-hash ${hash})`]; + `RELAY: gstack-gate render ${gateFile} --all (a rec rendered before this is refused; answer with --rec-hash ${hash})`, + ...[capyRelayLine(state.out)].filter((l): l is string => !!l)]; } /** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */ diff --git a/lib/captain-brief.ts b/lib/captain-brief.ts new file mode 100644 index 000000000..c94c8a168 --- /dev/null +++ b/lib/captain-brief.ts @@ -0,0 +1,114 @@ +/** + * captain-brief — the plan-approval ask in the shape Capy Captains relay to + * their person (capy/skill/gstack/captain.md): a two-line header with the + * one-word reply, then Before → after, Why it matters, What caused this work, + * Big decisions (each read from the reviewed plan through the shared heading + * parser) and What we need from you (built from gate.json: every choice `rec` + * takes, in words). Gate ids, gate_rev and rec_hash appear only on the last + * line, which is for the relaying agent. A plan missing a section yields a + * brief that says it is not ready for approval and has no reply invitation + * (CAPTAIN_BRIEF_INCOMPLETE). Called by bin/gstack-owner-brief render --captain. + */ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { recHash, type GateItem } from './gate-list'; +import { renderUrgentBlock } from './headless-artifacts'; +import { findSection, firstTitle } from './markdown-sections'; +import type { BriefInputs } from './owner-brief'; + +export const BRIEF_SECTIONS = [ + { title: 'Before → after', aliases: ['Before → after', 'Before -> after', 'Before and after', 'Before/after'] }, + { title: 'Why it matters', aliases: ['Why it matters', 'Why this matters'] }, + { title: 'What caused this work', aliases: ['What caused this work', 'What caused us to work on this', 'What caused this', 'Trigger'] }, + { title: 'Big decisions', aliases: ['Big decisions', 'Big decisions we made', 'Key decisions'] }, +] as const; +const SECTION_LINES = 24; +const SECTION_CHARS = 3000; + +/** The plan text the gate approves: the run's active (reviewed) plan unless an amendment is pending, else the export. */ +export function reviewedPlan(outDir: string, run?: Record<string, any>): { file?: string; text: string; sha: string } { + const active = typeof run?.active_plan === 'string' && run?.status !== 'amend_pending' ? run.active_plan : undefined; + const candidates = [active, path.join(outDir, 'plan-active.md'), path.join(outDir, 'plan.md')].filter((f): f is string => !!f); + const file = candidates.find(f => fs.existsSync(f)); + const text = file ? fs.readFileSync(file, 'utf8') : ''; + const sha = typeof run?.plan_reality?.plan_sha256 === 'string' ? run.plan_reality.plan_sha256.slice(0, 12) : text ? createHash('sha256').update(text).digest('hex').slice(0, 12) : 'unknown'; + return { file, text, sha }; +} + +const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim(); +const OPTION_WORDS: Record<string, string> = { + 'keep your direction': 'keep the plan as written (a reviewer suggested changing it)', + 'accept the change': "make the reviewer's change", +}; +const plainOption = (t: string) => { + const s = t.replace(/\s*\([^)]*gstack-autoplan[^)]*\)/g, '').trim(); + if (/^approve with the recommended changes\b/.test(s)) return 'approve, with the plan changes marked below'; + if (s === 'approve as-is') return 'approve the plan as reviewed'; + return OPTION_WORDS[s] ?? s; +}; +const P1_DONE: Record<string, string> = { a: 'approved as reviewed', b: 'approved with the recommended plan changes', c: 'questions asked, still open', d: 'sent back for another review round', e: 'rejected' }; +const clip = (t: string, n: number) => (t.length <= n ? t : t.slice(0, n).replace(/\s+\S*$/, '') + '…'); + +function capped(body: string, planPath: string): string { + const lines = body.split('\n'); + let out = lines.slice(0, SECTION_LINES).join('\n'); + if (out.length > SECTION_CHARS) out = out.slice(0, SECTION_CHARS).replace(/\s+\S*$/, '') + ' …'; + const hidden = lines.length - Math.min(lines.length, SECTION_LINES); + return hidden > 0 || out.length < body.length ? `${out}\n\n_+ more in the plan (${path.basename(planPath)})._` : out; +} + +export interface CaptainBrief { text: string; missing: string[]; pending: boolean; recHash: string } + +export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }): CaptainBrief { + const plan = reviewedPlan(o.outDir, inputs.run); + const title = firstTitle(plan.text) ?? 'this plan'; + const list = inputs.gate; + const hash = recHash(list); + const p1 = list.items.find(i => i.id === 'p1'); + const rowOf = (id: string) => inputs.decisions.filter(d => d.gate_rev === list.gate_rev).find(d => (d.label ?? '').toLowerCase() === id); + const p1Row = p1 ? rowOf('p1') : undefined; + const pending = !p1Row || p1Row.status === 'pending'; + const sections = BRIEF_SECTIONS.map(s => ({ title: s.title, found: findSection(plan.text, [...s.aliases]) })); + const missing = sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title); + const urgent = inputs.findings.some(r => r.urgent === true || r.outside_plan === true) ? [renderUrgentBlock(inputs.findings).trimEnd(), ''] : []; + const items = list.items.filter(i => i.id !== 'p1'); + const recText = (i: GateItem) => plainOption(i.options.find(x => x.key === i.recommended)?.text ?? i.recommended); + + let header: string[]; + if (!pending) { + header = [`**Nothing pending.** Already decided${p1Row?.answered_at ? ` on ${p1Row.answered_at.slice(0, 10)}` : ''}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'}${p1Row?.owner_reply ?? p1Row?.reply ? ` (“${p1Row?.owner_reply ?? p1Row?.reply}”)` : ''}.`]; + } else if (missing.length) { + header = [`**Not ready for approval:** the plan "${title}" is missing ${missing.join(', ')}. The thread will add them and send this again; do not approve from this draft.`]; + } else { + header = [ + `**Decision needed:** approve the plan "${title}". Reply **rec** to take every recommendation (recommended), or say in words what to change.`, + `${items.length ? `${items.length} choice${items.length === 1 ? '' : 's'} below; rec takes the recommended answer for each.` : 'No open choices besides the approval itself.'} Nothing is built until you answer.`, + ]; + } + + const body = sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? capped(s.found.body.trim(), plan.file ?? 'plan.md') : '_Not written for this plan yet._', '']); + const need: string[] = ['### What we need from you', '']; + if (!pending) need.push('Nothing. This gate is answered.', ''); + else { + need.push(`One decision: approve the plan.`, ''); + need.push(`- **rec (recommended):** ${p1 ? plainOption(p1.options.find(x => x.key === p1.recommended)?.text ?? 'approve') : 'approve'}${items.length ? ', taking these recommendations:' : '.'}`); + items.forEach((i, n) => need.push(` ${n + 1}. ${stripRef(i.title)}: ${recText(i)}${i.options.find(x => x.key === i.recommended)?.changes_plan ? ' (changes the plan)' : ''}.`)); + const example = items.find(i => i.options.length > 1); + const alt = example?.options.find(x => x.key !== example.recommended); + need.push(`- **Change something:** say which choice and what you want instead, in words${example && alt ? ` (for example "${clip(stripRef(example.title), 60)}: ${plainOption(alt.text)}")` : ''}. A change that alters the plan reopens its review before anything is built.`); + need.push('- **Reject:** nothing is built.', ''); + if (!missing.length) need.push('Reply: **rec**', ''); + } + const tokens = list.items.map(i => `${i.id}${i.recommended}`).join(' '); + const trailer = `_For the relaying agent, not the person:_ run ${list.run}, gate_rev=${list.gate_rev}, rec_hash=${hash}; rec resolves to ${tokens}; plan ${plan.file ?? '(missing)'} (sha256 ${plan.sha}). Answer with ` + + `\`gstack-autoplan answer --out ${o.outDir} --gate-rev ${list.gate_rev} --reply rec --rec-hash ${hash} --by agent:<id> --owner-reply "<their exact words>"\`, then \`gstack-approvals import --autoplan ${o.outDir} --by <name>\`.`; + const text = [...urgent, ...header, '', ...body, ...need, '---', '', trailer, ''].join('\n'); + return { text, missing, pending, recHash: hash }; +} + +/** The extra line a gate-producing command prints under Capy (GSTACK_CAPY=1): the owner-facing render to relay. */ +export function capyRelayLine(outDir: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + if (env.GSTACK_CAPY !== '1') return undefined; + return `RELAY: gstack-owner-brief render --captain --out ${outDir} (Capy: send it to your Captain, or to the person when you report to none; it carries the rec_hash to answer with)`; +} diff --git a/lib/markdown-sections.ts b/lib/markdown-sections.ts new file mode 100644 index 000000000..742562ab4 --- /dev/null +++ b/lib/markdown-sections.ts @@ -0,0 +1,58 @@ +/** + * markdown-sections — the one heading parser the Captain brief + * (lib/captain-brief.ts) and gstack-pr-shots --heading (lib/pr-shots.ts) + * share. ATX headings only (`#`..`######` then a space), never inside a + * fenced code block (three backticks or ~~~, closed by the same fence or longer); + * CRLF is read as LF. A section runs from its heading to the next heading of + * the same or a higher level, or the end of the text. Titles match after + * normalization (case, arrows, punctuation and spacing folded), against an + * alias list; the first match wins and later duplicates are reported. + */ + +export interface Heading { level: number; title: string; line: number } +export interface Section { heading: Heading; body: string; duplicates: number } + +const ATX = /^(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/; +const FENCE = /^ {0,3}(`{3,}|~{3,})/; + +/** Every heading outside fenced code, with its 0-based line. */ +export function headings(text: string): Heading[] { + const out: Heading[] = []; + let fence: string | null = null; + text.replace(/\r\n?/g, '\n').split('\n').forEach((line, i) => { + const f = FENCE.exec(line); + if (f) { + const mark = f[1]!; + if (fence === null) fence = mark; + else if (mark[0] === fence[0] && mark.length >= fence.length) fence = null; + return; + } + if (fence !== null) return; + const m = ATX.exec(line); + if (m) out.push({ level: m[1]!.length, title: m[2]!.trim(), line: i }); + }); + return out; +} + +/** Folded title: lower case, arrows and dashes to one space, punctuation dropped. */ +export function normalizeTitle(title: string): string { + return title.toLowerCase().replace(/→|->|=>|—|–/g, ' ').replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); +} + +/** The section whose title matches one of `aliases` (normalized), or undefined. */ +export function findSection(text: string, aliases: string[]): Section | undefined { + const want = new Set(aliases.map(normalizeTitle)); + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + const all = headings(text); + const hits = all.filter(h => want.has(normalizeTitle(h.title))); + const first = hits[0]; + if (!first) return undefined; + const next = all.find(h => h.line > first.line && h.level <= first.level); + const body = lines.slice(first.line + 1, next ? next.line : lines.length).join('\n').replace(/^\n+|\s+$/g, ''); + return { heading: first, body, duplicates: hits.length - 1 }; +} + +/** The first H1 title, for naming a plan. */ +export function firstTitle(text: string): string | undefined { + return headings(text).find(h => h.level === 1)?.title; +} diff --git a/lib/pr-shots.ts b/lib/pr-shots.ts new file mode 100644 index 000000000..821569dc8 --- /dev/null +++ b/lib/pr-shots.ts @@ -0,0 +1,217 @@ +/** + * pr-shots — before/after images of a change for a PR body (Capy's merge ask, + * capy/skill/gstack/captain.md; /ship's Capy section). Input is text only: + * two capture files (terminal output, a rendered prompt) or a tracked file + * (optionally one heading's section) at a base commit and in the working + * tree. Nothing is executed. Inputs are bounded, ANSI-stripped, refused when + * binary, and secret-scanned before anything is rendered, because PR images + * are public. + * + * The page stacks Before above After at 880 CSS px (GitHub's column), shows + * only the changed hunks with three lines of context, marks lines with a + * −/+ gutter as well as colour, and says how many unchanged lines each gap + * hides, so a change at line 240 of 300 is always on screen. It is rendered + * once to pair.png through lib/aside-render.ts; with no browser the HTML and + * the text fallback (text.md, a collapsed unified diff) are still written. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { findSection } from './markdown-sections'; +import { scan } from './redact-engine'; +import type { ResultCodeName } from './result-codes'; + +export const MAX_BYTES = 512 * 1024; +const MAX_DIFF_LINES = 2000; +const CONTEXT = 3; +const MAX_ROWS = 90; + +export type ShotKind = 'terminal' | 'prompt'; +/** One side; `text` null means the side does not exist (a new file, a removed file, a section only on one side). */ +export interface Side { text: string | null; label: string; absent?: string } +export interface ShotError { code: ResultCodeName; message: string } +export type Op = { t: ' ' | '-' | '+'; a?: number; b?: number; line: string }; + +const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 20_000, maxBuffer: 4 * MAX_BYTES }); + +/** ANSI-free LF text, or an error for binary or oversize input. */ +export function cleanText(raw: string | Buffer, what: string): string | ShotError { + const buf = typeof raw === 'string' ? Buffer.from(raw, 'utf8') : raw; + if (buf.length > MAX_BYTES) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `${what} is ${buf.length} bytes (cap ${MAX_BYTES})` }; + if (buf.includes(0)) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `${what} is binary` }; + return buf.toString('utf8').replace(/\r\n?/g, '\n').replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\x1b\][^\x07]*\x07/g, ''); +} + +function inside(root: string, file: string): boolean { + const rel = path.relative(root, file); + return !rel.startsWith('..') && !path.isAbsolute(rel); +} + +/** A capture file: must exist and resolve inside the repository or the state root. */ +export function readCapture(file: string, roots: string[], what: string): string | ShotError { + if (!fs.existsSync(file)) return { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: `${what} file ${file}` }; + const real = fs.realpathSync(file); + if (!roots.some(r => fs.existsSync(r) && inside(fs.realpathSync(r), real))) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `${what} file ${file} is outside the repository and the state root` }; + return cleanText(fs.readFileSync(real), `${what} file`); +} + +/** Resolve `ref` to a commit sha without letting it be read as an option. */ +export function resolveCommit(cwd: string, ref: string): string | ShotError { + if (!ref || ref.startsWith('-') || /\s/.test(ref)) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `ref ${JSON.stringify(ref)} is not a plain ref` }; + const r = git(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]); + return r.status === 0 ? r.stdout.trim() : { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: `base ref ${ref}` }; +} + +/** The default base: where this branch left origin's default branch. */ +export function defaultBase(cwd: string): string | ShotError { + for (const ref of ['origin/HEAD', 'origin/main', 'main']) { + const r = git(cwd, ['merge-base', 'HEAD', ref]); + if (r.status === 0 && r.stdout.trim()) return r.stdout.trim(); + } + return { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: 'no merge base with origin/HEAD, origin/main or main; pass --base <ref>' }; +} + +/** Both sides of a tracked file (or one heading's section of it) at `base` and in the working tree. */ +export function pathSides(cwd: string, rel: string, base: string, heading?: string): { before: Side; after: Side } | ShotError { + const top = git(cwd, ['rev-parse', '--show-toplevel']); + if (top.status !== 0) return { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: 'not a git repository' }; + const root = top.stdout.trim(); + const abs = path.resolve(cwd, rel); + if (!inside(root, abs) || abs === root) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `--path ${rel} is outside the repository` }; + const relPath = path.relative(root, abs).split(path.sep).join('/'); + const short = base.slice(0, 7); + const atBase = git(root, ['cat-file', 'blob', `${base}:${relPath}`]); + const tracked = git(root, ['ls-files', '--error-unmatch', '--', relPath]).status === 0; + const beforeRaw = atBase.status === 0 ? cleanText(atBase.stdout, `${relPath} at ${short}`) : null; + const afterRaw = tracked && fs.existsSync(abs) ? cleanText(fs.readFileSync(abs), relPath) : null; + if (beforeRaw === null && afterRaw === null) return { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: `${relPath} is neither at ${short} nor tracked in the working tree` }; + for (const s of [beforeRaw, afterRaw]) if (s !== null && typeof s !== 'string') return s; + let before = beforeRaw as string | null; let after = afterRaw as string | null; + let beforeAbsent = before === null ? 'new in this change' : undefined; + let afterAbsent = after === null ? 'removed in this change' : undefined; + if (heading) { + const pick = (t: string | null) => (t === null ? undefined : findSection(t, [heading])); + const b = pick(before); const a = pick(after); + if (!b && !a) return { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: `heading ${JSON.stringify(heading)} in ${relPath} on both sides` }; + if (!b) beforeAbsent = beforeAbsent ?? 'section not present before'; + if (!a) afterAbsent = afterAbsent ?? 'section not present after'; + before = b ? `${'#'.repeat(b.heading.level)} ${b.heading.title}\n${b.body}` : null; + after = a ? `${'#'.repeat(a.heading.level)} ${a.heading.title}\n${a.body}` : null; + } + const where = heading ? `${relPath} § ${heading}` : relPath; + return { + before: { text: before, label: `Before · ${where} at ${short} · source text`, absent: before === null ? beforeAbsent : undefined }, + after: { text: after, label: `After · ${where} on this branch · source text`, absent: after === null ? afterAbsent : undefined }, + }; +} + +/** Refuse a side or caption that matches the secret scanner; the message names the side, line and rule, never the text. */ +export function scanSides(sides: Array<[string, string | null]>): ShotError | undefined { + for (const [what, text] of sides) { + if (!text) continue; + const s = scan(text, { maxBytes: MAX_BYTES * 2 }); + const hits = s.findings.filter(f => f.severity === 'HIGH' || f.severity === 'MEDIUM'); + if (s.oversize) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `${what} is too large to scan` }; + if (hits.length) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `${what} matched the secret scanner: ${hits.slice(0, 5).map(f => `line ${f.line} ${f.id}`).join(', ')}` }; + } + return undefined; +} + +/** Line diff: common prefix and suffix trimmed, LCS on the middle (bounded). */ +export function diffLines(a: string[], b: string[]): Op[] | ShotError { + let pre = 0; + while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++; + let suf = 0; + while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++; + const am = a.slice(pre, a.length - suf); const bm = b.slice(pre, b.length - suf); + if (am.length > MAX_DIFF_LINES || bm.length > MAX_DIFF_LINES) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `the changed region is over ${MAX_DIFF_LINES} lines; picture a section (--heading) instead` }; + const n = am.length; const m = bm.length; const w = m + 1; + const L = new Uint32Array((n + 1) * w); + for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i * w + j] = am[i] === bm[j] ? L[(i + 1) * w + j + 1]! + 1 : Math.max(L[(i + 1) * w + j]!, L[i * w + j + 1]!); + const ops: Op[] = []; + for (let k = 0; k < pre; k++) ops.push({ t: ' ', a: k + 1, b: k + 1, line: a[k]! }); + let i = 0; let j = 0; + while (i < n || j < m) { + if (i < n && j < m && am[i] === bm[j]) { ops.push({ t: ' ', a: pre + i + 1, b: pre + j + 1, line: am[i]! }); i++; j++; } + else if (j < m && (i >= n || L[i * w + j + 1]! >= L[(i + 1) * w + j]!)) { ops.push({ t: '+', b: pre + j + 1, line: bm[j]! }); j++; } + else { ops.push({ t: '-', a: pre + i + 1, line: am[i]! }); i++; } + } + for (let k = 0; k < suf; k++) ops.push({ t: ' ', a: a.length - suf + k + 1, b: b.length - suf + k + 1, line: a[a.length - suf + k]! }); + return ops; +} + +/** Changed regions with CONTEXT lines around them; gaps report how many unchanged lines they hide. */ +export function hunks(ops: Op[]): Array<{ gapBefore: number; ops: Op[] }> { + const keep = new Array(ops.length).fill(false); + ops.forEach((o, k) => { if (o.t !== ' ') for (let d = -CONTEXT; d <= CONTEXT; d++) if (ops[k + d]) keep[k + d] = true; }); + const out: Array<{ gapBefore: number; ops: Op[] }> = []; + let gap = 0; + ops.forEach((o, k) => { + if (!keep[k]) { gap++; return; } + if (!out.length || gap > 0 || !keep[k - 1]) out.push({ gapBefore: gap, ops: [] }); + out[out.length - 1]!.ops.push(o); gap = 0; + }); + return out; +} + +const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + +function paneRows(hs: Array<{ gapBefore: number; ops: Op[] }>, side: 'a' | 'b', total: number, whole: boolean): string[] { + const rows: string[] = []; + if (whole) rows.push(`<div class="gap">rewritten entirely: every line ${side === 'a' ? 'is removed (−)' : 'is new (+)'}</div>`); + let shown = 0; let hidden = 0; + for (const h of hs) { + const mine = h.ops.filter(o => o.t === ' ' || (side === 'a' ? o.t === '-' : o.t === '+')); + if (!mine.length) continue; + if (h.gapBefore) rows.push(`<div class="gap">⋯ ${h.gapBefore} unchanged line${h.gapBefore === 1 ? '' : 's'}</div>`); + for (const o of mine) { + if (shown >= MAX_ROWS) { hidden++; continue; } + const cls = o.t === ' ' || whole ? 'ctx' : side === 'a' ? 'del' : 'add'; + const mark = o.t === ' ' ? ' ' : side === 'a' ? '−' : '+'; + rows.push(`<div class="row ${cls}"><span class="n">${(side === 'a' ? o.a : o.b) ?? ''}</span><span class="m">${mark}</span><span class="t">${esc(o.line) || ' '}</span></div>`); + shown++; + } + } + if (hidden) rows.push(`<div class="gap">… ${hidden} more line${hidden === 1 ? '' : 's'} in the text version below the image</div>`); + const last = hs.at(-1); const end = last ? Math.max(...last.ops.map(o => (side === 'a' ? o.a : o.b) ?? 0)) : 0; + if (total > end && end > 0) rows.push(`<div class="gap">⋯ ${total - end} unchanged line${total - end === 1 ? '' : 's'}</div>`); + return rows; +} + +export interface ShotPage { html: string; text: string; changed: number } + +export function buildPage(o: { before: Side; after: Side; title: string; caption: string; kind: ShotKind }): ShotPage | ShotError { + const a = o.before.text === null ? [] : o.before.text.split('\n'); + const b = o.after.text === null ? [] : o.after.text.split('\n'); + if (o.before.text !== null && o.before.text === o.after.text) return { code: 'PR_SHOTS_IDENTICAL', message: 'before and after are the same text' }; + const ops = diffLines(a, b); + if (!Array.isArray(ops)) return ops; + const hs = hunks(ops); + const changed = ops.filter(x => x.t !== ' ').length; + const whole = a.length > 0 && b.length > 0 && !ops.some(x => x.t === ' ' && x.line.trim()); + const pane = (s: Side, side: 'a' | 'b', total: number) => `<section class="pane"><div class="label">${esc(s.label)}</div>${s.text === null ? `<div class="absent">(${esc(s.absent ?? 'absent')})</div>` : `<div class="code">${paneRows(hs, side, total, whole).join('')}</div>`}</section>`; + const dark = o.kind === 'terminal'; + const css = [ + "*{box-sizing:border-box}body{margin:0;background:#f6f7f9}", + `#shot{width:880px;padding:16px;background:#ffffff;border:1px solid #d0d7de;font-family:'DM Sans',-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;color:#1f2328}`, + "h1{font-size:16px;margin:0 0 4px}p.cap{font-size:13px;margin:0 0 12px;color:#57606a}", + `.pane{margin:0 0 12px;border:1px solid #d0d7de;border-radius:6px;overflow:hidden}.label{font-size:12px;font-weight:600;padding:6px 10px;background:#eaeef2;color:#24292f}`, + `.code{background:${dark ? '#0f1115' : '#ffffff'};color:${dark ? '#e6e6e6' : '#1f2328'};font:14px/1.45 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:6px 0}`, + ".row{display:flex;white-space:pre-wrap;word-break:break-word}.n{flex:0 0 44px;text-align:right;padding-right:8px;opacity:.55}.m{flex:0 0 18px;font-weight:700}.t{flex:1;padding-right:10px}", + `.del{background:${dark ? 'rgba(248,81,73,.22)' : 'rgba(255,129,130,.25)'}}.add{background:${dark ? 'rgba(255,180,0,.24)' : 'rgba(255,196,0,.30)'}}`, + `.gap{font:12px 'DM Sans',sans-serif;padding:3px 10px;opacity:.7;background:${dark ? '#171a21' : '#f6f8fa'}}.absent{font-size:13px;padding:10px;font-style:italic;color:#57606a}`, + ].join(''); + const html = [ + '<!doctype html><html><head><meta charset="utf-8">', + "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:\">", + `<title>${esc(o.title)}
`, + `

${esc(o.title)}

${esc(o.caption)} · ${changed} changed line${changed === 1 ? '' : 's'} (− before, + after)

`, + pane(o.before, 'a', a.length), pane(o.after, 'b', b.length), + '
', + ].join('\n'); + const unified = hs.flatMap(h => [...(h.gapBefore ? [`@@ ⋯ ${h.gapBefore} unchanged ⋯ @@`] : []), ...h.ops.map(x => `${x.t}${x.line}`)]).join('\n'); + const fence = '~'.repeat(Math.max(3, ...[...unified.matchAll(/~{3,}/g)].map(m => m[0].length + 1))); + const summary = `Before/after as text: ${o.caption}`.replace(/[<>]/g, ''); + const text = [`
${summary}`, '', `${fence}diff`, `--- ${o.before.label}${o.before.absent ? ` (${o.before.absent})` : ''}`, `+++ ${o.after.label}${o.after.absent ? ` (${o.after.absent})` : ''}`, unified, fence, '', '
', ''].join('\n'); + return { html, text, changed }; +} diff --git a/lib/result-codes-capy.ts b/lib/result-codes-capy.ts new file mode 100644 index 000000000..5fd806275 --- /dev/null +++ b/lib/result-codes-capy.ts @@ -0,0 +1,67 @@ +/** + * Result codes for gstack's Capy adaptations: the per-thread approval record + * (gstack-approvals), the Captain brief (gstack-owner-brief render --captain) + * and PR screenshots (gstack-pr-shots). Kept beside lib/result-codes.ts (which + * spreads this table into RESULT_CODES) so the owner module stays within the + * module-size ratchet; the same rules apply: one stable docs/troubleshooting.md + * anchor, a summary and a fix clause each. + */ +import type { ResultCode } from './result-codes'; + +export const CAPY_RESULT_CODES = { + APPROVAL_RECORD_INVALID: { + anchor: 'approval-record-invalid', + summary: 'an approval row is missing a required field, holds a value outside its enum, or exceeds a length cap', + fix: 'rerun gstack-approvals add with every required flag (--ask, --words, --scope, --by) inside the caps it names; nothing was written', + }, + APPROVAL_RECORD_SECRET: { + anchor: 'approval-record-secret', + summary: 'a field of the approval row matched the secret scanner, so the row was refused and nothing was written', + fix: 'record the words with the secret removed (write [redacted] in its place) or ask the person to restate them; the refusal names the field and rule, never the text', + }, + APPROVAL_RECORD_NO_BRANCH: { + anchor: 'approval-record-no-branch', + summary: 'the approval record is kept per branch, and HEAD is detached or this is not a git repository', + fix: 'check out the branch the work ships from, or pass --branch ', + }, + APPROVAL_RECORD_UNAVAILABLE: { + anchor: 'approval-record-unavailable', + summary: 'no approval record exists on this machine and the state root is ephemeral with no Drive mirror, so an empty table could be a lost one', + fix: 'restore the record (copy it back from the thread report, or run on the machine that holds it), or set up the Capy Drive mirror; the section says "Record unavailable" instead of "No approvals recorded"', + }, + APPROVAL_RECORD_WRITE_FAILED: { + anchor: 'approval-record-write-failed', + summary: 'the approval record or its Drive mirror could not be written', + fix: 'check the path and permission the line names (the state root must be writable), then rerun; a failed append wrote nothing', + }, + APPROVAL_IMPORT_UNANSWERED: { + anchor: 'approval-import-unanswered', + summary: 'the autoplan run has no answered gate to import', + fix: 'answer the gate first (gstack-autoplan answer ... --by human|agent:), then rerun the import; it is idempotent, so a retry imports nothing twice', + }, + CAPTAIN_BRIEF_INCOMPLETE: { + anchor: 'captain-brief-incomplete', + summary: 'the plan lacks one or more of the sections the Captain brief needs (Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval', + fix: 'add the named sections to the plan, revise the run if the plan is under review, and render again; --allow-incomplete prints a preview that still says it is not ready', + }, + PR_SHOTS_INPUT_NOT_FOUND: { + anchor: 'pr-shots-input-not-found', + summary: 'a before/after input does not exist: the file, the base ref, the path at that ref, or the heading on both sides', + fix: 'check the ref, path and heading the line names (gstack-pr-shots reads tracked files and repository-local captures only)', + }, + PR_SHOTS_INPUT_REFUSED: { + anchor: 'pr-shots-input-refused', + summary: 'an input was refused before rendering: it matched the secret scanner, is binary, is too large, lies outside the repository and state root, or names an option-like ref', + fix: 'remove the secret from the capture (the line names the side, line and rule, never the text), or point at a tracked text file inside the repository', + }, + PR_SHOTS_IDENTICAL: { + anchor: 'pr-shots-identical', + summary: 'the before and after inputs are the same, so there is no change to picture', + fix: 'pick the file or section this change actually touched (git diff --stat origin/...HEAD lists them)', + }, + PR_SHOTS_RENDER_FAILED: { + anchor: 'pr-shots-render-failed', + summary: 'a browser was available but rendering shot.html failed', + fix: 'read the renderer transcript the line names; screenshot shot.html with your own browser at 880px width, or use the text fallback in text.md', + }, +} satisfies Record; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index d7adaf05e..cd5a5f74e 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -11,6 +11,7 @@ * checks every anchor exists; add a row here before printing a new code. */ import { RELEASE_RESULT_CODES } from './result-codes-release'; +import { CAPY_RESULT_CODES } from './result-codes-capy'; export interface ResultCode { /** Stable `` in docs/troubleshooting.md. */ @@ -768,6 +769,7 @@ export const RESULT_CODES = { fix: 'rerun once; if it repeats, report the printed frame to the gstack maintainers', }, ...RELEASE_RESULT_CODES, + ...CAPY_RESULT_CODES, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/package.json b/package.json index 988a9d893..d79a1c19a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.171", + "version": "1.91.172", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/pr-dependencies.ts b/scripts/pr-dependencies.ts index 077e01f71..2dbd26004 100644 --- a/scripts/pr-dependencies.ts +++ b/scripts/pr-dependencies.ts @@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [ // The build entry list: every compiled binary, which skills reach through their `--outfile` path instead. 'scripts/build.sh', // The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes. - 'lib/result-codes.ts', 'lib/result-codes-release.ts', + 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', // The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case). 'test/helpers/exercised-commands.json', ]; diff --git a/ship/SKILL.md b/ship/SKILL.md index eebbf1b36..924983ab4 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -497,6 +497,7 @@ sections. Read a section in full before doing its step; do not work from memory. | writing the CHANGELOG entry (Step 13) | `sections/changelog.md` | | auditing docs before final commit/verification (Step 14.5), on every ship | `sections/documentation.md` | | preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` | +| Step 19 printed CAPY_PR: on (a Capy session, GSTACK_CAPY=1) — read before composing the PR body | `sections/capy-pr.md` | | handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` | --- diff --git a/ship/sections/capy-pr.md b/ship/sections/capy-pr.md new file mode 100644 index 000000000..b0f226edc --- /dev/null +++ b/ship/sections/capy-pr.md @@ -0,0 +1,42 @@ + + +## Step 19-C: Capy PR body (What changed, Why, Approvals) + +Read only when Step 19 printed `CAPY_PR: on`. Under Capy a person decides the merge from the PR and +the READY report, through a Captain, so the body shows what changed in pictures, why, and who +approved what. Guide: `captain.md` beside the Capy `gstack` skill's SKILL.md. + +**Order.** The `gstack-ship-receipt` block stays first when Step 19 writes one. Then `## What changed`, +`## Why` and `## Approvals`, then the standard sections below. Keep `## Summary` to commit themes; +do not repeat the captions. + +1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the + key image. + - UI change: screenshot the changed screen at the base and on this branch with the browse + flow, or your host's browser, at the same size. + - Skill or prompt change: `~/.claude/skills/gstack/bin/gstack-pr-shots --path