v1.91.270.0 stamp

FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=e78f9beb5b4be0ac80be18f656f754d4a6af0e64
NEW_VERSION=1.91.270.0
base=295b32e2c47a3c30a2b797739fd0300012533f55 expect-order=3487

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-27 07:44:59 +00:00
commit ab030c0f5d
32 files changed
+1450 -75

No files matched your search

+34 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [Unreleased]
## [1.91.270.0] - 2026-10-27
### Added
- **Repositories without a version source** (`ship/SKILL.md` Step 12, `ship/sections/changelog.md`, `gstack-ship-receipt write`): a repo with no `.gstack/version-path` pin and no `VERSION` ships without a version change: `Version bump: skipped (no version source: <path, reason>)`, no `VERSION` is ever created, the PR title is unprefixed and the run log records `"version":null`; the receipt records `"version": "none"` when the repo also has no `package.json#version`, no policy stamp file and no CHANGELOG. A repo with no CHANGELOG (the policy's `changelog` is null or the file is absent) prints `CHANGELOG: skipped (none at <path>)` and never creates the file ([ship-policy.md#ship-receipt](docs/ship-policy.md#ship-receipt)).
@@ -30,6 +30,39 @@
### Gate integrity
- PR 2: the ship parity `maxSizeRatio` in `test/helpers/carve-guards.ts` moves 1.7150 → 1.7400 (measured 326,437 / 187,706 = 1.7391) for the Step 9.4 scope and late-defer prose, the docs-caps step and the version-less branch; the skeleton cap is unchanged. New case kind: `ship-frozen-scope-convergence` is `behavior` (3 trials, pass at 2) because the live model's wording of the fix and the review rows varies while the scope contract is asserted; no existing threshold, timeout, trial count or kind changed.
## [1.91.269.0] - 2026-10-27
**An autoplan gate now arrives with its price, and the approvals record lands where the whole crew can read it.** Captains reported the same two frictions: review rounds and reviewer subagents had no credit figure at the gate, and the approvals mirror sat in a private project-user folder no sibling thread could read. The Eng phase also gains the performance-budget question, after a correctness pass doubled a consumer's p95 with no bench step to catch it. (The standing "take the recommendations" answer planned beside these, `answer --owner-standing rec`, was cut from this release after review: a keyword grammar over the owner's words could still read a refusal as a grant, so it returns with a structured grant in its own change.)
### Added
- **`GATE_SPEND:` and `GATE_REC_COST:`** (`lib/autoplan-gate-cost.ts`; printed by every gate-producing `gstack-autoplan` command under `GATE:`, by `run` when it stops at a gate, and as `gate_cost` in the `--json` payloads): the run's ledger at the gate (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced `unknown` and counted at their reservation, with a pointer to the host's own cost view instead of an invented figure) and the price of `rec` as an upper bound from the answer and revise rules (a plan change counts every phase `revise` can keep or add, since it reopens each phase whose snapshot the edit changes and re-detects scope, so a run that skipped Design or DX without `--no-ui`/`--no-dx` counts them too; a round grant counts its phase and Eng; the effective choice per item is parseReply's (an answer already recorded at this gate revision, else the carried choice, else the recommendation), a recorded plan rerun `p1d` prices every phase, a reject or stop nothing, and an override of the recommendation counts as `answer` counts it; two voices each with the outside voice on, at the per-voice reservation the run has been making; a reopen past the cap says so). `gstack-owner-brief render` carries the same figures as one `Cost so far:` sentence under its Summary, and `render --captain` carries them as a `### Cost` section above the relaying-agent trailer, so the person approving sees it.
- **The Captain-named approvals directory** (`lib/approval-record.ts` `captainApprovalsDir`): on Capy the mirror is `GSTACK_APPROVALS_DIR` when set, else the `approvals/` directory in the one mounted Captain drive (`~/.capy/drive/captain-*/approvals/<slug>/<branch key>.jsonl`, readable by every crew thread), else the private project-user folder as before; two Captain drives with one each is ambiguous and falls through. The other roots are still read (`read_also`), so a row mirrored under the earlier rule stays visible to `list` and `render`; `gstack-approvals path` prints which rule chose the mirror and the `READ_ALSO:` roots.
### Changed
- **Eng phase, Section 4 (Performance)** (`autoplan/sections/eng-phase.md.tmpl`): a hot path with no performance budget or bench step is a finding, and the consensus table's row 3 asks "Perf risks + budget/bench named?". Five nearby phrases were shortened by the same number of characters, so every host's generated autoplan SKILL.md stays at its size-guard pin (raising a pin is an owner decision).
- **`autoplan/DRIVER.md`, `docs/capy-install.md`, `docs/unattended.md`, `docs/recommended-harness.md`, `capy/skill/gstack/captain.md`, `docs/troubleshooting.md`**: the loop relays the two cost lines with every gate render; the Captain sections name the crew-readable mirror and the `READ_ALSO:` roots; the brief descriptions carry the `### Cost` section; the `APPROVAL_RECORD_UNAVAILABLE` fix names the three mirror sources. `capy/skill/gstack/SKILL.md` is untouched here (it is #3475's file) and points at `autoplan/DRIVER.md` already.
### Tests
- `test/autoplan-gate-cost.test.ts` (new): `recReopens` as the upper bound (plan change counts every phase revise can keep or add, a round grant its phase plus Eng, carried items only when changed, overrides, `p1d`/`p1c`/`p1e`), `priorChoices`, and the two cost lines from a ledger with an unknown host attempt (cap, no cap, over the cap, no ledger); the mirror chain (project-user, one Captain drive, two, `GSTACK_APPROVALS_DIR`, mirror off) and `gstack-approvals path` with `READ_ALSO:`; end-to-end runs through `bin/gstack-autoplan`: the closing submit prints `GATE_SPEND:`/`GATE_REC_COST:` (text) or `gate_cost` (`--json`), the owner brief carries the `Cost so far:` sentence, `render --captain` a `### Cost` section above its trailer, and `run` at a pending gate prints the gate lines once (one `--json` payload).
### Gate integrity
- No threshold, rubric, trial count, case kind or judge prompt changed. The only prompt edit is the Eng phase's performance paragraph; the one paid autoplan case (`autoplan-dual-voice`) reads Phase 1, which is untouched.
## [1.91.268.0] - 2026-10-27
**Two read-and-write helpers absorbed from pstack: a decision trail a reviewer reads without the transcript, and a worktree audit that tells you which Conductor worktrees are safe to remove.** Both are ports of Lauren Tan's MIT scripts (header credit in each file, `licenses/MIT-pstack.txt`, NOTICE.md), approved with the rest of the pstack absorb (Garry, 2026-10-26: "Do it, send").
### Added
- **`bin/gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>`**: appends one UTC-stamped row to a run's TSV decision trail, writing the header once and only ever appending; cells are flattened to one line and a cell that starts with `=`, `+`, `-` or `@` gets a leading quote so evidence lifted from a PR title or a filename cannot run as a formula in a reviewer's spreadsheet. `start <file> <run-id>` appends a run's `start` row (naming the earlier rows and `run:<id>`; the id is letters, digits and `. _ : @ / -`, so a tab or quote in it is a usage error rather than a row that breaks the idempotence check) and is a no-op while that row is the newest start row, so a rerun writes nothing twice; only a leading `-h`/`--help` is help, so a cell whose text is `-h` is appended as data; a trail without a final newline, with CRLF rows, a UTF-8 BOM, a repeated header line or no header at all is appended to correctly; `--run <id>` on an append refuses with `DECISION_TRAIL_START_REQUIRED` (exit 3) until the run has opened its stretch. The end-of-run rule, in `docs/unattended.md` ("The decision trail") and `autoplan/DRIVER.md`: audit the trail against the transcript and correct the log, not the story (a wrong row is superseded, never edited), then a reviewer on another model family reads it and the report ends with an `Attention` section (`reviewed by <model>`, the flags, or `No flags`).
- **`bin/gstack-worktree-audit [-b <ref>] [--no-fetch] [repo]`** (bash, read-only): one TSV row per git worktree beside the main one, largest first: size, age, merged into the base, `clean`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh`'s own `--jq` (no `jq` needed), the newest Claude Code chat that ran in the worktree (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<slug>`) and a bucket: `prunable` (registered, directory gone), `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe`, `review`. `safe` means nothing exists only in that worktree: a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed; an unpushed commit or an untracked file on a merged branch reads `review`. A fork's PR on a same-named branch is ignored and an open PR wins over an older closed one on a reused name. A locked entry whose directory is gone and a `broken` worktree whose own `.git` link is missing (git would otherwise describe the containing repository and hide its files) read `review`; a clean tree names its gitignored entries as `clean,ignored:<n>` without changing the bucket; a branch name shared with a tag still resolves; worktree paths with leading, trailing or inner spaces are read whole. It never deletes or prunes; its one write is a best-effort fetch of the base ref that `--no-fetch` skips. `/landing-report` offers it when the sibling table shows stale worktrees; `docs/CONTRIBUTOR_REFERENCE.md` has the guide for Conductor users, `docs/standalone-binaries.md` lists both bins.
### Tests
- `test/decision-trail.test.ts`: header once (an empty file gets one, a second append does not), the formula guard on every leading byte it covers, flattened tabs and line breaks, the start row's text and its idempotent rerun, a second run's start row, the `--run` refusal and admission, a `-h` cell appended as data, a missing final newline, CRLF rows and a repeated header, a blank file and a BOM, run ids with whitespace or quotes refused, usage errors.
- `test/worktree-audit.test.ts`: a bare origin, a clone and twelve worktrees (merged, wip with an open PR, scratch with a chat today, stale, closed PR, a merged one whose path has a space, a merged one with only an untracked file, a registered one whose directory is gone, a locked one whose directory is gone, a broken one inside the clone with a precious file, one whose branch shares its name with a tag, one with only a gitignored `.env`) classified into every bucket (also under `status.showUntrackedFiles=no`, which must not hide untracked files) through a fake `gh` (which records its arguments and runs the audit's own `--jq` over canned JSON with a fork PR when `jq` is installed) and a `CLAUDE_CONFIG_DIR` transcript directory; the fixture, its worktree registry included, is byte-identical after the audit; `-b`, `--no-fetch`, an unpushed commit on a closed PR reading `review` and an open PR on the reused name winning, no `gh` on PATH (git resolved from `git --exec-path`, so a distribution's wrapper script does not matter), a failing `gh`, a missing base and a non-repo path.
### Gate integrity
- No threshold, timeout, trial count or case kind changed. No pinned skill changed; `landing-report/SKILL.md` grows by one line and is unpinned. `test/autoplan-driver.test.ts`'s line cap on `autoplan/DRIVER.md` moves 160 → 162 (measured 162) for the decision-trail paragraph at the end of the coordinator contract section; the file was at the cap before.
## [1.91.267.0] - 2026-10-27
**The Captain guide says which questions never need to reach a person.** Follow-up to #3493 (pstack absorb item 4, GSTA-124).
+17
View File
@@ -124,3 +124,20 @@ design is not a defect, missing context; independent agreement as the strongest
`docs/review-policy.md` are written in gstack's words. No sentence is copied, so no license
text is carried; this entry is a courtesy.
Also ideas, not text, from `pstack/skills/principle-*`, `poteto-mode` and its eval playbook: ETHOS.md principles 5 to 8, the observable-questions rule in `plan-tune/SKILL.md.tmpl` and `docs/proactive.md`, the blinding rules in `eval-plan/SKILL.md.tmpl` and `docs/TESTING_INTERNALS.md`, and the limiter sentence in AGENTS.md step 4 (GSTA-124).
## pstack, ported scripts — Copyright (c) 2026 Lauren Tan — MIT License
https://github.com/cursor/plugins (`pstack/` at 99b609a7). License text:
`licenses/MIT-pstack.txt`. Unlike the ideas entry above, these two are code
ports, so the license text is carried and each file's header names its source.
Derived, modified:
- `bin/gstack-decision-trail`: `pstack/skills/show-me-your-work/scripts/log.sh`
in TypeScript: the header-once append, the one-line cells and the
spreadsheet formula guard (a leading quote on cells starting with `=`, `+`,
`-` or `@`). The `start` subcommand and the `--run` admission check are
gstack's.
- `bin/gstack-worktree-audit`: `pstack/skills/poteto-mode/scripts/worktree-audit.sh`,
with the PR column read through `gh --jq` alone, the chat column read from
Claude Code's per-worktree transcript directory, and a `--no-fetch` flag.
+1 -1
View File
@@ -1 +1 @@
1.91.267.0
1.91.270.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.267.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.270.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,
+5 -5
View File
@@ -27,11 +27,9 @@ line yourself; gstack never writes the plan.
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
`gstack-gate render <dir>/gate.json --all` and relay every page. You never approve the
gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
`--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with `gstack-gate render <dir>/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`).
You never approve the gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an
unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are
accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed
`NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until
@@ -157,3 +155,5 @@ Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane
Before done. A lane pushes a WIP commit to its branch after each step and gates a pushed SHA. It is done only when `gstack-pregate run --allow-repo-commands` passes; `requires-remote` needs `--stage tests --remote --spend-cap <cap>`. Where `.gstack/pregate.json` has a `sealed` key, it runs `gstack-pregate sealed` before any push or PR creation; a refusal stops the push.
Handoff format. A lane reports in this order, identifiers first: PR URL (or `no PR` with branch and SHA), `GSTACK_RESULT` line, the `gstack: ok|fail <rev>` line, the `PREGATE:` line, gate summary (lanes passed/failed/skipped), behavior changes (or `none`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (`jam_…`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be.
Decision trail. A lane keeps `<run dir>/decisions.tsv` through `gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>` (one row per fork, unit, revert or blocker; evidence a pointer that resolves) and opens it with `gstack-decision-trail start <file> <run id>` when another run wrote it first. Before done it audits the trail against the transcript, superseding a wrong row with a new one rather than editing it, has a reviewer on another model family read the trail, and ends its report with an `Attention` section (`reviewed by <model>`, then the flags, or `No flags`). The full rule is [docs/unattended.md](../docs/unattended.md#the-decision-trail).
+5 -5
View File
@@ -24,11 +24,9 @@ line yourself; gstack never writes the plan.
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
`gstack-gate render <dir>/gate.json --all` and relay every page. You never approve the
gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
`--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with `gstack-gate render <dir>/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`).
You never approve the gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an
unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are
accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed
`NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until
@@ -82,3 +80,5 @@ is the completion check. Copy the whole directory, never single files.
{{AUTOPLAN_DRIVER_CODES}}
{{COORDINATOR_CONTRACT}}
Decision trail. A lane keeps `<run dir>/decisions.tsv` through `gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>` (one row per fork, unit, revert or blocker; evidence a pointer that resolves) and opens it with `gstack-decision-trail start <file> <run id>` when another run wrote it first. Before done it audits the trail against the transcript, superseding a wrong row with a new one rather than editing it, has a reviewer on another model family read the trail, and ends its report with an `Attention` section (`reviewed by <model>`, then the flags, or `No flags`). The full rule is [docs/unattended.md](../docs/unattended.md#the-decision-trail).
+7 -7
View File
@@ -93,12 +93,12 @@ Failed/incomplete outside review → unavailable; disabled → skip outside. Bot
Retain the historical review-log skill ID; add `"host":"claude","outside_provider":"codex","outside_status":"completed|unavailable|disabled|skipped","phase":"eng"`. Record differing attempt outcomes separately. `source:"codex"` requires completed CLI output; native uses `source:"in-host"` (historical `source:"claude"`: native Claude). Availability/native fallback is not outside completion. Preserve all reported modelUsage; unknown model identity stays unknown. Under `GSTACK_CODEX_NO_SANDBOX=1` add `"codex_sandbox":"danger-full-access"`.
Error handling: Phase 1 failure/degradation policy applies.
Errors: Phase 1 failure/degradation policy applies.
- Architecture choices: explicit over clever (P5). If Codex disagrees with valid reason → TASTE DECISION. Scope changes both models agree on → USER CHALLENGE.
- Evals: always include all relevant suites (P1)
- Test plan: generate artifact at `$GSTACK_STATE_ROOT/projects/$SLUG/{user}-{branch}-eng-review-test-plan-{datetime}.md` (the loaded eng skill's name and root)
- TODOS.md: collect all deferred scope expansions from every prior phase (Eng runs last), auto-write
- TODOS.md: collect every prior phase's deferred scope expansions (Eng runs last), auto-write
**Required execution checklist (Eng):**
@@ -110,14 +110,14 @@ Retain the historical review-log skill ID; add `"host":"claude","outside_provide
2. Step 0.5 (Dual Voices): Present the completed calls above under Codex SAYS
(eng — architecture challenge) and Claude SUBAGENT (eng — independent review).
Produce eng consensus table:
Eng consensus table:
```
ENG DUAL VOICES — CONSENSUS TABLE:
Dimension Claude Codex Consensus
1. Architecture sound? — — —
2. Test coverage sufficient? — — —
3. Performance risks addressed? — — —
3. Perf risks + budget/bench named? — — —
4. Security threats covered? — — —
5. Error paths handled? — — —
6. Deployment risk manageable? — — —
@@ -142,7 +142,7 @@ Missing/disabled voice = N/A, never CONFIRMED. Flag any single-voice critical fi
skipping the analysis.
- Write the test plan artifact to disk
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths.
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths; a hot path with no budget nor bench step is a finding.
**Mandatory outputs from Phase 3:**
- "NOT in scope" section
@@ -151,8 +151,8 @@ Missing/disabled voice = N/A, never CONFIRMED. Flag any single-voice critical fi
- Test diagram mapping codepaths to coverage (Section 3)
- Test plan artifact written to disk (Section 3)
- Failure modes registry with critical gap flags
- Completion Summary (the full summary from the Eng skill)
- TODOS.md updates (collected from all phases)
- Completion Summary (from the Eng skill)
- TODOS.md updates (all phases)
**Close this phase:**
+7 -7
View File
@@ -48,12 +48,12 @@ Failed/incomplete outside review → unavailable; disabled → skip outside. Bot
{{OUTSIDE_PROVENANCE:eng}}
Error handling: Phase 1 failure/degradation policy applies.
Errors: Phase 1 failure/degradation policy applies.
- Architecture choices: explicit over clever (P5). If {{OUTSIDE_LABEL}} disagrees with valid reason → TASTE DECISION. Scope changes both models agree on → USER CHALLENGE.
- Evals: always include all relevant suites (P1)
- Test plan: generate artifact at `$GSTACK_STATE_ROOT/projects/$SLUG/{user}-{branch}-eng-review-test-plan-{datetime}.md` (the loaded eng skill's name and root)
- TODOS.md: collect all deferred scope expansions from every prior phase (Eng runs last), auto-write
- TODOS.md: collect every prior phase's deferred scope expansions (Eng runs last), auto-write
**Required execution checklist (Eng):**
@@ -63,14 +63,14 @@ Failed/incomplete outside review → unavailable; disabled → skip outside. Bot
2. Step 0.5 (Dual Voices): Present the completed calls above under {{OUTSIDE_LABEL}} SAYS
(eng — architecture challenge) and {{NATIVE_LABEL}} SUBAGENT (eng — independent review).
Produce eng consensus table:
Eng consensus table:
```
ENG DUAL VOICES — CONSENSUS TABLE:
Dimension {{NATIVE_LABEL}} {{OUTSIDE_LABEL}} Consensus
1. Architecture sound? — — —
2. Test coverage sufficient? — — —
3. Performance risks addressed? — — —
3. Perf risks + budget/bench named? — — —
4. Security threats covered? — — —
5. Error paths handled? — — —
6. Deployment risk manageable? — — —
@@ -95,7 +95,7 @@ Missing/disabled voice = N/A, never CONFIRMED. Flag any single-voice critical fi
skipping the analysis.
- Write the test plan artifact to disk
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths.
6. Section 4 (Performance): Evaluate N+1 queries, memory, caching, slow paths; a hot path with no budget nor bench step is a finding.
**Mandatory outputs from Phase 3:**
- "NOT in scope" section
@@ -104,8 +104,8 @@ Missing/disabled voice = N/A, never CONFIRMED. Flag any single-voice critical fi
- Test diagram mapping codepaths to coverage (Section 3)
- Test plan artifact written to disk (Section 3)
- Failure modes registry with critical gap flags
- Completion Summary (the full summary from the Eng skill)
- TODOS.md updates (collected from all phases)
- Completion Summary (from the Eng skill)
- TODOS.md updates (all phases)
**Close this phase:**
+7 -5
View File
@@ -11,8 +11,10 @@
* gstack-approvals sync [--branch <name>]
* gstack-approvals path [--branch <name>]
*
* 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.
* Rows are kept per branch under the state root and, on Capy, mirrored to
* GSTACK_APPROVALS_DIR, else the `approvals/` directory a Captain created in
* its drive (readable by the whole crew), else the private project-user Drive
* folder. Every field is secret-scanned before it is written. A row with
* `render` prints the `## Approvals` section /ship puts in the PR body. Logic:
* lib/approval-record.ts.
*/
@@ -28,8 +30,8 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs
import --autoplan <run dir> [--by <whose words, e.g. Garry>]
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
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
path print the record and mirror paths, and which rule chose the mirror
Every command takes [--branch <name>] (default: the checked-out branch).
Example:
@@ -107,7 +109,7 @@ function main(argv: string[]): number {
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`);
process.stdout.write(`RECORD: ${loc.file}\nMIRROR: ${loc.mirror ? `${loc.mirror} (${loc.mirror_source})` : 'none'}\n${(loc.read_also ?? []).map(f => `READ_ALSO: ${f}\n`).join('')}BRANCH: ${loc.branch}\n`);
return EXIT.ok;
}
process.stderr.write(`gstack-approvals: unknown command ${cmd}\n${HELP}\n`);
+26 -11
View File
@@ -31,7 +31,8 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
import { changeBudget, liftStop } from '../lib/autoplan-budget';
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
import { attemptEstimate, changeBudget, liftStop } from '../lib/autoplan-budget';
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';
@@ -185,17 +186,28 @@ 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(...gateLines(r.state.gate_rev, out));
if (status === 'gate_pending') lines.push(...gateLines(r.state, 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 });
emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), 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[] {
/** The gate pointer, its cost so far and the price of `rec` (lib/autoplan-gate-cost.ts), plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */
function gateLines(state: RunState, out: string): string[] {
const relay = capyRelayLine(out);
return [`GATE: gate_rev=${gateRev} render with: gstack-gate render ${path.join(out, 'gate.json')}`, ...(relay ? [relay] : [])];
const cost = gateCostOf(state, out);
return [`GATE: gate_rev=${state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`, ...(cost ? renderGateCost(cost) : []), ...(relay ? [relay] : [])];
}
function gateCostOf(state: RunState, out: string): GateCost | undefined {
const gateFile = path.join(out, 'gate.json');
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) return undefined;
return gateCost(out, JSON.parse(fs.readFileSync(gateFile, 'utf8')), state, { defaultPerVoiceUsd: attemptEstimate(state, undefined, process.env).usd });
}
/** `gate_cost` for a JSON payload when the run sits at a gate. */
function gateCostField(state: RunState, out: string): Record<string, unknown> {
const cost = gateCostOf(state, out);
return cost ? { gate_cost: gateCostPayload(cost) } : {};
}
function doSubmit(out: string, rest: string[], json: boolean): number {
@@ -211,10 +223,10 @@ 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(...gateLines(r.state.gate_rev, out));
if (status === 'gate_pending') lines.push(...gateLines(r.state, 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 });
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, ...gateCostField(r.state, out), out });
return EXIT.ok;
}
@@ -242,11 +254,11 @@ 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(...gateLines(state.gate_rev, out)); }
if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state, out)); }
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
writeState(state);
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, out });
emit(json, lines, { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, ...gateCostField(state, out), out });
return EXIT.ok;
}
@@ -272,7 +284,10 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
const code = withRunLock(out, () => doNext(out, rest, true));
if (code !== EXIT.ok) return code;
const state = readState(out);
if (state.status !== 'awaiting_result') { if (!json) process.stdout.write(statusLines(state).join('\n') + '\n'); process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok; }
if (state.status !== 'awaiting_result') {
if (!json) process.stdout.write([...statusLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])].join('\n') + '\n');
process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok;
}
const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!;
for (const voice of ['native', 'outside'] as const) {
const v = state.phases[phase]![voice]!;
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-decision-trail — append one row to a run's decision trail: a TSV with
* one line per decision (ts, phase, decision, why, evidence, result) that a
* reviewer reads top to bottom after the run. Append-only: a wrong call gets a
* new row that supersedes it, never an edit.
*
* Usage:
* gstack-decision-trail [--run <id>] <file> <phase> <decision> <why> <evidence> <result>
* gstack-decision-trail start <file> <run-id>
* gstack-decision-trail --help
*
* The write rules are a port of pstack's show-me-your-work/scripts/log.sh
* (Copyright Lauren Tan, MIT; https://github.com/cursor/plugins,
* pstack/skills/show-me-your-work at 99b609a7), in TypeScript: the header is
* written once and only ever appended, cells lose their tabs and line breaks,
* and a cell whose first byte a spreadsheet would run as a formula (=, +, -, @)
* gets a leading apostrophe, so evidence taken from PR titles, filenames or
* generated text cannot execute when a reviewer opens the file. License text:
* licenses/MIT-pstack.txt; NOTICE.md carries the entry.
*
* The start-row rule is gstack's mechanisation of the same skill's prose: a run
* (one agent conversation, with its later turns) that writes to a trail already
* holding rows opens with a `start` row naming the rows it did not write, and
* its evidence names the run (`run:<id>`). `start` is idempotent for the same
* id, and an append with `--run <id>` refuses until that run's start row is the
* newest start row in the file (DECISION_TRAIL_START_REQUIRED, exit 3).
*/
import * as fs from "node:fs";
import * as path from "node:path";
const COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const;
const HEADER = COLUMNS.join("\t") + "\n";
const USAGE = `Usage:
gstack-decision-trail [--run <id>] <file> <phase> <decision> <why> <evidence> <result>
gstack-decision-trail start <file> <run-id>
gstack-decision-trail --help
Appends one tab-separated row (ts is stamped in UTC) to <file>, writing the
header line first when the file is missing or empty. Cells are flattened to
one line, and a cell starting with =, +, - or @ is prefixed with a quote so a
spreadsheet cannot run it as a formula. Nothing is ever rewritten or removed.
phase the phase or workstream the decision belongs to; 'start' is reserved
decision what was chosen or done, one line, in plain words
why the reason, as you would tell a teammate
evidence a pointer that resolves: commit, PR, file:line, artifact path
result the outcome: tests green, reverted, INCONCLUSIVE, open, ...
start <file> <run-id> appends this run's start row (phase 'start', evidence
'run:<run-id>') naming the rows earlier runs wrote. Running it again for the
same run is a no-op while that start row is still the newest one. A run id is
letters, digits and . _ : @ / - (a Capy jam_ id, a session id, a PR number).
--run <id> on an append refuses with DECISION_TRAIL_START_REQUIRED (exit 3)
when the trail has rows and the newest start row is another run's.
`;
type Row = { line: number; cells: string[] };
function clean(cell: string): string {
const flat = cell.replace(/[\t\n\r]/g, " ");
return /^[=+\-@]/.test(flat) ? `'${flat}` : flat;
}
function readRows(file: string): Row[] {
if (!fs.existsSync(file)) return [];
const lines = fs.readFileSync(file, "utf8").split("\n");
const rows: Row[] = [];
lines.forEach((raw, i) => {
let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
// A repeated header (the append-on-flaky-mount case) is never a row.
if (text === "" || text === HEADER.trimEnd()) return;
rows.push({ line: i + 1, cells: text.split("\t") });
});
return rows;
}
function appendRow(file: string, cells: readonly string[]): number {
const dir = path.dirname(file);
if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
// Append the header too, never truncate: on a flaky mount the worst case is
// one stray header line, not lost rows.
// A file with no header line anywhere (empty, blank, or rows written by hand)
// gets one appended once; readRows skips a header line wherever it sits.
const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, HEADER);
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
return readRows(file).length;
}
function hasHeader(file: string): boolean {
return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n")
.some(line => line.replace(/\r$/, "") === HEADER.trimEnd());
}
function endsWithNewline(file: string, size: number): boolean {
const fd = fs.openSync(file, "r");
try {
const last = Buffer.alloc(1);
fs.readSync(fd, last, 0, 1, size - 1);
return last[0] === 0x0a;
} finally {
fs.closeSync(fd);
}
}
const RUN_ID = /^[A-Za-z0-9._:@/-]+$/;
function checkRunId(runId: string): void {
if (!RUN_ID.test(runId)) fail(`run id must match ${RUN_ID} (letters, digits, . _ : @ / -), got ${JSON.stringify(runId)}`, 2);
}
function newestStart(rows: Row[]): Row | undefined {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i]!.cells[1] === "start") return rows[i];
return undefined;
}
function fail(message: string, code: number): never {
process.stderr.write(`gstack-decision-trail: ${message}\n`);
process.exit(code);
}
const argv = process.argv.slice(2);
// Only a leading --help asks for help: a cell whose text is "-h" is data and gets appended (quoted).
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
process.stdout.write(USAGE);
process.exit(argv.length === 0 ? 2 : 0);
}
if (argv[0] === "start") {
const [, file, runId, ...extra] = argv;
if (!file || !runId || extra.length > 0) fail(`start takes <file> <run-id>\n${USAGE}`, 2);
checkRunId(runId);
const rows = readRows(file);
const newest = newestStart(rows);
if (newest && newest.cells[4] === `run:${runId}`) {
process.stdout.write(`start row for run ${runId} already present at ${file}:${newest.line}\n`);
process.exit(0);
}
const earlier = rows.length === 0
? "no earlier rows"
: `${rows.length} earlier row${rows.length === 1 ? "" : "s"} (${rows[0]!.cells[0]}..${rows[rows.length - 1]!.cells[0]}) written by other runs`;
const line = appendRow(file, ["start", rows.length === 0 ? `run ${runId} opens this trail` : `run ${runId} joins this trail`, earlier, `run:${runId}`, "open"]);
process.stdout.write(`appended start row ${line} for run ${runId} to ${file}\n`);
process.exit(0);
}
let runId: string | undefined;
const positional = [...argv];
while (positional[0]?.startsWith("--")) {
const flag = positional.shift()!;
if (flag === "--run") {
runId = positional.shift();
if (!runId) fail("--run needs an id", 2);
checkRunId(runId);
} else fail(`unknown option ${flag}\n${USAGE}`, 2);
}
if (positional.length !== 6) fail(`expected <file> and five cells, got ${positional.length} argument${positional.length === 1 ? "" : "s"}\n${USAGE}`, 2);
const [file, ...cells] = positional as [string, string, string, string, string, string];
if (cells[0] === "start") fail("phase 'start' is reserved; use: gstack-decision-trail start <file> <run-id>", 2);
if (runId) {
const rows = readRows(file);
if (rows.length > 0 && newestStart(rows)?.cells[4] !== `run:${runId}`) {
fail(`DECISION_TRAIL_START_REQUIRED: ${file} has ${rows.length} rows and run ${runId} has no start row after the newest one; run: gstack-decision-trail start ${file} ${runId}`, 3);
}
}
const line = appendRow(file, cells);
process.stdout.write(`appended row ${line} to ${file}\n`);
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
# gstack-worktree-audit — read-only audit of every git worktree beside the main
# one: size, age of its last commit, whether its head is merged into the base,
# tracked edits vs untracked scratch, remote state, the PR on its branch, the
# newest Claude Code chat that ran in it, and a suggested bucket. One TSV row
# per worktree, largest first. It never deletes, prunes, checks out or edits a
# worktree or a branch; its one write is the best-effort fetch of the base ref
# (skip it with --no-fetch). In a fork workflow, where origin is your fork and
# gh resolves the upstream, every PR is cross-repository and the PR column is -. Removing a worktree stays a step a person takes
# after reading the table.
#
# Ported from pstack's poteto-mode/scripts/worktree-audit.sh (Copyright Lauren
# Tan, MIT; https://github.com/cursor/plugins, pstack/skills/poteto-mode at
# 99b609a7). Changes from the source: the PR column comes from gh alone through
# its own --jq (no jq binary) and ignores fork PRs, LAST_CHAT reads Claude
# Code's per-worktree transcript directory instead of Cursor's, worktree paths
# may contain spaces, a registered worktree whose directory is gone gets a
# `prunable` row, `safe` also requires a clean tree with nothing unpushed, and
# --no-fetch is new. License
# text: licenses/MIT-pstack.txt; NOTICE.md carries the entry.
#
# Usage: gstack-worktree-audit [-b|--base <ref>] [--no-fetch] [repo-path]
# repo-path defaults to the repository the current directory is in.
# -b sets the ref the MERGED column checks against (origin/integration, say).
# Without it the base is origin/HEAD, then origin/main, then origin/master,
# and a warn line says which one was picked: origin/HEAD is the remote's
# default branch, not necessarily the branch PRs merge into.
# --no-fetch skips the best-effort fetch of the base before the merge check.
#
# Columns: SIZE AGE MERGED DIRTY REMOTE PR LAST_CHAT BUCKET WORKTREE
# MERGED YES when the worktree's head is an ancestor of the base. A
# squash-merged branch is never an ancestor, so read PR with it.
# DIRTY clean | clean,ignored:<n> (only gitignored entries: .env, build
# output; the bucket ignores them, removing the worktree would not) |
# wip:<n> (tracked edits) | scratch:<n> (untracked only) |
# missing (registered, directory gone) | broken (the worktree's own
# .git link is gone, so git reads describe another repository) |
# unknown (git status failed)
# REMOTE pushed | ahead<n> | no-remote | detached, against the local
# origin/<branch> tracking ref as last fetched
# PR #<n>/<state> from gh (open, merged, closed; an open PR wins when a
# branch name has several, fork PRs are ignored), or - without gh
# LAST_CHAT date of the newest Claude Code transcript under
# ${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<worktree slug>/, or -
# BUCKET prunable (directory gone and not locked) > review for a locked or
# broken entry > hold-wip (tracked edits) >
# hold-open-pr > verify-recent-chat (a chat in the last 4 days) >
# safe (clean tree, and either merged into the base or its PR is
# closed or merged with every commit pushed) > review (anything
# else: unpushed commits, untracked files, nothing says it is done)
set -u
base=""
fetch=1
while [ $# -gt 0 ]; do
case "$1" in
-b|--base) [ $# -ge 2 ] || { echo "gstack-worktree-audit: $1 needs a ref" >&2; exit 2; }; base="$2"; shift 2 ;;
--no-fetch) fetch=0; shift ;;
-h|--help) sed -n '2,/^set -u/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
-*) echo "gstack-worktree-audit: unknown option: $1" >&2; exit 2 ;;
*) break ;;
esac
done
repo="${1:-$(git rev-parse --show-toplevel 2>/dev/null)}"
[ -z "$repo" ] && { echo "gstack-worktree-audit: not in a git repository; pass a repo path" >&2; exit 1; }
cd "$repo" || exit 1
git rev-parse --git-dir >/dev/null 2>&1 || { echo "gstack-worktree-audit: not a git repository: $repo" >&2; exit 1; }
# The main worktree is the first entry; every other one is a candidate.
main_wt=$(git worktree list --porcelain | sed -n 's/^worktree //p' | head -1)
if [ -z "$base" ]; then
for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do
[ -n "$cand" ] && git rev-parse --verify --quiet "$cand^{commit}" >/dev/null && { base="$cand"; break; }
done
[ -z "$base" ] && { echo "gstack-worktree-audit: no base ref (origin/HEAD, origin/main, origin/master all missing); pass -b <ref>" >&2; exit 1; }
echo "warn: no -b given; MERGED checks against $base" >&2
fi
# A stale base only makes MERGED conservative, so the fetch is best-effort. The
# explicit refspec creates the remote ref even in a single-branch clone.
if [ "$fetch" = 1 ]; then
case "$base" in
origin/*) GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes}" git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \
|| echo "warn: could not fetch $base; MERGED may be stale" >&2 ;;
esac
fi
git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || { echo "gstack-worktree-audit: base ref not found: $base" >&2; exit 1; }
# GNU coreutils reads `stat -f` as file-system status and `date -r` as a file,
# so pick the stat and date spellings once.
if stat -c %Y / >/dev/null 2>&1; then
stat_fmt=(-c '%Y %n'); fmt_day() { date -u -d "@$1" '+%Y-%m-%d'; }
else
stat_fmt=(-f '%m %N'); fmt_day() { date -u -r "$1" '+%Y-%m-%d'; }
fi
# PR state per branch, read once as "<branch>\t<number>\t<state>" lines through
# gh's own --jq; a fork's PR says nothing about a local branch of the same name,
# so cross-repository PRs are dropped. Empty when gh is missing or cannot read
# this repository.
prs=$(mktemp)
trap 'rm -f "$prs"' EXIT
if command -v gh >/dev/null 2>&1; then
gh pr list --state all --limit 1000 --json number,state,headRefName,isCrossRepository \
--jq '.[] | select(.isCrossRepository | not) | [.headRefName, (.number|tostring), (.state|ascii_downcase)] | @tsv' > "$prs" 2>/dev/null \
|| { : > "$prs"; echo "warn: gh could not list this repository's PRs; PR column is -" >&2; }
else
echo "warn: gh not on PATH; PR column is -" >&2
fi
projects="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
now=$(date +%s)
echo "# base: $base"
printf 'SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n'
# One porcelain block per worktree: the path, then flag lines (locked, prunable).
# IFS is a tab so a path keeps its leading and trailing spaces; a path with a
# newline in it cannot be read from this format and is not supported.
git worktree list --porcelain | awk 'BEGIN { RS=""; FS="\n" } {
flags = "-"
for (i = 2; i <= NF; i++) { if ($i ~ /^locked/) flags = flags "L"; if ($i ~ /^prunable/) flags = flags "P" }
print flags "\t" substr($1, 10)
}' | while IFS=$'\t' read -r flags wt; do
[ "$wt" = "$main_wt" ] && continue
# Directory gone: prunable, unless git holds the entry locked (an unmounted
# volume, say), in which case prune would not touch it and the branch's
# unpushed work may still exist elsewhere.
if [ ! -d "$wt" ]; then
case "$flags" in *L*) bucket=review ;; *) bucket=prunable ;; esac
printf '0\t?\t?\tmissing\t?\t-\t-\t%s\t%s\n' "$bucket" "$wt"
continue
fi
# A worktree whose .git file is gone (git marks it prunable) makes every
# git -C read describe whichever repository contains the directory, so its
# own files would be invisible: report it broken, never safe.
case "$flags" in *P*) printf '%s\t?\t?\tbroken\t?\t-\t-\treview\t%s\n' "$(du -sh "$wt" 2>/dev/null | awk '{print $1}')" "$wt"; continue ;; esac
# --show-cdup is empty only when the directory is itself a toplevel; a path
# comparison would misfire where git and the shell spell one path differently.
if [ ! -e "$wt/.git" ] || [ -n "$(git -C "$wt" rev-parse --show-cdup 2>/dev/null || echo broken)" ]; then
printf '%s\t?\t?\tbroken\t?\t-\t-\treview\t%s\n' "$(du -sh "$wt" 2>/dev/null | awk '{print $1}')" "$wt"
continue
fi
size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}')
head=$(git -C "$wt" rev-parse HEAD 2>/dev/null)
head_ts=$(git -C "$wt" log -1 --format='%ct' HEAD 2>/dev/null || echo 0)
age=$([ "$head_ts" -gt 0 ] 2>/dev/null && echo "$(( (now - head_ts) / 86400 ))d" || echo "?")
git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no
# Tracked edits are work in progress; untracked files alone are scratch. A
# clean tree still names its ignored entries (.env, build output): they do
# not change the bucket, but they are what removing the worktree would lose.
# The flags pin what status reports: a status.showUntrackedFiles=no config
# would otherwise hide untracked files and let a worktree holding them read clean.
if porcelain=$(git -C "$wt" status --porcelain --untracked-files=normal --ignore-submodules=none 2>/dev/null); then
if [ -z "$porcelain" ]; then
ignored=$(git -C "$wt" ls-files --others --ignored --exclude-standard --directory 2>/dev/null | grep -c .)
[ "$ignored" -gt 0 ] && dirty="clean,ignored:$ignored" || dirty=clean
elif printf '%s\n' "$porcelain" | grep -qv '^??'; then
dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')"
else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi
else dirty=unknown; fi
# refs/heads/ is stripped by hand: --short prints heads/<name> when a tag
# shares the branch's name, and then nothing downstream would match.
branch=$(git -C "$wt" symbolic-ref --quiet HEAD 2>/dev/null | sed 's#^refs/heads/##')
if [ -z "$branch" ]; then remote=detached
elif git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
[ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \
&& remote=pushed \
|| remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)"
else remote=no-remote; fi
# Several PRs can share a branch name over time; an open one describes the
# branch as it is now, so it wins over a closed or merged one.
pr="-"
[ -n "$branch" ] && [ -s "$prs" ] && pr=$(awk -F'\t' -v b="$branch" \
'$1==b { if ($3=="open" || $3=="draft") { print "#" $2 "/" $3; exit } if (first=="") first="#" $2 "/" $3 } END { if (first!="") print first }' "$prs" | head -1)
[ -z "$pr" ] && pr="-"
# Newest transcript in this worktree's own Claude Code project directory
# (the path with every non-alphanumeric byte replaced by -, as Claude names it).
last="-"; last_ts=0
chat_dir="$projects/$(printf '%s' "$wt" | sed 's/[^A-Za-z0-9]/-/g')"
if [ -d "$chat_dir" ]; then
f=$(find "$chat_dir" -type f -print0 2>/dev/null | xargs -0 stat "${stat_fmt[@]}" 2>/dev/null | sort -rn | head -1)
if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}'); last=$(fmt_day "$last_ts" 2>/dev/null); fi
fi
recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no)
# safe means nothing in the worktree exists only there: a clean tree whose
# head is in the base, or whose PR is closed or merged with every commit
# pushed. Unpushed commits or untracked files on a merged branch are review.
case "$dirty" in wip:*) bucket=hold-wip ;; *)
case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *)
if [ "$recent" = yes ]; then bucket=verify-recent-chat
elif [ "${dirty%%,*}" = clean ] && { [ "$merged" = YES ] || { [ "$pr" != "-" ] && [ "$remote" = pushed ]; }; }; then bucket=safe
else bucket=review; fi ;;
esac ;;
esac
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt"
done | sort -t"$(printf '\t')" -k1,1 -rh
+6 -4
View File
@@ -53,9 +53,10 @@ gstack-approvals render # the ## Approvals se
- **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.
is also mirrored to `GSTACK_APPROVALS_DIR`, else to the `approvals/` directory in the mounted Captain
drive (`<captain drive>/approvals/<slug>/`, readable by the whole crew), else to the private project-user
Drive folder. `gstack-approvals path` prints the chosen mirror and `READ_ALSO:` roots, which reads still
merge. `GSTACK_APPROVALS_MIRROR=0` turns the mirror off, 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
@@ -94,7 +95,8 @@ 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, and closes with the "do it" reply. The last line, "For the relaying agent", carries the run, `gate_rev`, `rec_hash` and the exact
words, and closes with the "do it" reply, then a `### Cost` section (spent so far, worst case, and the estimate for
the phases `rec` can reopen). 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
+30
View File
@@ -172,3 +172,33 @@ Option C (this section) keeps secret-distribution scope tight.
If the user asks you to skip the move (e.g., "just leave it as a fork PR"),
respect that — eval CI will fail with empty-env auth, but check-freshness,
workflow-lint, and windows-tests will still pass on the fork PR.
## Auditing Conductor worktrees
Conductor workspaces are git worktrees, so after a few weeks of parallel
lanes the checkout carries worktrees nobody remembers. `bin/gstack-worktree-audit
[-b origin/<base>] [--no-fetch] [repo-path]` prints one TSV row per worktree
beside the main one, largest first: size, age of the last commit, whether its
head is merged into the base, `clean` / `clean,ignored:<n>` (only gitignored
entries such as `.env` or build output) / `wip:<n>` (tracked edits) /
`scratch:<n>` (untracked only) / `missing` (registered, directory gone) /
`broken` (below) / `unknown` (`git status` failed), `pushed` / `ahead<n>` / `no-remote` /
`detached` (against the local `origin/<branch>` tracking ref as last fetched),
the PR on its branch through `gh` (`#n/open|merged|closed`, or `-`; a fork's
PR on a same-named branch is ignored, so in a fork workflow where `origin` is
your fork the column is `-`, and an open PR wins over an older closed one on a
reused branch name), the date of the newest Claude Code chat that ran in it,
and a bucket: `prunable` (registered, directory gone, not locked), then
`review` for a locked entry whose directory is gone or a `broken` worktree
whose own `.git` link is missing, then `hold-wip`, then `hold-open-pr`, then
`verify-recent-chat` (a chat in the last four days), then `safe`, else
`review`. `safe` means nothing tracked or untracked exists only in that
worktree: a clean tree whose head is merged into the base, or whose PR is
closed or merged with every commit pushed; an unpushed commit or an untracked
file on a merged branch is `review`. Ignored entries do not change the bucket,
but removing the worktree removes them too, so read the `ignored:<n>` count
before you do. A squash-merged branch is never an ancestor of the base, so read
`MERGED` together with `PR`. The audit only reads, apart from a best-effort fetch
of the base ref that `--no-fetch` skips; removing a worktree
(`git worktree remove <path>`) stays your call after the table. `/landing-report` points at it when the sibling table shows
stale worktrees.
+5 -2
View File
@@ -488,8 +488,11 @@ 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
`gstack-approvals` (mirrored to the Captain's drive `approvals/` directory when the
Captain created one, so the crew can read every lane's record), and /ship's PR body opens
with before/after images (`gstack-pr-shots`), Why and Approvals. Every autoplan gate
prints its cost (`GATE_SPEND:`, `GATE_REC_COST:`; `autoplan/DRIVER.md` says how to relay
them). The guide is
[`capy/skill/gstack/captain.md`](../capy/skill/gstack/captain.md);
`GSTACK_CAPY_ASKS=0` drops the block for one session. The controller's side (the
READY contract, merge trains, the digest, the daily flaky pass, with templates for the
+6 -4
View File
@@ -312,7 +312,7 @@ closed) holds the longer history behind these numbers; from here on
|---|---|---|
| Install and health | `./setup`, `gstack-doctor`, `gstack-upgrade` | the user-scope skill folder, `scripts/install.sh --check` and its trailer, the project Setup snapshot |
| Running a workflow | the SKILL.md files, `GSTACK_SESSION_KIND=unattended`, `GSTACK_RESULT:` lines | the `GSTACK_LAUNCH` assignments, `GSTACK_CAPY=1` and the `capy-asks` block, no enforced hooks (safety skills are advisory) |
| Approvals | `gstack-approvals`, `gstack-owner-brief render --captain`, the `## Approvals` table | the Captain relay (`needs_you`), the private Drive mirror |
| Approvals | `gstack-approvals`, `gstack-owner-brief render --captain`, the `## Approvals` table | the Captain relay (`needs_you`), the Captain drive `approvals/` mirror |
| Pull requests | `/ship`, the ship receipt, `gstack-pr-shots` | `tools.pr.create` (callbacks), `tools.pr.uploadAsset` (before/after images) |
| Merging | `gstack-ship-queue` (ready, train build, verdict, verify), `gstack-restamp`, `gh pr merge --match-head-commit` | the controller is a Captain thread; slot calls are thread messages |
| Flaky pass | the rerun rule, `flake-records-a*` and `flake-ledger-*` artifacts, `test:health` | the daily automation and the ledger in the Captain's Drive |
@@ -403,9 +403,11 @@ harness on it as of that date, with the workaround the crews use.
- An autoplan gate inside a crew thread costs a Captain round trip even when
the owner's standing answer is "take every recommendation"; record the
standing answer once with `gstack-approvals` and cite it. Approval rows
mirrored to the project-user Drive are not readable by other crew threads,
so the Captain carries the text into PR bodies.
standing answer once with `gstack-approvals` and cite it. Rows mirror to the
Captain drive's `approvals/` directory when the
Captain created one, so other crew threads can read them; without it they
stay in the private project-user Drive and the Captain carries the text into
PR bodies.
**Harness gaps the same files name (fixed or owned in gstack, listed so the
reader knows the date):** the pregate failing every touched file in a
+2
View File
@@ -12,6 +12,8 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-code-intelligence` | **Code-intelligence provider picker** — wraps GBrain, Sourcebot, and Graphify behind one interface: `options`/`status` to see what's available, `select` to pick one, `index`/`search` to use it, `suggest` to check whether the one-time indexing offer should fire here. The offer triggers on large repos (1,000+ tracked files; a decline is persisted). Non-local providers refuse to index *or search* until you record per-repo consent (`consent <repo> yes\|no` — the query text is repo-derived content), the per-repo trust policy's deny and read-only tiers veto write-class operations regardless of consent, and every off-machine send writes an egress receipt. Fully optional — with nothing selected, gstack falls back to grep. |
| `gstack-verify-gate` | **Verification stop hook (opt-in)** — blocks a Claude Code turn from ending until the project's declared verify command passes (after 3 blocked re-entries it yields with a loud still-RED warning instead of looping forever). Declare it on one line in CLAUDE.md: `<!-- gstack:verify: bun test -->`. Hooks bypass the permission system, so a declared command never runs until you trust it once per repo (`gstack-verify-gate --trust`); editing the command invalidates trust until re-granted, and every grant is audit-logged. `./setup` never registers it for you — opt in with `gstack-settings-hook add-event --event Stop --command ~/.claude/skills/gstack/bin/gstack-verify-gate --source verify-gate`, remove with `gstack-settings-hook remove-source --source verify-gate`. |
| `gstack-memorable` | **Memorable recall bridge (opt-in, third party, Claude Code only)** — connects Claude Code to the external [Memorable](https://memorable.sh) CLI *through gstack* instead of the vendor's own installer, so the hook gets gstack's guarantees: an explicit consent key (`memorable_recall`, off by default, listed by `gstack-egress grants`), a fail-closed egress receipt for every prompt handed over (`gstack-egress list --sink memorable-recall`), a HIGH-tier secret pre-scan, a trust envelope and 8 KiB cap on whatever comes back, an allowlisted environment and process-group containment for the vendor process, and clean removal. `enable` registers the hook at the stable install with a 5 s timeout and never runs the vendor's own consent command; `disable` revokes the gate first and removes the entry by identity even after Claude Code strips the tag; `status` is read-only. gstack never installs Memorable, and what its binary sends is the vendor's claim, not gstack's. Not available on Windows yet. [Full guide](memorable-workflow-memory.md). |
| `gstack-decision-trail` | **Decision trail** — append one row (ts, phase, decision, why, evidence, result) to a run's TSV trail so a reviewer reads what was chosen and why without the transcript. Header once, append-only, cells flattened to one line, and a cell starting with `=`, `+`, `-` or `@` quoted so a spreadsheet cannot run it. `start <file> <run-id>` opens a run's stretch of a shared trail (idempotent; `start` is reserved as a phase name), and `--run <id>` refuses an append until that start row is the newest. Ported from pstack's `log.sh` (MIT, NOTICE.md). [Rule](unattended.md#the-decision-trail). |
| `gstack-worktree-audit` | **Worktree audit** — read-only table of every git worktree beside the main one, largest first: size, age, merged into the base or not, `clean`/`clean,ignored:<n>`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh` (fork PRs ignored), the newest Claude Code chat in it, and a bucket (`prunable` for an unlocked registered worktree whose directory is gone, `review` for a locked or `broken` one, `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe` only for a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed, else `review`). `-b origin/<branch>` sets the base, `--no-fetch` skips the best-effort fetch of the base ref (its one write). Never deletes. Ported from pstack's `worktree-audit.sh` (MIT, NOTICE.md). [Guide](CONTRIBUTOR_REFERENCE.md#auditing-conductor-worktrees). |
| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. |
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
+35 -2
View File
@@ -1741,8 +1741,11 @@ from "the record was lost with the old machine". `render` still prints the secti
"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 Drive mirror resolvable: `GSTACK_APPROVALS_DIR`, an `approvals/` directory in the one
mounted Captain drive (`~/.capy/drive/captain-*/approvals`, readable by the whole crew), or one
`~/.capy/drive/project-user-*` folder. `gstack-approvals path` prints which rule chose the mirror
and, as `READ_ALSO:`, the other roots it still reads (a row mirrored before the Captain created
`approvals/` stays visible).
<a id="approval-record-write-failed"></a>
### `gstack-approvals: ... (APPROVAL_RECORD_WRITE_FAILED)`
@@ -4914,3 +4917,33 @@ final summary. Rename or move yours, or switch modes (`./setup --prefix` /
a generated gstack SKILL.md and then edited it, that file is moved to
`~/.gstack/backups/skills/<timestamp>/<skill>/SKILL.md` before gstack's is
linked in, never deleted.
## gstack-decision-trail and gstack-worktree-audit
<a id="decision-trail-start-required"></a>
### `gstack-decision-trail: DECISION_TRAIL_START_REQUIRED: <file> has <n> rows and run <id> has no start row after the newest one`
You appended with `--run <id>` to a trail that other runs already wrote, and
the newest `start` row in it is not this run's. A run (one agent
conversation, with its later turns) that joins a trail opens with its own
`start` row so a reviewer can tell whose rows are whose. Run
`gstack-decision-trail start <file> <id>` once and append again; the start
command is a no-op while this run's start row is still the newest, so a rerun
of the same script is safe. Nothing was written. See
[docs/unattended.md](unattended.md#the-decision-trail).
### `gstack-worktree-audit` prints `warn: no -b given; MERGED checks against origin/HEAD` or every row says `MERGED no`
Without `-b` the audit compares each worktree's head against the remote's
default branch (then `origin/main`, then `origin/master`), which is not
always the branch PRs merge into; pass `-b origin/<branch>`. A squash-merged
branch is never an ancestor of the base, so `MERGED no` beside `PR #n/merged`
is normal and the bucket is still `safe` while the tree is clean and every
commit is pushed; an unpushed commit or an untracked file on that branch makes
it `review`, because deleting the worktree would lose it. The audit only reads
(its one write is the base fetch, which `--no-fetch` skips);
a worktree it calls `safe` is still yours to remove, and a `prunable` row names
a registered, unlocked worktree whose directory is already gone (`git worktree
prune`); a locked one, or a `broken` row whose `.git` link is missing, reads
`review` instead. `clean,ignored:<n>` means the tree is clean but holds
gitignored entries (`.env`, build output) that go with the worktree.
+44 -2
View File
@@ -358,7 +358,9 @@ the plan.
so a gate an agent closed is distinguishable from one the owner closed.
`gstack-autoplan recommend` changes one recommendation before relay; it
bumps the gate's `rec_rev`, and a later `rec` needs the `--rec-hash` from
the render the owner saw (`GATE_REC_STALE`).
the render the owner saw (`GATE_REC_STALE`). Every `GATE:` line is followed
by `GATE_SPEND:` and `GATE_REC_COST:` (also `gate_cost` in `--json`; see
`autoplan/DRIVER.md`).
A user challenge both voices raised on a confirmed finding (a title match,
not file:line proximity alone, which is `unverified`) with the same
`changes` value is recommended as `b` only with one shared proposal: a
@@ -490,7 +492,9 @@ replaces the generated sentence with the run's own), then the numbered
decisions through the gate list: seven per page (`--page N`), stable ids,
each `auto` item annotated with the default taken if unanswered, each
`approval` item `pending until you answer` or `decided <id><letter> on <date>`.
The last line names `review-record.md`, where the full review lives, and the
It carries a `Cost so far:` sentence (spent, worst case, cap, and the estimate for
the phases `rec` can reopen); `render --captain` puts it in a `### Cost` section above
the relaying-agent trailer. The last line names `review-record.md`, where the full review lives, and the
`gstack-autoplan answer` call that answers the page. `--write` also saves
`brief.md` in the run directory. `/autoplan` Phase 4 presents this output
verbatim and writes the former long report to `review-record.md`.
@@ -543,3 +547,41 @@ fix. `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (`INSTALL_VERDICT:
overridden`), recorded as a deviation. TypeScript entrypoints use
`checkInstallAdmission` from `lib/install-verdict.ts`; the Bash side is
`bin/gstack-install-verdict.sh`. See [capy-install.md](capy-install.md#the-launch-line).
## The decision trail
A long or unattended run keeps one tab-separated trail of its decisions at
`<run dir>/decisions.tsv`, written through `gstack-decision-trail <file>
<phase> <decision> <why> <evidence> <result>`, so the parent reads what the run
chose and why without the transcript. The helper stamps the time, writes the
header once, flattens each cell to one line and quotes a cell that starts
with `=`, `+`, `-` or `@`, so evidence lifted from a PR title or a filename
cannot run as a formula when a reviewer opens the file in a spreadsheet.
Log the forks, not the keystrokes: an approach chosen or dropped, a unit done
with its verification result, a revert and what triggered it, a blocker, one
row per iteration of a loop. Evidence is a pointer that resolves (a commit, a
PR, `file:line`, an artifact path), never prose. A run that joins a trail
other runs wrote (a pickup after a crash, a replacement machine, a later turn
of the same thread) first runs `gstack-decision-trail start <file> <run id>`,
which appends a `start` row naming the earlier rows and the run; appends with
`--run <id>` refuse (`DECISION_TRAIL_START_REQUIRED`) until that row is the
newest start row, and `start` is a no-op when it already is, so a rerun of the
same step writes nothing twice.
Before it hands back, the run audits the trail against what it did: every row
of its own stretch (from its `start` row to the next run's) maps to a real
decision or action, every evidence pointer resolves and shows what the row
claims, and a fork or abandoned approach that shaped the result but is not
logged gets a row. The audit corrects the log, never the story: no row is
edited or removed, an invented or wrong row stays and a new row supersedes it
with what actually happened. Then a reviewer on a different model family from
the one that did the work (a Capy subagent on another family, or the outside
voice) reads the trail and the transcript and flags what the owner should look
at: decisions with weak or missing evidence, verification claimed without
proof, choices that look risky in hindsight, gaps a skim would miss. The run's
final report ends with an `Attention` section that opens with `reviewed by
<model>` on its own line and lists each flag against its rows; `No flags` is a
valid body, a missing model line is not. Self-review does not stand in for
this. `decisions.jsonl` stays the typed record `gstack-artifact` validates; the
trail is the plain one a person reads, and `column -s$'\t' -t decisions.tsv`
renders it in a terminal.
+2
View File
@@ -478,6 +478,8 @@ After rendering the table, suggest ONE of:
3. **If everything looks clean:**
"Queue is clean. Next /ship will claim a slot without conflict."
If the sibling table shows worktrees whose last commit is weeks old, offer `bin/gstack-worktree-audit` (read-only: size, merge state, tracked edits, remote, PR, last chat and a suggested bucket per worktree; it removes nothing).
---
## Plan Mode
+2
View File
@@ -156,6 +156,8 @@ After rendering the table, suggest ONE of:
3. **If everything looks clean:**
"Queue is clean. Next /ship will claim a slot without conflict."
If the sibling table shows worktrees whose last commit is weeks old, offer `bin/gstack-worktree-audit` (read-only: size, merge state, tracked edits, remote, PR, last chat and a suggested bucket per worktree; it removes nothing).
---
## Plan Mode
+42 -13
View File
@@ -15,14 +15,18 @@
* 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.
* every write is mirrored: to GSTACK_APPROVALS_DIR when set, else to the
* directory the Captain named by creating `approvals/` in its drive
* (`~/.capy/drive/captain-<name>/approvals/<slug>/<branch key>.jsonl`, which every
* crew thread can read), else to the private project-user Drive folder
* (`<drive>/approvals/<slug>/<branch key>.jsonl`). 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 os from 'node:os';
import * as path from 'node:path';
import { capyDriveFolder } from './autoplan-checkpoint';
import { reviewedPlan } from './captain-brief';
@@ -40,7 +44,9 @@ 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 type MirrorSource = 'GSTACK_APPROVALS_DIR' | 'captain drive' | 'project-user drive';
/** `mirror` is written and read; `read_also` are the other mirror roots' copies, read so a row mirrored under an earlier rule stays visible. */
export interface StoreLocation { file: string; mirror?: string; mirror_source?: MirrorSource; read_also?: string[]; branch: string; slug: string; ephemeral: boolean }
export interface RecordError { code: ResultCodeName; message: string }
export type Env = NodeJS.ProcessEnv;
@@ -70,16 +76,39 @@ export function locate(o: { cwd: string; env: Env; branch?: string }): StoreLoca
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') {
// Every mirror root this machine can see, best first; the first is written, the rest are read so a
// row mirrored before the Captain created approvals/ (or under an explicit directory) stays visible.
const roots: Array<[MirrorSource, string]> = [];
if (o.env.GSTACK_APPROVALS_DIR) roots.push(['GSTACK_APPROVALS_DIR', o.env.GSTACK_APPROVALS_DIR]);
if (o.env.GSTACK_CAPY === '1' && o.env.GSTACK_APPROVALS_MIRROR !== '0') {
const captain = captainApprovalsDir(o.env);
if (captain) roots.push(['captain drive', captain]);
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');
if (folder) roots.push(['project-user drive', 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' };
const rel = path.join(slug, `${branchKey(branch)}.jsonl`);
for (const [, root] of roots) if (!contained(root, path.join(root, rel))) return { code: 'APPROVAL_RECORD_INVALID', message: `mirror path escapes ${root}` };
const [chosen, ...rest] = roots;
return {
file, mirror: chosen ? path.join(chosen[1], rel) : undefined, mirror_source: chosen?.[0], read_also: rest.map(([, root]) => path.join(root, rel)),
branch, slug, ephemeral: o.env.GSTACK_EPHEMERAL === '1',
};
}
/**
* The approvals directory a Captain named for its crew: exactly one mounted Captain drive
* (`~/.capy/drive/captain-*`) holding an `approvals/` directory. Two Captains with one each
* is ambiguous, so neither is taken and the private project-user folder stays the mirror.
*/
export function captainApprovalsDir(env: Env): string | undefined {
if (env.GSTACK_CAPY !== '1') return undefined;
const drive = path.join(env.HOME || os.homedir(), '.capy', 'drive');
let found: string[];
try {
found = fs.readdirSync(drive).filter(d => d.startsWith('captain-')).map(d => path.join(drive, d, 'approvals')).filter(p => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
} catch { return undefined; }
return found.length === 1 ? found[0] : undefined;
}
function readRows(file: string | undefined): ApprovalRow[] {
@@ -99,7 +128,7 @@ 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);
for (const r of [...(loc.read_also ?? []).flatMap(readRows), ...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';
+110
View File
@@ -0,0 +1,110 @@
/**
* autoplan-gate-cost — what a gate costs so far and what taking every
* recommendation would add (plan E16). Every gate-producing command prints the
* two lines, and the owner brief carries the same figures, so an approval is
* never asked without its price. gstack prices what it reserved and settled in
* `<out>/spend.json` (lib/spend-ledger.ts); a host subagent's actual spend is
* `unknown` to it (kept at its reservation in worst_case), so the line says so
* and points at the host's own cost view instead of inventing a credit figure.
* The reopen estimate is an upper bound (`recReopens`): two voices per reopened
* or newly added phase when the outside voice is on, at the per-voice estimate
* the run has been reserving.
*/
import * as path from 'node:path';
import { PHASES } from './autoplan-prompts';
import { readDecisions } from './autoplan-state';
import type { GateList } from './gate-list';
import { readLedger, summarize, type SpendSummary } from './spend-ledger';
export interface GateCost {
summary: SpendSummary;
/** Attempts the ledger has seen: settled plus still reserved. */
attempts: number;
/** Phases a `rec` reply would reopen through its plan-changing recommendations, in run order; empty when none changes the plan. */
reopens: string[];
voices: number;
per_voice_usd: number;
/** The reopen estimate: voices × per-voice. */
rec_more_usd: number;
}
export interface GateCostRun { phase_order: string[]; outside_enabled: boolean; spend_cap_usd: number | null; scope_flags?: Record<string, boolean | undefined> }
const usd = (n: number) => n.toFixed(2);
/**
* The phases a `rec` can reopen, as an upper bound from the answer and revise rules. The effective
* choice per item is what `rec` would record (parseReply's order): an answer already given at this
* gate revision (`prior`, as answerGate reads it from decisions.jsonl), else the carried choice,
* else the recommendation. A recorded plan rerun (`p1d`) reopens every phase; a reject or stop
* (`p1c`/`p1e`) reopens nothing; an override of the recommendation counts as answerGate counts it. A plan
* change is absorbed by `revise`, which reopens every phase whose snapshot the edited plan changes
* and re-detects scope, so it can add a phase the run skipped: every phase the scope flags do not
* pin off is counted (`--no-ui` keeps Design out, `--no-dx` keeps DX out). A round grant (`cap`
* item, option `b`) reopens its phase and Eng. Phases already at their cap are not reopened by
* revise, but the bound does not subtract them: the gate lists their delta as its own item instead.
*/
export function recReopens(list: GateList, run: Pick<GateCostRun, 'phase_order' | 'scope_flags'>, prior: Record<string, string> = {}): string[] {
const flags = run.scope_flags ?? {};
const bound = PHASES.filter(p => run.phase_order.includes(p) || (p === 'design' ? flags.ui !== false : p === 'dx' ? flags.dx !== false : true));
// The effective choice is parseReply's: an answer recorded at this gate, else the carried choice, else the recommendation.
const choiceOf = (item: GateList['items'][number]) => prior[item.id] ?? item.carried?.chosen ?? item.recommended;
const plan = list.items.find(i => i.id === 'p1');
const planChoice = plan ? choiceOf(plan) : undefined;
if (planChoice === 'c' || planChoice === 'e') return [];
const named = new Set<string>(planChoice === 'd' ? run.phase_order : []);
for (const item of list.items) {
if (item.id === 'p1') continue;
const choice = choiceOf(item);
if (item.carried && choice === item.carried.chosen) continue;
if (item.cap && choice === 'b') { named.add(item.cap.phase); if (run.phase_order.includes('eng')) named.add('eng'); continue; }
if (item.reopens === false) continue;
if (item.options.find(o => o.key === choice)?.changes_plan ?? choice !== item.recommended) for (const p of bound) named.add(p);
}
return PHASES.filter(p => named.has(p));
}
/** The answers already recorded at this gate revision, keyed as parseReply's `prior` is (item id → option). */
export function priorChoices(outDir: string, gateRev: number): Record<string, string> {
return Object.fromEntries(readDecisions(outDir).filter(r => r.gate_rev === gateRev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
}
export function gateCost(outDir: string, list: GateList, run: GateCostRun, o: { defaultPerVoiceUsd?: number } = {}): GateCost {
const ledger = readLedger(path.join(outDir, 'spend.json'), run.spend_cap_usd);
const summary = summarize(ledger);
const seen = [...ledger.settled.map(s => s.reserved_usd), ...ledger.reserved.map(r => r.estimate_usd)].filter(n => n > 0);
const per_voice_usd = seen.length ? seen[seen.length - 1]! : o.defaultPerVoiceUsd ?? 0;
const reopens = recReopens(list, run, priorChoices(outDir, list.gate_rev));
const voices = reopens.length * (run.outside_enabled ? 2 : 1);
return { summary, attempts: ledger.settled.length + ledger.reserved.length, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
}
/** The two printed lines: the ledger at this gate, and the price of `rec`. */
export function renderGateCost(c: GateCost): string[] {
const s = c.summary;
const cap = s.cap_usd === null ? 'none' : usd(s.cap_usd);
const headroom = s.headroom_usd === null ? 'none' : usd(s.headroom_usd);
const unknown = s.unknown_count ? `; ${s.unknown_count} host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host's cost view)` : '';
const after = s.headroom_usd === null ? '' : ` → headroom ${usd(s.headroom_usd - c.rec_more_usd)}${s.headroom_usd - c.rec_more_usd < 0 ? ' (over the cap: raise it with gstack-autoplan budget before answering rec)' : ''}`;
return [
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${unknown}`,
c.reopens.length
? `GATE_REC_COST: rec reopens up to ${c.reopens.join(',')} ≈ ${c.voices} voice(s) × ${usd(c.per_voice_usd)} = +${usd(c.rec_more_usd)} at most (the run's per-voice reservation)${after}`
: 'GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00',
];
}
/** The JSON payload beside the printed lines. */
export function gateCostPayload(c: GateCost): Record<string, unknown> {
const s = c.summary;
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
}
/** One sentence for the owner brief. */
export function gateCostSentence(c: GateCost): string {
const s = c.summary;
const cap = s.cap_usd === null ? 'no cap set' : `cap ${usd(s.cap_usd)}, headroom ${usd(s.headroom_usd!)}`;
const unknown = s.unknown_count ? `, ${s.unknown_count} host attempt(s) unknown and counted at reservation` : '';
const rec = c.reopens.length ? `Taking every recommendation reopens up to ${c.reopens.join(', ')}: at most +${usd(c.rec_more_usd)} more.` : 'Taking every recommendation reopens nothing: no further spend.';
return `Cost so far: spent ${usd(s.spent_usd)}, worst case ${usd(s.worst_case_usd)} (${cap}${unknown}; ${c.attempts} reviewer attempt(s)). ${rec}`;
}
+4 -1
View File
@@ -13,6 +13,7 @@
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { gateCost, gateCostSentence } from './autoplan-gate-cost';
import { recHash, type GateItem } from './gate-list';
import { renderUrgentBlock } from './headless-artifacts';
import { findSection, firstTitle } from './markdown-sections';
@@ -104,7 +105,9 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
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');
// The price sits above the trailer: the relaying agent sends everything above that line, so the person approving sees it.
const cost = inputs.run ? ['### Cost', '', gateCostSentence(gateCost(o.outDir, list, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null, scope_flags: inputs.run.scope_flags })), ''] : [];
const text = [...urgent, ...header, '', ...body, ...need, ...cost, '---', '', trailer, ''].join('\n');
return { text, missing, pending, recHash: hash };
}
+2
View File
@@ -12,6 +12,7 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { gateCost, gateCostSentence } from './autoplan-gate-cost';
import { decisionId, PAGE_SIZE, renderGate, type GateList } from './gate-list';
import { readJsonl, renderUrgentBlock, type DecisionRow } from './headless-artifacts';
import type { ResultCodeName } from './result-codes';
@@ -85,6 +86,7 @@ export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: s
'### Summary',
'',
plainSummary(inputs, o.summary),
...(o.outDir && inputs.run ? ['', gateCostSentence(gateCost(o.outDir, inputs.gate, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null, scope_flags: inputs.run.scope_flags }))] : []),
'',
'### Decisions',
'',
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lauren Tan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.267",
"version": "1.91.270",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+4 -3
View File
@@ -3,7 +3,8 @@
* parent agent reads to drive /autoplan through `gstack-autoplan`. It is
* generated from autoplan/DRIVER.md.tmpl by resolvers that read the code
* (phase order, scope flags, run files, RESULT FORMAT, result codes, the
* coordinator contract), stays under 160 lines with no harness vocabulary,
* coordinator contract), stays at or under 162 lines (160 until the decision
* trail paragraph joined the coordinator contract section) with no harness vocabulary,
* is in the generator's normal, out-dir and dry-run inventories, and is
* found from an installed root through its recorded `.source-path`.
*/
@@ -47,8 +48,8 @@ describe('autoplan/DRIVER.md content comes from the code', () => {
}
});
test('at most 160 lines and no harness vocabulary', () => {
expect(doc.split('\n').length).toBeLessThanOrEqual(160);
test('at most 162 lines and no harness vocabulary', () => {
expect(doc.split('\n').length).toBeLessThanOrEqual(162);
for (const term of ['PreToolUse', 'PostToolUse', 'SessionStart', 'AskUserQuestion', 'Skill tool']) expect(doc).not.toContain(term);
});
+219
View File
@@ -0,0 +1,219 @@
/**
* Plan E16/E22 (lane F, autoplan gates):
* E16 every gate prints its cost: `GATE_SPEND:` from the run's ledger and
* `GATE_REC_COST:` for the phases a `rec` reopens (an upper bound priced on
* the effective choices, as `answer` reads them); the owner brief carries
* the same sentence. Host-subagent attempts are priced unknown, never zero.
* E22 the approvals mirror honours the directory a Captain named by creating
* `approvals/` in its drive, between GSTACK_APPROVALS_DIR and the private
* project-user folder; `gstack-approvals path` names the rule.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildRow, captainApprovalsDir, locate, readRecord, type ApprovalRow } from '../lib/approval-record';
import { gateCost, priorChoices, recReopens, renderGateCost, gateCostSentence } from '../lib/autoplan-gate-cost';
import type { GateList } from '../lib/gate-list';
import { attemptsOf, autoplan, env, readRun, resultOf, review, ROOT, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-gate-cost-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
const keep = { key: 'a', text: 'keep your direction', changes_plan: false };
const change = { key: 'b', text: 'accept the change', changes_plan: true };
const list = (recommended: 'a' | 'b'): GateList => ({
schema_version: 1, run: 'r', gate_rev: 1, items: [
{ id: 'uc1', title: 'challenge', options: [keep, change], recommended, kind: 'approval', phase: 'ceo' },
{ id: 'd1', title: 'auto', options: [keep, change], recommended: 'a', kind: 'auto', phase: 'design' },
{ id: 'p1', title: 'Plan approval', options: [{ key: 'a', text: 'approve as-is' }], recommended: 'a', kind: 'approval' },
],
});
const row = (over: Partial<Parameters<typeof buildRow>[0]> = {}, branch = 'main'): ApprovalRow => {
const r = buildRow({ ask: 'autoplan gates', words: 'assume Garry will take the recommendations', by: 'Garry (user-scope AGENTS.md)', scope: 'autoplan gates: take the recommendations', outcome: 'standing', date: '2026-10-20', source: 'AGENTS.md', ...over }, branch);
if ('code' in r) throw new Error(r.message);
return r;
};
describe('E16: the gate prints what it cost and what rec adds', () => {
test('recReopens is the upper bound of the answer and revise rules: a plan change counts every phase revise can keep or add, a recommended round grant its phase plus Eng, nothing otherwise', () => {
const pinned = { ui: false, dx: false };
expect(recReopens(list('b'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual(['ceo', 'design', 'eng']);
expect(recReopens(list('a'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual([]);
// revise re-detects scope: a run that skipped Design and DX without pinning them off can gain both.
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'] })).toEqual(['ceo', 'design', 'dx', 'eng']);
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: { ui: false } })).toEqual(['ceo', 'dx', 'eng']);
// an answer already recorded at this gate wins over the recommendation, as rec itself keeps it
expect(recReopens(list('a'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
const carried = list('b'); carried.items[0]!.carried = { from: 'x', chosen: 'b', gate_rev: 1 };
expect(recReopens(carried, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
// a carried choice overridden at this gate is a change again; an override of a non-changing recommendation counts as answerGate counts it
const carriedA = list('a'); carriedA.items[0]!.carried = { from: 'x', chosen: 'a', gate_rev: 1 };
expect(recReopens(carriedA, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
const plain = list('a'); plain.items[0]!.options = [{ key: 'a', text: 'keep' }, { key: 'b', text: 'other' }];
expect(recReopens(plain, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
// the plan approval: a recorded rerun reopens every phase; a reject or stop reopens nothing
const rerun = list('a'); rerun.items[2]!.options.push({ key: 'd', text: 'rerun every phase' }, { key: 'e', text: 'reject' });
expect(recReopens(rerun, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'd' })).toEqual(['ceo', 'eng']);
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'e' })).toEqual([]);
const agent = list('b'); agent.items[0]!.reopens = false;
expect(recReopens(agent, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
const grant = list('a');
grant.items.splice(1, 0, { id: 'c1', title: 'design at its round cap', options: [{ key: 'a', text: 'accept as deferred', changes_plan: false }, { key: 'b', text: 'one more round', changes_plan: false }], recommended: 'b', kind: 'approval', phase: 'design', cap: { phase: 'design', kind: 'deferred' } });
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual(['design', 'eng']);
grant.items[1]!.recommended = 'a';
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual([]);
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] }, { c1: 'b' })).toEqual(['design', 'eng']);
});
test('priorChoices reads the answers already recorded at this gate revision, as answerGate does; gateCost prices the partial answer, not the recommendation', () => {
const out = path.join(TMP, 'prior'); fs.mkdirSync(out, { recursive: true });
const rows = [
{ schema_version: 1, run: 'r', id: 'r-UC1', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 1, chosen: 'b' },
{ schema_version: 1, run: 'r', id: 'r-D1', label: 'D1', kind: 'auto', status: 'pending', gate_rev: 1, chosen: null },
{ schema_version: 1, run: 'r', id: 'r-OLD', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 0, chosen: 'a' },
];
fs.writeFileSync(path.join(out, 'decisions.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
expect(priorChoices(out, 1)).toEqual({ uc1: 'b' });
expect(priorChoices(path.join(TMP, 'no-decisions'), 1)).toEqual({});
const c = gateCost(out, list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null, scope_flags: { ui: false, dx: false } }, { defaultPerVoiceUsd: 2 });
expect(c).toMatchObject({ reopens: ['ceo', 'eng'], voices: 2, rec_more_usd: 4 });
});
test('the lines price the reopen at the per-voice reservation, two voices each with the outside voice on, and name unknown host attempts', () => {
const out = path.join(TMP, 'cost'); fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, 'spend.json'), JSON.stringify({ schema_version: 1, cap_usd: 40, spent_usd: 1.5, reserved: [], released: [],
settled: [{ attempt: 'a1', usd: 1.5, reserved_usd: 5, at: 't' }, { attempt: 'a2', usd: 'unknown', reserved_usd: 5, at: 't' }] }));
const c = gateCost(out, list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 40, scope_flags: { dx: false } });
expect(c).toMatchObject({ attempts: 2, reopens: ['ceo', 'design', 'eng'], voices: 6, per_voice_usd: 5, rec_more_usd: 30 });
const lines = renderGateCost(c);
expect(lines[0]).toBe('GATE_SPEND: spent=1.50 worst_case=6.50 cap=40.00 headroom=33.50 attempts=2; 1 host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host\'s cost view)');
expect(lines[1]).toBe('GATE_REC_COST: rec reopens up to ceo,design,eng ≈ 6 voice(s) × 5.00 = +30.00 at most (the run\'s per-voice reservation) → headroom 3.50');
expect(gateCostSentence(c)).toContain('Taking every recommendation reopens up to ceo, design, eng: at most +30.00 more.');
const variant = (name: string, cap: string) => { const d = path.join(TMP, name); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'spend.json'), fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', `"cap_usd":${cap}`)); return d; };
const over = renderGateCost(gateCost(variant('cost-tight', '10'), list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 10, scope_flags: { dx: false } }));
expect(over[1]).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
const uncapped = gateCost(variant('cost-uncapped', 'null'), list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null });
const none = renderGateCost(uncapped);
expect(none[0]).toContain('cap=none headroom=none');
expect(none[1]).toBe('GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00');
expect(gateCostSentence(uncapped)).toBe('Cost so far: spent 1.50, worst case 6.50 (no cap set, 1 host attempt(s) unknown and counted at reservation; 2 reviewer attempt(s)). Taking every recommendation reopens nothing: no further spend.');
const empty = gateCost(path.join(TMP, 'no-ledger'), list('b'), { phase_order: ['ceo', 'eng'], outside_enabled: true, spend_cap_usd: null }, { defaultPerVoiceUsd: 5 });
expect(empty).toMatchObject({ attempts: 0, per_voice_usd: 5, rec_more_usd: 40, reopens: ['ceo', 'design', 'dx', 'eng'] });
});
});
describe('E22: the mirror honours the Captain-named approvals directory', () => {
const home = path.join(TMP, 'home');
const drive = path.join(home, '.capy', 'drive');
fs.mkdirSync(path.join(drive, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
const base = { GSTACK_CAPY: '1', HOME: home, GSTACK_STATE_ROOT: path.join(TMP, 'state') } as NodeJS.ProcessEnv;
const at = (e: NodeJS.ProcessEnv) => { const l = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in l) throw new Error(l.message); return l; };
test('no Captain drive: the private project-user folder; one Captain drive with approvals/: that directory; two: back to project-user; GSTACK_APPROVALS_DIR wins over both', () => {
expect(captainApprovalsDir(base)).toBeUndefined();
expect(at(base)).toMatchObject({ mirror_source: 'project-user drive' });
expect(at(base).mirror).toBe(path.join(drive, 'project-user-gstack-garry', 'approvals', at(base).slug, path.basename(at(base).file)));
fs.mkdirSync(path.join(drive, 'captain-minder'), { recursive: true });
expect(captainApprovalsDir(base)).toBeUndefined();
fs.mkdirSync(path.join(drive, 'captain-minder', 'approvals'));
expect(captainApprovalsDir(base)).toBe(path.join(drive, 'captain-minder', 'approvals'));
expect(at(base)).toMatchObject({ mirror_source: 'captain drive', mirror: path.join(drive, 'captain-minder', 'approvals', at(base).slug, path.basename(at(base).file)) });
fs.mkdirSync(path.join(drive, 'captain-other', 'approvals'), { recursive: true });
expect(captainApprovalsDir(base)).toBeUndefined();
expect(at(base)).toMatchObject({ mirror_source: 'project-user drive' });
fs.rmSync(path.join(drive, 'captain-other'), { recursive: true });
const explicit = path.join(TMP, 'explicit');
expect(at({ ...base, GSTACK_APPROVALS_DIR: explicit })).toMatchObject({ mirror_source: 'GSTACK_APPROVALS_DIR', mirror: path.join(explicit, at(base).slug, path.basename(at(base).file)) });
expect(at({ ...base, GSTACK_APPROVALS_MIRROR: '0' }).mirror).toBeUndefined();
expect(captainApprovalsDir({ ...base, GSTACK_CAPY: undefined })).toBeUndefined();
});
test('the other mirror roots are read: a row mirrored under project-user before the Captain created approvals/ stays visible, and path names both', () => {
const h = fs.mkdtempSync(path.join(TMP, 'home-'));
const d = path.join(h, '.capy', 'drive');
fs.mkdirSync(path.join(d, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
const e = { ...base, HOME: h, GSTACK_STATE_ROOT: path.join(TMP, 'state-ra') } as NodeJS.ProcessEnv;
const before = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in before) throw new Error(before.message);
expect(before.read_also).toEqual([]);
fs.mkdirSync(path.dirname(before.mirror!), { recursive: true });
fs.writeFileSync(before.mirror!, JSON.stringify(row({}, 'lane-x')) + '\n');
fs.mkdirSync(path.join(d, 'captain-minder', 'approvals'), { recursive: true });
const after = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in after) throw new Error(after.message);
expect(after.mirror_source).toBe('captain drive');
expect(after.read_also).toEqual([before.mirror!]);
expect(readRecord(after).rows.map(r => r.id)).toEqual([row({}, 'lane-x').id]);
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'path', '--branch', 'lane-x'], { cwd: ROOT, env: { ...process.env, ...e } as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
expect(r.stdout).toContain(`MIRROR: ${after.mirror} (captain drive)`);
expect(r.stdout).toContain(`READ_ALSO: ${before.mirror}`);
});
});
describe('E16 end to end: every gate-producing command prints the cost, and the briefs carry it', () => {
/** Both voices challenge the same item: the gate recommends the plan change (b) and `rec` would reopen phases. */
function gateRun(name: string, extraFinding: Record<string, unknown> = {}, o: { challenge?: boolean; json?: boolean } = {}): string {
const repo = path.join(TMP, name);
fs.mkdirSync(repo, { recursive: true });
expect(spawnSync('git', ['init', '-q', '-b', 'lane-z', repo], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
const out = startSmallRun(repo);
const challenge = o.challenge === false ? [] : [{ id: 'F3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes' }];
const native = JSON.stringify([
{ id: 'F1', severity: 'High', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'print PASS/FAIL per component', ...extraFinding }, ...challenge]);
const outside = JSON.stringify([
{ id: 'O1', severity: 'Critical', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 14, fix: 'exit non-zero on any FAIL', ...extraFinding },
...challenge.map(c => ({ ...c, id: 'O3', fix: 'Stack the lanes.' }))]);
let last = '';
for (const phase of ['ceo', 'eng']) {
const n = autoplan(['next', '--out', out]);
expect(n.status).toBe(0);
review(path.join(out, `${phase}-native-prompt.md`), { FIXTURE_FINDINGS: native });
review(path.join(out, `${phase}-outside-prompt.md`), { FIXTURE_FINDINGS: outside });
const ids = attemptsOf(out, phase);
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0);
last = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.json && phase === 'eng' ? ['--json'] : [])]).stdout;
}
expect(readRun(out).status).toBe('gate_pending');
if (o.json) {
const payload = JSON.parse(last.trim().split('\n').pop()!);
expect(payload.status).toBe('gate_pending');
expect(payload.gate_cost).toMatchObject({ attempts: 4, unknown_count: 4, rec_voices: payload.gate_cost.rec_reopens.length * 2 });
expect(typeof payload.gate_cost.spent_usd).toBe('number');
} else {
expect(last).toMatch(/^GATE_SPEND: spent=\d+\.\d\d worst_case=\d+\.\d\d cap=\S+ headroom=\S+ attempts=4; 4 host-subagent attempt/m);
expect(last).toMatch(/^GATE_REC_COST: rec reopens /m);
}
return out;
}
test('the closing submit prints GATE_SPEND/GATE_REC_COST (text) or gate_cost (--json); the owner brief carries the Cost so far sentence and render --captain a ### Cost section above the relaying trailer', () => {
const out = gateRun('plain', {}, { challenge: false, json: true });
const brief = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', out], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
expect(brief.stdout).toMatch(/Cost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
const captain = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--captain', '--out', out, '--allow-incomplete'], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
expect(captain.stdout).toMatch(/### Cost\n\nCost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
expect(captain.stdout).toContain('Taking every recommendation reopens nothing: no further spend.');
// the relaying agent sends everything above the trailer, so the price must sit above it
expect(captain.stdout.indexOf('### Cost')).toBeLessThan(captain.stdout.indexOf('_For the relaying agent, not the person:_'));
expect(captain.stdout.indexOf('_For the relaying agent, not the person:_')).toBeGreaterThan(0);
const changing = gateRun('changing');
const rendered = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', changing], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
expect(rendered.stdout).toMatch(/Taking every recommendation reopens up to [a-z, ]+: at most \+\d+\.\d\d more\./);
});
test('run at a pending gate prints the gate lines once: text mode carries GATE_SPEND/GATE_REC_COST, --json exactly one payload with gate_cost', () => {
const out = gateRun('run-gate', {}, { challenge: false });
const models = ['--native-model', 'anthropic/claude-opus-4-7', '--outside-model', 'openai/gpt-6-astra'];
const text = autoplan(['run', '--out', out, ...models]);
expect(text.status).toBe(0);
expect(text.stdout.match(/^GATE_SPEND: /gm)?.length).toBe(1);
expect(text.stdout.match(/^GATE_REC_COST: /gm)?.length).toBe(1);
const json = autoplan(['run', '--out', out, ...models, '--json']);
expect(json.status).toBe(0);
const objects = json.stdout.trim().split('\n').filter(l => l.startsWith('{'));
expect(objects.length).toBe(1);
expect(JSON.parse(objects[0]!)).toMatchObject({ status: 'gate_pending', gate_cost: { rec_reopens: [] } });
expect(readRun(out).status).toBe('gate_pending');
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* Subprocess tests for bin/gstack-decision-trail: the TSV decision trail
* (header once, append-only, one-line cells, spreadsheet formula guard), the
* start-row rule and its idempotent rerun, and the --run admission check.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const ROOT = path.resolve(import.meta.dir, "..");
const BIN = path.join(ROOT, "bin", "gstack-decision-trail");
const TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
let tmp: string;
let file: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-decision-trail-"));
file = path.join(tmp, "run", "decisions.tsv");
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
function run(...args: string[]) {
const r = spawnSync(process.execPath, [BIN, ...args], { cwd: ROOT, encoding: "utf8", timeout: 20_000 });
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
}
const rows = () => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(l => l.split("\t"));
describe("gstack-decision-trail append", () => {
test("first append creates the parent directory, the header and one stamped row", () => {
const r = run(file, "frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out");
expect(r.status).toBe(0);
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
const [header, row] = rows();
expect(header).toEqual(["ts", "phase", "decision", "why", "evidence", "result"]);
expect(row!.slice(1)).toEqual(["frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out"]);
expect(row![0]).toMatch(TS_RE);
});
test("later appends add rows and never a second header; an empty existing file gets the header", () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "");
run(file, "a", "b", "c", "d", "e");
run(file, "f", "g", "h", "i", "j");
const all = rows();
expect(all).toHaveLength(3);
expect(all.filter(r => r[0] === "ts")).toHaveLength(1);
expect(all[2]!.slice(1)).toEqual(["f", "g", "h", "i", "j"]);
});
test("cells lose tabs and line breaks, and a leading =, +, - or @ gets a quote so a spreadsheet cannot run it", () => {
run(file, "probe", "=HYPERLINK(\"http://evil\")", "two\nlines\there\r", "-", "@SUM(A1)");
const row = rows()[1]!;
expect(row).toHaveLength(6);
expect(row.slice(1)).toEqual(["probe", "'=HYPERLINK(\"http://evil\")", "two lines here ", "'-", "'@SUM(A1)"]);
run(file, "+1", "plain", "plain", "plain", "plain");
expect(rows()[2]![1]).toBe("'+1");
});
test("a cell equal to -h or --help is data, a trail without a final newline or with CRLF rows is repaired on append, and a repeated header is never a row", () => {
const r = run(file, "phase", "--help", "why", "evidence", "-h");
expect(r.status).toBe(0);
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
expect(rows()[1]!.slice(1)).toEqual(["phase", "'--help", "why", "evidence", "'-h"]);
fs.appendFileSync(file, "x\ty\tz");
run(file, "p2", "d2", "w", "e", "r");
const all = rows();
expect(all[2]).toEqual(["x", "y", "z"]);
expect(all[3]!.slice(1)).toEqual(["p2", "d2", "w", "e", "r"]);
const crlf = path.join(tmp, "crlf.tsv");
fs.writeFileSync(crlf, "ts\tphase\tdecision\twhy\tevidence\tresult\r\n2026-01-01T00:00:00Z\ta\tb\tc\td\te\r\nts\tphase\tdecision\twhy\tevidence\tresult\r\n");
const start = run("start", crlf, "run-1");
expect(start.stdout).toBe(`appended start row 2 for run run-1 to ${crlf}\n`);
expect(fs.readFileSync(crlf, "utf8").split("\n").filter(Boolean).pop()!.split("\t")[3]).toBe("1 earlier row (2026-01-01T00:00:00Z..2026-01-01T00:00:00Z) written by other runs");
});
test("a blank file gets a header, and a BOM before the header does not make it a row", () => {
const blank = path.join(tmp, "blank.tsv");
fs.writeFileSync(blank, "\n");
expect(run(blank, "p", "d", "w", "e", "r").status).toBe(0);
expect(run(blank, "p2", "d", "w", "e", "r").status).toBe(0);
const lines = fs.readFileSync(blank, "utf8").split("\n");
expect(lines[1]).toBe("ts\tphase\tdecision\twhy\tevidence\tresult");
expect(lines[2]!.split("\t").slice(1)).toEqual(["p", "d", "w", "e", "r"]);
expect(lines.filter(l => l === "ts\tphase\tdecision\twhy\tevidence\tresult")).toHaveLength(1);
const bom = path.join(tmp, "bom.tsv");
fs.writeFileSync(bom, "\uFEFFts\tphase\tdecision\twhy\tevidence\tresult\n");
expect(run("--run", "r1", bom, "p", "d", "w", "e", "r").status).toBe(0);
expect(fs.readFileSync(bom, "utf8").split("\n").filter(Boolean)).toHaveLength(2);
});
test("a run id with whitespace or quotes is a usage error on start and on --run", () => {
expect(run("start", file, "a\tb").status).toBe(2);
expect(run("start", file, "a b").status).toBe(2);
expect(run("--run", "x'y", file, "a", "b", "c", "d", "e").status).toBe(2);
expect(run("start", file, "jam_01ABC.turn-2:x/y@z").status).toBe(0);
expect(fs.readFileSync(file, "utf8").split("\n")).toHaveLength(3);
});
test("wrong arity, an unknown option and a bare call are usage errors; --help exits 0", () => {
expect(run(file, "only", "four", "cells", "here").status).toBe(2);
expect(run("--bogus", file, "a", "b", "c", "d", "e").status).toBe(2);
expect(run().status).toBe(2);
expect(run(file, "start", "a", "b", "c", "d").status).toBe(2);
const help = run("--help");
expect(help.status).toBe(0);
expect(help.stdout).toContain("gstack-decision-trail start <file> <run-id>");
expect(fs.existsSync(file)).toBe(false);
});
});
describe("gstack-decision-trail start", () => {
test("a start row names the earlier rows and the run; rerunning it for the same run is a no-op", () => {
run(file, "a", "b", "c", "d", "e");
run(file, "f", "g", "h", "i", "j");
const first = run("start", file, "jam_A");
expect(first.status).toBe(0);
expect(first.stdout).toBe(`appended start row 3 for run jam_A to ${file}\n`);
const start = rows()[3]!;
expect(start[1]).toBe("start");
expect(start[2]).toBe("run jam_A joins this trail");
expect(start[3]).toMatch(/^2 earlier rows \(\d{4}-.*\.\.\d{4}-.*\) written by other runs$/);
expect(start[4]).toBe("run:jam_A");
expect(start[5]).toBe("open");
const again = run("start", file, "jam_A");
expect(again.status).toBe(0);
expect(again.stdout).toBe(`start row for run jam_A already present at ${file}:4\n`);
expect(rows()).toHaveLength(4);
});
test("a start row on an empty trail opens it, and another run's start row follows a prior run's", () => {
expect(run("start", file, "run-1").stdout).toBe(`appended start row 1 for run run-1 to ${file}\n`);
expect(rows()[1]!.slice(1)).toEqual(["start", "run run-1 opens this trail", "no earlier rows", "run:run-1", "open"]);
run("--run", "run-1", file, "work", "did a thing", "because", "commit 1", "ok");
expect(run("start", file, "run-2").status).toBe(0);
expect(rows().map(r => r[1])).toEqual(["phase", "start", "work", "start"]);
// run-1's row is older than run-2's start, so run-1 is a new run again and needs a new start row.
expect(run("start", file, "run-1").stdout).toContain("appended start row 4");
});
test("--run refuses an append until the run's start row is the newest one, and admits a fresh trail", () => {
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
run("start", file, "other");
const refused = run("--run", "r9", file, "a", "b", "c", "d", "e");
expect(refused.status).toBe(3);
expect(refused.stderr).toContain("DECISION_TRAIL_START_REQUIRED");
expect(refused.stderr).toContain(`gstack-decision-trail start ${file} r9`);
expect(rows()).toHaveLength(3);
run("start", file, "r9");
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
expect(rows()).toHaveLength(5);
});
test("start with the wrong arity is a usage error", () => {
expect(run("start", file).status).toBe(2);
expect(run("start", file, "r", "extra").status).toBe(2);
});
});
+2
View File
@@ -148,6 +148,8 @@ const SCANNER_EXEMPT: Record<string, string> = {
'fetches the user\'s own repo\'s base branch for version-claim freshness — a user-repo dev-workflow op, not gstack-state egress',
'bin/gstack-version-bump':
'git fetch appears only in an error-message string',
'bin/gstack-worktree-audit':
'read-only audit: best-effort fetch of the user\'s own repo\'s base branch so the MERGED column is current (skipped with --no-fetch) — a user-repo dev-workflow op like gstack-next-version\'s, not gstack-state egress',
'bin/gstack-state-matrix':
'git fetch appears only in an error-message string (the fix the user runs when origin/<base> has no merge base)',
'bin/gstack-redact-prepush':
+263
View File
@@ -0,0 +1,263 @@
/**
* Fixture test for bin/gstack-worktree-audit: a bare origin, a main clone and
* worktrees in every bucket the script knows, with a fake gh on PATH and a
* Claude Code transcript directory under CLAUDE_CONFIG_DIR. The audit reads
* only: the fixture is byte-compared before and after.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const ROOT = path.resolve(import.meta.dir, "..");
const BIN = path.join(ROOT, "bin", "gstack-worktree-audit");
let tmp: string;
let repo: string;
let origin: string;
let fakeBin: string;
let claudeDir: string;
const wts: Record<string, string> = {};
function git(cwd: string, ...args: string[]): string {
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`);
return r.stdout.trim();
}
function commit(cwd: string, file: string, text: string, msg: string): void {
fs.writeFileSync(path.join(cwd, file), text);
git(cwd, "add", file);
git(cwd, "commit", "-q", "-m", msg);
}
function audit(...args: string[]) {
const r = spawnSync("bash", [BIN, ...args], {
cwd: repo, encoding: "utf8", timeout: 60_000,
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp },
});
const lines = r.stdout.split("\n").filter(Boolean);
const header = lines[1]?.split("\t") ?? [];
const rows = lines.slice(2).map(l => Object.fromEntries(l.split("\t").map((v, i) => [header[i]!, v])) as Record<string, string>);
return { status: r.status, stderr: r.stderr, base: lines[0], header, rows, byPath: Object.fromEntries(rows.map(r => [r.WORKTREE, r])) };
}
function snapshot(dir: string): string {
const r = spawnSync("bash", ["-c", "find . -path ./.git -prune -o -type f -print0 | sort -z | xargs -0 sha256sum; git -C . status --porcelain; git -C . for-each-ref; git -C . worktree list --porcelain"], { cwd: dir, encoding: "utf8", timeout: 30_000 });
return r.stdout;
}
// The fake gh records its argv and, when jq is installed, runs the audit's own --jq expression over
// canned JSON (so the fork-PR filter is exercised); without jq it prints the TSV that expression yields.
function fakeGh(prs: Array<{ number: number; state: string; headRefName: string; isCrossRepository: boolean }>): void {
fs.writeFileSync(path.join(fakeBin, "prs.json"), JSON.stringify(prs));
fs.writeFileSync(path.join(fakeBin, "prs.tsv"), prs.filter(p => !p.isCrossRepository).map(p => `${p.headRefName}\t${p.number}\t${p.state.toLowerCase()}`).join("\n") + "\n");
fs.writeFileSync(path.join(fakeBin, "gh"), `#!/usr/bin/env bash
printf '%s\\n' "$@" > "${fakeBin}/gh-args"
expr=""; while [ $# -gt 0 ]; do [ "$1" = --jq ] && expr="$2"; shift; done
if command -v jq >/dev/null 2>&1 && [ -n "$expr" ]; then jq -r "$expr" "${fakeBin}/prs.json"; else cat "${fakeBin}/prs.tsv"; fi
`, { mode: 0o755 });
}
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-wt-audit-"));
origin = path.join(tmp, "origin.git");
repo = path.join(tmp, "repo");
fs.mkdirSync(origin);
git(origin, "init", "-q", "--bare", "--initial-branch=main");
git(tmp, "clone", "-q", origin, repo);
git(repo, "checkout", "-q", "-b", "main");
commit(repo, "README.md", "# fixture\n", "initial");
git(repo, "push", "-q", "-u", "origin", "main");
// merged: a branch whose commit was fast-forwarded into main and pushed; clean tree, branch never pushed.
wts.merged = path.join(tmp, "wt-merged");
git(repo, "worktree", "add", "-q", "-b", "feat/merged", wts.merged);
commit(wts.merged, "merged.txt", "done\n", "merged work");
git(repo, "merge", "-q", "--ff-only", "feat/merged");
git(repo, "push", "-q", "origin", "main");
// wip: pushed branch with a tracked edit on top; its PR is open.
wts.wip = path.join(tmp, "wt-wip");
git(repo, "worktree", "add", "-q", "-b", "feat/wip", wts.wip);
commit(wts.wip, "wip.txt", "v1\n", "wip work");
git(wts.wip, "push", "-q", "-u", "origin", "feat/wip");
fs.writeFileSync(path.join(wts.wip, "wip.txt"), "v2 edited\n");
// scratch: an unpushed branch, two untracked files, a chat from today → verify-recent-chat.
wts.scratch = path.join(tmp, "wt-scratch");
git(repo, "worktree", "add", "-q", "-b", "feat/scratch", wts.scratch);
commit(wts.scratch, "scratch.txt", "s\n", "scratch work");
fs.writeFileSync(path.join(wts.scratch, "notes.md"), "untracked\n");
fs.writeFileSync(path.join(wts.scratch, "tmp.log"), "untracked\n");
claudeDir = path.join(tmp, "claude");
const chat = path.join(claudeDir, "projects", wts.scratch.replace(/[^A-Za-z0-9]/g, "-"));
fs.mkdirSync(chat, { recursive: true });
fs.writeFileSync(path.join(chat, "session.jsonl"), "{}\n");
// stale: an unpushed branch nobody touched, no chat, no PR → review.
wts.stale = path.join(tmp, "wt-stale");
git(repo, "worktree", "add", "-q", "-b", "feat/stale", wts.stale);
commit(wts.stale, "stale.txt", "s\n", "stale work");
// closed: pushed branch whose PR was closed without merging → safe.
wts.closed = path.join(tmp, "wt-closed");
git(repo, "worktree", "add", "-q", "-b", "feat/closed", wts.closed);
commit(wts.closed, "closed.txt", "c\n", "closed work");
git(wts.closed, "push", "-q", "-u", "origin", "feat/closed");
fakeBin = path.join(tmp, "fakebin");
fs.mkdirSync(fakeBin);
// spaced: a worktree whose path has a space, merged into main, clean → safe.
wts.spaced = path.join(tmp, "wt with space");
git(repo, "worktree", "add", "-q", "-b", "feat/spaced", wts.spaced);
commit(wts.spaced, "spaced.txt", "s\n", "spaced work");
git(repo, "merge", "-q", "--ff-only", "feat/spaced");
git(repo, "push", "-q", "origin", "main");
// fresh: head is in main but an untracked file exists only here → review, not safe.
wts.fresh = path.join(tmp, "wt-fresh");
git(repo, "worktree", "add", "-q", "-b", "feat/fresh", wts.fresh);
fs.writeFileSync(path.join(wts.fresh, "newfeature.ts"), "export const x = 1;\n");
// gone: registered, directory deleted → prunable.
wts.gone = path.join(tmp, "wt-gone");
git(repo, "worktree", "add", "-q", "-b", "feat/gone", wts.gone);
fs.rmSync(wts.gone, { recursive: true, force: true });
// locked: registered and locked, directory gone → review (prune would not touch it).
wts.locked = path.join(tmp, "wt-locked");
git(repo, "worktree", "add", "-q", "-b", "feat/locked", wts.locked);
git(repo, "worktree", "lock", wts.locked);
fs.rmSync(wts.locked, { recursive: true, force: true });
// broken: a gitignored worktree inside the clone whose .git file is gone, with a
// precious file only there; git -C would describe the clone itself → review.
fs.appendFileSync(path.join(repo, ".git", "info", "exclude"), ".wt\n");
wts.broken = path.join(repo, ".wt", "broken");
git(repo, "worktree", "add", "-q", "-b", "feat/broken", wts.broken);
fs.writeFileSync(path.join(wts.broken, "precious.txt"), "only here\n");
fs.rmSync(path.join(wts.broken, ".git"));
// tagged: merged and clean, with a tag of the same name as its branch and an open PR → hold-open-pr.
wts.tagged = path.join(tmp, "wt-tagged");
git(repo, "worktree", "add", "-q", "-b", "feat/tagged", wts.tagged);
git(repo, "tag", "feat/tagged");
// ignored: merged and clean, with a gitignored .env → safe, DIRTY names the ignored entry.
wts.ignored = path.join(tmp, "wt-ignored");
git(repo, "worktree", "add", "-q", "-b", "feat/ignored", wts.ignored);
fs.writeFileSync(path.join(wts.ignored, ".gitignore"), ".env\n");
git(wts.ignored, "add", ".gitignore");
git(wts.ignored, "commit", "-q", "-m", "ignore .env");
git(repo, "merge", "-q", "--ff-only", "feat/ignored");
git(repo, "push", "-q", "origin", "main");
fs.writeFileSync(path.join(wts.ignored, ".env"), "SECRET=1\n");
});
// Each test starts from the same gh (one open, one closed, one fork PR on feat/stale that must be
// ignored) and the same closed worktree, whatever an earlier test changed.
beforeEach(() => {
fakeGh([
{ number: 41, state: "OPEN", headRefName: "feat/wip", isCrossRepository: false },
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
{ number: 99, state: "OPEN", headRefName: "feat/stale", isCrossRepository: true },
{ number: 43, state: "OPEN", headRefName: "feat/tagged", isCrossRepository: false },
]);
git(wts.closed, "reset", "-q", "--hard", "origin/feat/closed");
});
afterAll(() => {
if (repo) spawnSync("git", ["worktree", "prune"], { cwd: repo, timeout: 30_000 });
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
});
describe("gstack-worktree-audit", () => {
test("classifies every worktree, reads the PR column through gh, the chat through CLAUDE_CONFIG_DIR, and changes nothing", () => {
const live = Object.values(wts).filter(w => fs.existsSync(w));
const before = live.map(snapshot).join("\n") + snapshot(repo);
const r = audit();
expect(r.status).toBe(0);
expect(r.stderr).toContain("warn: no -b given; MERGED checks against origin/main");
expect(r.base).toBe("# base: origin/main");
expect(r.header).toEqual(["SIZE", "AGE", "MERGED", "DIRTY", "REMOTE", "PR", "LAST_CHAT", "BUCKET", "WORKTREE"]);
expect(r.rows).toHaveLength(12);
expect(r.byPath[repo]).toBeUndefined();
expect(r.byPath[wts.locked]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "review" });
expect(r.byPath[wts.broken]).toMatchObject({ DIRTY: "broken", BUCKET: "review" });
expect(r.byPath[wts.tagged]).toMatchObject({ MERGED: "YES", DIRTY: "clean", PR: "#43/open", BUCKET: "hold-open-pr" });
expect(r.byPath[wts.ignored]).toMatchObject({ MERGED: "YES", DIRTY: "clean,ignored:1", BUCKET: "safe" });
const ghArgs = fs.readFileSync(path.join(fakeBin, "gh-args"), "utf8").split("\n");
expect(ghArgs).toContain("number,state,headRefName,isCrossRepository");
expect(ghArgs.find(a => a.startsWith(".[]"))).toContain("select(.isCrossRepository | not)");
expect(r.byPath[wts.spaced]).toMatchObject({ MERGED: "YES", DIRTY: "clean", BUCKET: "safe" });
expect(r.byPath[wts.fresh]).toMatchObject({ MERGED: "YES", DIRTY: "scratch:1", REMOTE: "no-remote", BUCKET: "review" });
expect(r.byPath[wts.gone]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "prunable" });
expect(r.rows.slice(-2).map(row => row.WORKTREE).sort()).toEqual([wts.gone, wts.locked].sort());
const merged = r.byPath[wts.merged]!;
expect(merged).toMatchObject({ MERGED: "YES", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "safe", AGE: "0d" });
expect(merged.SIZE).toMatch(/^\d+(\.\d+)?[KMG]?$/);
expect(r.byPath[wts.wip]).toMatchObject({ MERGED: "no", DIRTY: "wip:1", REMOTE: "pushed", PR: "#41/open", BUCKET: "hold-wip" });
const scratch = r.byPath[wts.scratch]!;
expect(scratch).toMatchObject({ MERGED: "no", DIRTY: "scratch:2", REMOTE: "no-remote", PR: "-", BUCKET: "verify-recent-chat" });
expect(scratch.LAST_CHAT).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(r.byPath[wts.stale]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "review" });
expect(r.byPath[wts.closed]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "pushed", PR: "#40/closed", BUCKET: "safe" });
const after = live.map(snapshot).join("\n") + snapshot(repo);
expect(after).toBe(before);
});
test("status.showUntrackedFiles=no in the user's config does not hide untracked files", () => {
const r = spawnSync("bash", [BIN, "--no-fetch"], {
cwd: repo, encoding: "utf8", timeout: 60_000,
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "status.showUntrackedFiles", GIT_CONFIG_VALUE_0: "no" },
});
expect(r.status).toBe(0);
const row = r.stdout.split("\n").find(l => l.endsWith("\t" + wts.fresh))!;
expect(row.split("\t").slice(3, 4)).toEqual(["scratch:1"]);
expect(row.split("\t")[7]).toBe("review");
});
test("an unpushed commit on a branch with a closed PR is review, an open PR on the same name wins and holds it, and -b picks the base", () => {
git(wts.closed, "commit", "-q", "--allow-empty", "-m", "one more");
const r1 = audit("-b", "origin/main", "--no-fetch", repo);
expect(r1.stderr).not.toContain("warn: no -b given");
expect(r1.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#40/closed", BUCKET: "review" });
fakeGh([
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
{ number: 42, state: "OPEN", headRefName: "feat/closed", isCrossRepository: false },
]);
const r2 = audit("-b", "origin/main", "--no-fetch", repo);
expect(r2.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#42/open", BUCKET: "hold-open-pr" });
});
test("without gh the PR column is - with a warning, and a missing base ref or repo is an error", () => {
// A PATH with every tool the audit uses except gh. git goes in as the real binary from its
// exec path, since a distribution's /usr/bin/git may be a wrapper script that looks git up on PATH.
const noGh = path.join(tmp, "nogh");
fs.mkdirSync(noGh);
const onPath = (tool: string) => (process.env.PATH ?? "").split(path.delimiter).map(d => path.join(d, tool)).find(p => fs.existsSync(p));
const execPath = spawnSync("git", ["--exec-path"], { encoding: "utf8", timeout: 30_000 }).stdout.trim();
const realGit = [path.join(execPath, "git"), onPath("git")].find(p => p && fs.existsSync(p))!;
fs.symlinkSync(fs.realpathSync(realGit), path.join(noGh, "git"));
for (const tool of ["bash", "sh", "env", "du", "awk", "sed", "sort", "head", "find", "xargs", "stat", "date", "mktemp", "grep", "rm", "cat", "dirname", "basename", "uname", "tr", "cut", "wc"]) {
const found = onPath(tool);
if (found) fs.symlinkSync(fs.realpathSync(found), path.join(noGh, tool));
}
const r = spawnSync("bash", [BIN, "--no-fetch"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: { ...process.env, PATH: noGh, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp } });
expect(r.status).toBe(0);
expect(r.stderr).toContain("PR column is -");
const rows = r.stdout.split("\n").filter(Boolean).slice(2);
expect(rows).toHaveLength(12);
expect(rows.every(l => l.split("\t")[5] === "-")).toBe(true);
fs.writeFileSync(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\necho 'gh: not logged in' >&2; exit 4\n", { mode: 0o755 });
const failing = audit("--no-fetch");
expect(failing.status).toBe(0);
expect(failing.stderr).toContain("gh could not list this repository's PRs; PR column is -");
expect(failing.rows).toHaveLength(12);
expect(failing.rows.every(row => row.PR === "-")).toBe(true);
expect(audit("-b", "origin/nope", "--no-fetch").status).toBe(1);
expect(audit("--no-fetch", path.join(tmp, "not-a-repo")).status).toBe(1);
expect(audit("--bogus").status).toBe(2);
expect(audit("--help").status).toBe(0);
});
});