mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
76aa68fc9e
commit
295b32e2c4
20 files changed
+479
-71
No files matched your search
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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
@@ -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]!;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user