v1.91.269.0 feat(autoplan): gates print their cost, approvals mirror to the Captain's drive, Eng asks for a perf budget (GSTA-114 lane F, PR 3) (#3496)

* feat(autoplan): gates print their cost, --owner-standing rec cites a standing approval, approvals mirror to the Captain's drive

E16: GATE_SPEND and GATE_REC_COST under every GATE line (lib/autoplan-gate-cost.ts), and a
Cost so far sentence in the owner brief; host-subagent attempts stay priced unknown.
E21: gstack-autoplan answer --owner-standing rec --by agent:<id> answers rec by citing the
newest standing approval covering autoplan recommendations from any branch's record; the
id lands on every decision row, in run.json and in the approvals import; refused with
STANDING_APPROVAL_ABSENT or STANDING_APPROVAL_INAPPLICABLE (urgent finding).
E22: the approvals mirror honours the approvals/ directory a Captain created in its drive,
between GSTACK_APPROVALS_DIR and the private project-user folder; gstack-approvals path
names the rule.
E23: the Eng phase asks for a performance budget and a bench step on hot-path changes.

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

* fix(autoplan): review pass 1 — standing answers bind to the run's project and refuse recommend-edited gates, cost is an upper bound and reaches JSON and the Captain brief

Review pass 1 (6 specialists + red team) on the standing-approval and gate-cost change:
- the standing lookup resolves the approvals record of the repository the run's plan lives in,
  never the caller's cwd (red team: project A's row answered project B's gate)
- standingAnswer refuses a gate that `recommend` edited (rec_rev > 0 or recommended_by), a
  missing or unreadable findings.jsonl, and an outside-plan finding; the STANDING line flattens
  control characters so a crew-writable row cannot forge a protocol line
- coversAutoplanRec matches the recommendation words in the owner's words only and rejects
  negations; STANDING_APPROVAL_ABSENT's fix names every required gstack-approvals flag
- recReopens is the upper bound of the answer/revise rules (a plan change counts every phase,
  a round grant its phase plus Eng); gate_cost rides the JSON payloads, `run` prints the lines
  at its final gate, and `gstack-owner-brief render --captain` appends a ### Cost section
- locate keeps the other mirror roots as read_also so rows mirrored under an earlier rule stay
  visible; gstack-approvals path prints READ_ALSO
- the Eng phase edit is size-neutral: five nearby phrases shortened so every host's autoplan
  SKILL.md stays at its size-guard pin
- tests: isolated fixtures, the run's plan inside its own git repo, cross-project refusal,
  amend_pending vs complete, recommend-then-standing, injection, gate_cost JSON, Captain brief

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

* fix: pre-landing review fixes (pass 2)

Standing approvals: the newest row about autoplan recommendations decides, so a later refusal
blocks older grants; rows whose id or date is not the recorded shape are never cited; the
project is fixed at init from the plan's physical path (run.json project_root) so a symlinked
plans folder or a checkpoint restore cannot borrow another repository's record. Gate cost:
recReopens counts every phase revise can keep or add and prices answers already recorded at
the gate; run --json emits one payload; the Captain brief's ### Cost sits above the relaying
trailer.

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

* fix: pre-landing review fixes (pass 4, rule-23 exception)

Standing grammar fails closed: the newest standing row mentioning autoplan decides and grants only
with the canonical scope and affirmative owner words; same-id copies keep the newest recorded; id and
date are type-checked before the regexes. Gate cost prices the effective choices exactly as answer
does (p1d, carried overrides, non-recommended options). The run records project_slug beside
project_root so a restored run names its record on another machine, and project_root is a recorded
root for export --public.

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

* refactor(autoplan): drop --owner-standing rec (E21) from this PR

Pass-4 review (red team + Codex adversarial) still found that the keyword
grammar over the owner's standing words could read a refusal as a grant
("I won't take the recommendations", "Taking the recommendations is
forbidden" granted under the canonical scope) and that gstack-approvals add
deduped a same-day scope revocation as the grant. A free-text negation
parser cannot fail closed, so the standing answer leaves this change and
returns with a structured grant in its own PR.

Kept: GATE_SPEND/GATE_REC_COST cost lines and gate_cost (E16), the
Captain-drive approvals mirror with READ_ALSO (E22), the Eng perf-budget
question (E23). Removed: standingAnswer, standingRecApproval,
coversAutoplanRec, citableRow, project_root/project_slug in run.json,
STANDING_APPROVAL_* result codes and anchors, the standing DRIVER
paragraph and docs, test/autoplan-standing.test.ts (its E16/E22 cases move
to test/autoplan-gate-cost.test.ts).

Focused: 793 pass / 0 fail across 45 files; typecheck, typecheck:test,
format: cso:check clean; renders regenerated with gen:skill-docs --host all.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* RESTAMP: 1.91.268.0 -> 1.91.269.0 (stamped; queue_mode=stamp-at-merge base=origin/main@76aa68fc9e7a policy=base origin/main@76aa68fc9e7a:.gstack/ship-policy.json)
ship, next in queue at 1.91.269.0
STAMP_VERSION: 1.91.269.0
claims: ignored (stamp-at-merge)
stage version: done (wrote 1.91.269.0)
stage release_tool: done (bun scripts/gen-agents-digest.ts)
stage changelog: done (Unreleased -> 1.91.269.0)
stage stamp_paths: skipped (no stamp_paths in policy)
stage mirror: skipped (no mirror in policy)
stage apply: done (4 file(s))
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4e28308c..566bb525c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
 # Changelog

-## [Unreleased]
+## [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.)

diff --git a/VERSION b/VERSION
index c31f84688..6437fa3e4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.268.0
+1.91.269.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index 3548eb690..4bdc2879f 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.268.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.269.0 — regenerate/re-copy after upgrading gstack

 Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
 for agent hosts without a full skill install. The full skills add workflows,
diff --git a/node_modules b/node_modules
new file mode 120000
index 000000000..1de79460f
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+/workspace/gstack-ap/node_modules
\ No newline at end of file
diff --git a/package.json b/package.json
index 7a672beb5..26a9702da 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "gstack",
-  "version": "1.91.268",
+  "version": "1.91.269",
   "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
   "license": "MIT",
   "type": "module",
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=624f0eedec7c3aebdd0cecd70be4e4ebd10eb4af
next: NEW_VERSION=1.91.269.0; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head 624f0eedec and recompute on a stale queue.
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack-ap/.gstack/tmp/restamp-journal.json

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-27 06:54:56 +00:00
1 parent 76aa68fc9e
commit 295b32e2c4
20 files changed
+479 -71

No files matched your search

+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [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").
+1 -1
View File
@@ -1 +1 @@
1.91.268.0
1.91.269.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.268.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.269.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,
+3 -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
+3 -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
+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]!;
+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
+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
+5 -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)`
+6 -2
View File
@@ -293,7 +293,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
@@ -425,7 +427,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`.
+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',
'',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.268",
"version": "1.91.269",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+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');
});
});