mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
Merge origin/main into gsta152-pr-b (rule 19 grant, CHANGELOG via mergeChangelogs)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
a924d74345
48 files changed
+1764
-186
No files matched your search
+76
-13
@@ -2,9 +2,23 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**/ship takes a token cap and stops cleanly at it, and long runs hand their heavy segments to fresh-context children.** On gstack-evals#31 a /ship spent 54M tokens against a 25M cap because the cap was only a number in a brief; #26, whose agent read the counter after every step, stopped at 39.40M of 40M. `/ship --token-cap 25M` (or `GSTACK_SHIP_TOKEN_CAP`) now admits each step and dispatch batch only when it fits, and a run started from a long-lived context, or one whose per-call charge passes 150k at Step 7 or Step 12, runs that segment in a fresh child.
|
||||
### Tests
|
||||
- `test/review-depth.test.ts`: the #19 shape plans fix-check, Red Team and native adversarial; full depth, a fail-closed classification, `--depth-floor full`, a 151-line, missing or unread range and a gate-touching fix keep the full pass; a forced specialist and changed numbers keep their reviewers; 150, 151 and a docs-only range; the scoped pass counts toward the cap and a blocker it leaves stops; `DEPTH_RULES` moves with the constant.
|
||||
- `test/review-plan-cli.test.ts`: `--range-base` plans `NEXT: scoped`; a 151-line range, no range, an unknown base and uncommitted fixes plan full.
|
||||
- `test/review-scope.test.ts`: `raised_by` from the explicit field, the specialist tag or `unknown`.
|
||||
- `test/ship-receipt-depth.test.ts`: the queue binds a receipt whose pass 2 was scoped.
|
||||
|
||||
**Pass 2 after a blocking fix now reviews the fix in proportion to its size.** When pass 1 fixed a blocking finding on a diff below full depth and the fix range has at most 150 runtime lines, /ship runs a scoped pass: one fix-check, the Red Team and the native adversarial pass on the range, instead of the specialist army. On gstack-evals#19 a 3-file fix range (+18/-6) was planned for nine reviewers. Full depth, `--depth-floor full`, a fix that touched a gate path, a larger range, or a range it cannot read keeps the full pass 2. The two-pass cap and the stop for the owner are unchanged.
|
||||
### Gate integrity
|
||||
- Addition to the 1.91.316.0 entry below: **Pass 2 after a blocking fix now reviews the fix in proportion to its size.** When pass 1 fixed a blocking finding on a diff below full depth and the fix range has at most 150 runtime lines, /ship runs a scoped pass: one fix-check, the Red Team and the native adversarial pass on the range, instead of the specialist army. On gstack-evals#19 a 3-file fix range (+18/-6) was planned for nine reviewers. Full depth, `--depth-floor full`, a fix that touched a gate path, a larger range, or a range it cannot read keeps the full pass 2. The two-pass cap and the stop for the owner are unchanged.
|
||||
- Addition to the 1.91.316.0 entry below: **`gstack-review-plan --range-base <sha>`** (`lib/review-depth.ts`): counts the committed fix range `<sha>..HEAD` the way the depth counts runtime lines, prints `FIX_RANGE: runtime_lines=<n>|unread (<why>) base=<sha>` (`fix_range` in `--json`), and `NEXT: scoped` when the scoped pass applies. `nextStep` takes the depth and `History.rangeRuntimeLines`; `SCOPED_RANGE_LINES` (150) joins `DEPTH_RULES`, so `rules=` changes. `REVIEW_PLAN:` ends with the step's `reason="…"`. Uncommitted or untracked edits, a range base that is not an ancestor of HEAD, an empty range, and a range the classifier cannot count all plan the full pass. A full pass 1 always gets a full pass 2: `--pass1-depth` carries pass 1's `REVIEW_DEPTH` (absent counts as full), so a fix that reverts a gate, auth, migration or gate-tagged edit cannot lower pass 2. The fix range is classified with its own `gstack-diff-scope` and `gstack-gate-diff` input, and a gate-class range plans the full pass and sets `fix_range.touched_gate`.
|
||||
- Addition to the 1.91.316.0 entry below: **Scoped dispatches**: the fix-check, every force-included specialist, the Red Team, the native adversarial pass when the range changes runtime lines, and the outside voice when published numbers changed. Step 11's voices that have not run yet stay listed. A light diff's raise to standard applies to the scoped set only.
|
||||
- Addition to the 1.91.316.0 entry below: **Step 9** (`ship/sections/review-army.md`): the repeat decision passes `--range-base $PASS_HEAD` and maps `NEXT: scoped` to decision 3; a scoped pass logs a full-pass `review` row with `"scope":"scoped"`, so it counts toward the cap and its roles land in `review_loop.dispatches` with no receipt schema change.
|
||||
- Addition to the 1.91.316.0 entry below: **`raised_by` on classified findings** (`gstack-review-scope classify`, `lib/review-scope.ts`): the reviewer that raised each finding (`core`, `qa`, a specialist, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`), from the finding's `raised_by` or `specialist` tag, else `unknown`. Step 9 copies it into the review-log row. Older rows without it still summarize.
|
||||
- Addition to the 1.91.316.0 entry below: **Docs**: docs/review-policy.md (rule 3, the transitions table, a scoped-pass section and Behavior changes) and docs/ship-policy.md rule 3.
|
||||
|
||||
## [1.91.316.0] - 2026-10-29
|
||||
|
||||
**/ship takes a token cap and stops cleanly at it, and long runs hand their heavy segments to fresh-context children.** On gstack-evals#31 a /ship spent 54M tokens against a 25M cap because the cap was only a number in a brief; #26, whose agent read the counter after every step, stopped at 39.40M of 40M. `/ship --token-cap 25M` (or `GSTACK_SHIP_TOKEN_CAP`) now admits each step and dispatch batch only when it fits, and a run started from a long-lived context, or one whose per-call charge passes 150k at Step 7 or Step 12, runs that segment in a fresh child.
|
||||
|
||||
### Added
|
||||
- **`gstack-ship-budget`** (`lib/ship-budget.ts`, `lib/ship-budget-cli.ts`, `lib/ship-handoff.ts`): an admission cap on a per-run token ledger under `<state root>/ship-runs/<run>/` (its own schema; only `withFileLock` is shared with `lib/spend-ledger.ts`). `start`, `check` (the `BUDGET:` line, `verdict=continue|delegate|stop`, a `RESERVATION:` id per admitted step or batch), `settle`/`release`, `handoff`/`launch`/`verify-handoff`/`return`/`reconcile`, `resume` (cap raises need `--approved-by` and the owner's words; `--rebuild-from-record`; restore from a private Drive checkpoint) and `close`. Exit 0 continue/delegate, 3 stop, 2 usage. Each meter is a versioned contract, and there are three: the Capy root counter (`--reading`), Claude Code journal usage (`lib/claude-usage.ts`, bound by `--session` or an echoed nonce) and `--meter estimate`.
|
||||
@@ -13,20 +27,69 @@
|
||||
- **Result codes** `BUDGET_CAP_REACHED`, `BUDGET_READING_MISSING`, `BUDGET_READING_REJECTED`, `BUDGET_METER_UNAVAILABLE`, `BUDGET_LEDGER_INVALID`, `BUDGET_CHECK_SKIPPED`, `HANDOFF_PIN_MOVED` with docs/troubleshooting.md anchors; `lib/ship-budget-calibration.json` seeded from #19, #26 and #31.
|
||||
- **Docs**: docs/ship-budget.md (linked from docs/unattended.md), and a paragraph each in the Capy skill's SKILL.md and captain.md.
|
||||
|
||||
### Changed
|
||||
- **`gstack-review-plan --range-base <sha>`** (`lib/review-depth.ts`): counts the committed fix range `<sha>..HEAD` the way the depth counts runtime lines, prints `FIX_RANGE: runtime_lines=<n>|unread (<why>) base=<sha>` (`fix_range` in `--json`), and `NEXT: scoped` when the scoped pass applies. `nextStep` takes the depth and `History.rangeRuntimeLines`; `SCOPED_RANGE_LINES` (150) joins `DEPTH_RULES`, so `rules=` changes. `REVIEW_PLAN:` ends with the step's `reason="…"`. Uncommitted or untracked edits, a range base that is not an ancestor of HEAD, an empty range, and a range the classifier cannot count all plan the full pass. A full pass 1 always gets a full pass 2: `--pass1-depth` carries pass 1's `REVIEW_DEPTH` (absent counts as full), so a fix that reverts a gate, auth, migration or gate-tagged edit cannot lower pass 2. The fix range is classified with its own `gstack-diff-scope` and `gstack-gate-diff` input, and a gate-class range plans the full pass and sets `fix_range.touched_gate`.
|
||||
- **Scoped dispatches**: the fix-check, every force-included specialist, the Red Team, the native adversarial pass when the range changes runtime lines, and the outside voice when published numbers changed. Step 11's voices that have not run yet stay listed. A light diff's raise to standard applies to the scoped set only.
|
||||
- **Step 9** (`ship/sections/review-army.md`): the repeat decision passes `--range-base $PASS_HEAD` and maps `NEXT: scoped` to decision 3; a scoped pass logs a full-pass `review` row with `"scope":"scoped"`, so it counts toward the cap and its roles land in `review_loop.dispatches` with no receipt schema change.
|
||||
- **`raised_by` on classified findings** (`gstack-review-scope classify`, `lib/review-scope.ts`): the reviewer that raised each finding (`core`, `qa`, a specialist, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`), from the finding's `raised_by` or `specialist` tag, else `unknown`. Step 9 copies it into the review-log row. Older rows without it still summarize.
|
||||
- **Docs**: docs/review-policy.md (rule 3, the transitions table, a scoped-pass section and Behavior changes) and docs/ship-policy.md rule 3.
|
||||
|
||||
### Tests
|
||||
- `test/ship-budget.test.ts`: the cap boundary, batch overshoot, the #31 replay, spend above a reservation, the no-cap path, meters and epochs, exit codes, start and segment delegation, the full handoff chain with an idempotent reconcile, moved pins and a tampered brief, refused and interrupted children, stop and RECOVER, checkpoint restore into an empty state root, ledger rebuild, concurrent ledgers, and an unchanged `gstack-wtree`.
|
||||
- `test/ship-budget-review-log.test.ts`, `test/claude-usage.test.ts`, `test/ship-budget-calibration.test.ts` (leave-one-out), `test/ship-budget-section.test.ts`.
|
||||
- `test/review-depth.test.ts`: the #19 shape plans fix-check, Red Team and native adversarial; full depth, a fail-closed classification, `--depth-floor full`, a 151-line, missing or unread range and a gate-touching fix keep the full pass; a forced specialist and changed numbers keep their reviewers; 150, 151 and a docs-only range; the scoped pass counts toward the cap and a blocker it leaves stops; `DEPTH_RULES` moves with the constant.
|
||||
- `test/review-plan-cli.test.ts`: `--range-base` plans `NEXT: scoped`; a 151-line range, no range, an unknown base and uncommitted fixes plan full.
|
||||
- `test/review-scope.test.ts`: `raised_by` from the explicit field, the specialist tag or `unknown`.
|
||||
- `test/ship-receipt-depth.test.ts`: the queue binds a receipt whose pass 2 was scoped.
|
||||
|
||||
## [1.91.315.0] - 2026-10-29
|
||||
|
||||
### Changed
|
||||
- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base <base>` compares HEAD's product diff against the base with the range the receipt reviewed against the parent branch. That is the recorded `product_patch_id`, or the range against `origin/<parent>` for an older receipt. HEAD's side is its whole product diff against the base, so a parent's `package.json` change that never reached the base refuses too. Equal carries with the reason `stack parent <branch> landed` and rebinds the receipt's `base` to the base; anything else refuses. Until now every stacked child paid a full /ship once its parent merged (gstack-evals#19).
|
||||
|
||||
### Tests
|
||||
- `test/ship-receipt-carry-squash.test.ts`: a child carries after its parent squash-merges and main is merged in, names main as its base, and carries again across a later main merge. A test-only child carries. A child edited after the merge, a parent that never landed, a parent `package.json` change that never reached main, and a merge that keeps the child's tree over the landed parent refuse. An older receipt falls back to `origin/<parent>` and refuses once that branch is deleted.
|
||||
|
||||
## [1.91.314.0] - 2026-10-29
|
||||
|
||||
**A later autoplan gate that asks nothing new is now answered with the owner's earlier words instead of another round trip.** Captains were carrying approvals across gate revisions by hand (GSTA-140 reused a rev-1 "Do it" on rev 2; ALPH-50 lost about 27 minutes to one), and a replay over 23 recorded runs found 14 later gates, 8 of them holding only post-cap delta items.
|
||||
|
||||
### Added
|
||||
- **Carry-forward** (`lib/autoplan-carry.ts`): when gate_rev N+1 asks nothing new and gate_rev N was fully answered by the owner with `rec` or "do it", gstack answers N+1 with those recorded words and prints `CARRIED: gate_rev=<n> <- gate_rev=<m> …`; anything else prints `CARRY: no (<why>)` and goes to the owner. `--no-carry-forward` on the first `next` turns it off.
|
||||
- **"do it" covers new low-risk items.** When the owner's own words were "do it", a new item that changes no scope, cost, direction or plan text takes its recommendation, is marked `carried_under: do it`, and is listed in the approval record and the Captain brief as not shown to the owner. A Captain's "do it" never counts.
|
||||
- **Delta check.** A "plan changed after cap" item gets one reviewer from another model family on the delta first (`CARRY_CHECK:`, `gstack-autoplan check`), within a per-gate spend cap (`--carry-check-cap`, default $5). High or above, no reviewer, an owner answer, or a plan edit after dispatch leaves the gate for the owner and releases the reservation. The verdict is recorded on `gate.json`, the decision rows and `decisions.tsv`.
|
||||
- **`eli5` in gate.json**: the plan's `## ELI5` and the four approvals-shape sections; `gstack-gate render` opens page 1 with them, and the Captain brief requires `## ELI5` (older runs render as before).
|
||||
- **`gstack-approvals import --autoplan <dir> --stack b1,b2`** writes the same rows to every branch of a stack, or to none when one branch is unknown.
|
||||
- **`REVIEW_ESTIMATE:`** on the first `next`: phases, voices, rounds, attempts and the reservation total.
|
||||
|
||||
### Changed
|
||||
- `lib/decision-trail.ts` holds the trail append rules shared by `gstack-decision-trail` and the carry.
|
||||
|
||||
## [1.91.313.0] - 2026-10-29
|
||||
|
||||
**The PII scanner stops flagging two kinds of ordinary text: a bare power-of-two or hash constant in code, and a package@version-style filename.** Eval artifacts and PR bodies that print a PRNG range or name a versioned results file no longer raise a MEDIUM phone or email finding, and real phone numbers and addresses next to them still report.
|
||||
|
||||
### Fixed
|
||||
- **`lib/redact-patterns.ts`**: `pii.phone.e164` skips a bare number that is exactly a power of two from 2^30 through 2^49, one less than one, or one of eleven well-known 32-bit hash and PRNG constants. The same digits written with a `+`, spaces, dashes, dots or parentheses still report (#3577).
|
||||
- **`lib/redact-patterns.ts`**: `pii.email` skips a filename whose part after the `@` is a version or tag followed by file extensions no top-level domain uses, or a dotted version followed by an extension that doubles as one. A file named after a real address, an address at a numeric domain such as 163.com, and an address under a top-level domain that doubles as an extension (`.sh`, `.md`) still report (#3576).
|
||||
|
||||
### Tests
|
||||
- `test/redact-constant-and-at-filename.test.ts`: pins both exemptions in both directions, including files named after a real address.
|
||||
|
||||
## [1.91.312.0] - 2026-10-29
|
||||
|
||||
**An install check run from inside /ship no longer upgrades gstack under it.** /ship's docs step runs `scripts/install.sh --for document-release`. That run fast-forwarded the checkout from 1.91.298 to 1.91.299 in the middle of a lane's /ship, so the receipt writer's rules changed partway through the run (#3571).
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-capy-session`**: in install mode, a live workflow lease under 120 minutes old held by the caller's own process chain now skips the self-upgrade: `SELF_UPGRADE: skipped (a gstack run in this session holds a workflow lease on this installation: pid <p> (<age>m <command>), so the upgrade waits until the lease is 120 minutes old; …(UPGRADE_BUSY))`. The install still certifies the installed revision and the trailer stays ok with `behind=`. `bin/gstack-upgrade` deliberately ignores the caller's own lease, and an install started from inside the run passed that check. Each `gstack-skill-start` renews the lease. The age bound matters on Capy, where the leased pid is the machine's long-lived `capy-server`, so a lease is never released by its process exiting. An older lease, or none, upgrades as before, and `GSTACK_UPGRADE_FORCE=1` or a hand-run `bin/gstack-upgrade` still moves the checkout.
|
||||
|
||||
### Tests
|
||||
- `test/capy-skill-wrapper.test.ts`: an install run under a shell that holds a fresh lease leaves HEAD where it was and stays ok. Once that lease is 121 minutes old, with its holder still alive, the same install upgrades. Before this change the first run printed `SELF_UPGRADE: done 1.0.1 -> 1.0.2`.
|
||||
|
||||
## [1.91.311.0] - 2026-10-29
|
||||
|
||||
**A sealed policy can now refuse `sealed/<set>/tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks/<id>/sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants.
|
||||
|
||||
### Added
|
||||
- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose file name contains `**` or is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt <rule> <file> commit <sha> (sealed.allow_paths <glob>)` on every push.
|
||||
- **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs.
|
||||
|
||||
### Changed
|
||||
- `gstack-pregate sealed` prints `exempt=<n>` after `waived=<n>` on its `SEALED:` line (`policy=` stays last), and `--json` carries an `exempt` array. Both bins build their error report through one `sealedErrorReport`.
|
||||
- An unknown `sealed` key now says to upgrade gstack if the key comes from a newer one. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key.
|
||||
- `docs/pregate.md` § Sealed output explains glob anchoring: patterns match from the repository root, so `sealed/**` misses `tasks/x/sealed/a.json` and the docs say to write `**/sealed/**`. It also documents case handling, exceptions and the version skew.
|
||||
|
||||
### Gate integrity
|
||||
- This is a gate edit: `allow_paths` lets the sealed guard pass files it refused before, but only from the reviewed base branch. No existing test, threshold or allowlist changed. New cases in `test/sealed-guard.test.ts` (10) and `test/pregate-guards.test.ts` cover an allowed exception, a non-matching one, an exception added on the pushed head, a text rule inside an exempt file, transcripts, the waiver order, the error report, case folding in both directions and the root-anchoring example.
|
||||
|
||||
## [1.91.310.0] - 2026-10-29
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.310.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.316.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,
|
||||
|
||||
+4
-4
@@ -26,7 +26,7 @@ line yourself; gstack never writes the plan.
|
||||
|
||||
1. `next` prints one `ATTEMPT: <id> phase=<p> voice=native|outside prompt=<file> result=<file>`
|
||||
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=`.
|
||||
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take).
|
||||
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 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.
|
||||
@@ -38,6 +38,7 @@ line yourself; gstack never writes the plan.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
|
||||
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
|
||||
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
|
||||
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
|
||||
|
||||
### On Capy
|
||||
|
||||
@@ -51,9 +52,8 @@ line yourself; gstack never writes the plan.
|
||||
with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a
|
||||
generation is published at rest, after each bound voice and after each phase transition; restore
|
||||
with `resume --out <new dir> --from <dir>/<run>`, which never redispatches a bound voice.
|
||||
- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person),
|
||||
then `gstack-approvals import --autoplan <dir>` after `answer` (`capy/skill/gstack/captain.md`).
|
||||
- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan <dir>` after `answer` (`--stack <b1,b2,...>` for a stack; `capy/skill/gstack/captain.md`).
|
||||
|
||||
### Commands
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ line yourself; gstack never writes the plan.
|
||||
|
||||
1. `next` prints one `ATTEMPT: <id> phase=<p> voice=native|outside prompt=<file> result=<file>`
|
||||
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=`.
|
||||
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take).
|
||||
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 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.
|
||||
@@ -35,6 +35,7 @@ line yourself; gstack never writes the plan.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
|
||||
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
|
||||
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
|
||||
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
|
||||
|
||||
### On Capy
|
||||
|
||||
@@ -48,9 +49,8 @@ line yourself; gstack never writes the plan.
|
||||
with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a
|
||||
generation is published at rest, after each bound voice and after each phase transition; restore
|
||||
with `resume --out <new dir> --from <dir>/<run>`, which never redispatches a bound voice.
|
||||
- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person),
|
||||
then `gstack-approvals import --autoplan <dir>` after `answer` (`capy/skill/gstack/captain.md`).
|
||||
- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan <dir>` after `answer` (`--stack <b1,b2,...>` for a stack; `capy/skill/gstack/captain.md`).
|
||||
|
||||
### Commands
|
||||
|
||||
|
||||
+39
-4
@@ -5,7 +5,7 @@
|
||||
* gstack-approvals add --ask <text> --words <exact words> --by <who> --scope <what it covers>
|
||||
* [--pending <text>] [--outcome approved|rejected|pending|standing]
|
||||
* [--date YYYY-MM-DD] [--source <ref>] [--branch <name>] [--json]
|
||||
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name>] [--json]
|
||||
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name> | --stack <b1,b2,...>] [--json]
|
||||
* gstack-approvals render [--no-heading] [--branch <name>]
|
||||
* gstack-approvals list [--json] [--branch <name>]
|
||||
* gstack-approvals sync [--branch <name>]
|
||||
@@ -20,14 +20,15 @@
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { appendRows, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
|
||||
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
|
||||
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
|
||||
|
||||
add --ask <text> --words <exact words> --by <who> --scope <what it covers>
|
||||
[--pending <text>] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source <ref>]
|
||||
import --autoplan <run dir> [--by <whose words, e.g. Garry>]
|
||||
import --autoplan <run dir> [--by <whose words, e.g. Garry>] [--stack <b1,b2,...>]
|
||||
--stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none
|
||||
render [--no-heading] the ## Approvals section for a PR body or report
|
||||
list [--json]
|
||||
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
|
||||
@@ -40,7 +41,7 @@ Example:
|
||||
|
||||
${exitTableHelp()}`;
|
||||
|
||||
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan']);
|
||||
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack']);
|
||||
|
||||
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
|
||||
const flags: Record<string, string> = {};
|
||||
@@ -64,11 +65,45 @@ function fail(cmd: string, e: RecordError): number {
|
||||
return EXIT.refused;
|
||||
}
|
||||
|
||||
/** `import --stack`: every branch checked first, then the same rows (ids are branch-independent) written to each branch's record. */
|
||||
function importStack(cmd: string, flags: Record<string, string>, json: boolean): number {
|
||||
if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan <run dir>, without --branch\n`); return EXIT.usage; }
|
||||
const branches = [...new Set(flags['--stack']!.split(',').map(b => b.trim()).filter(Boolean))];
|
||||
if (!branches.length) { process.stderr.write(`gstack-approvals: --stack needs <b1,b2,...>\n`); return EXIT.usage; }
|
||||
const cwd = process.cwd();
|
||||
const missing = branches.filter(b => !branchExists(cwd, b));
|
||||
if (missing.length) return fail(cmd, { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no local or origin branch ${missing.join(', ')}; nothing was written` });
|
||||
const runDir = path.resolve(flags['--autoplan']);
|
||||
const results: Array<{ branch: string; added: string[]; skipped: number; file: string; mirror: string | null; error?: string }> = [];
|
||||
let firstError: RecordError | undefined;
|
||||
const plans: Array<{ branch: string; loc: StoreLocation; rows: ReturnType<typeof importAutoplan> & unknown[] }> = [];
|
||||
for (const branch of branches) {
|
||||
const loc = locate({ cwd, env: process.env, branch });
|
||||
if ('code' in loc) return fail(cmd, loc);
|
||||
const rows = importAutoplan(runDir, branch, flags['--by']);
|
||||
if ('code' in rows) return fail(cmd, rows);
|
||||
plans.push({ branch, loc, rows });
|
||||
}
|
||||
for (const { branch, loc, rows } of plans) {
|
||||
const res = appendRows(loc, rows);
|
||||
if ('code' in res) { firstError ??= res; results.push({ branch, added: [], skipped: 0, file: loc.file, mirror: loc.mirror ?? null, error: res.message }); continue; }
|
||||
if (res.mirrorError) firstError ??= res.mirrorError;
|
||||
results.push({ branch, added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null, ...(res.mirrorError ? { error: res.mirrorError.message } : {}) });
|
||||
}
|
||||
if (json) process.stdout.write(JSON.stringify({ branches: results }) + '\n');
|
||||
else {
|
||||
for (const r of results) process.stdout.write(`APPROVAL_IMPORTED: branch=${r.branch} n=${r.added.length} skipped=${r.skipped} ${r.file}${r.mirror ? ` mirror=${r.mirror}` : ''}${r.error ? ` error=${r.error}` : ''}\n`);
|
||||
process.stdout.write(`APPROVAL_IMPORTED_STACK: branches=${results.length} added=${results.reduce((n, r) => n + r.added.length, 0)}\n`);
|
||||
}
|
||||
return firstError ? fail(cmd, firstError) : EXIT.ok;
|
||||
}
|
||||
|
||||
function main(argv: string[]): number {
|
||||
const [cmd, ...rest] = argv;
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
const { flags, bools, error } = parse(rest);
|
||||
if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; }
|
||||
if (flags['--stack'] !== undefined) return importStack(cmd, flags, bools.has('--json'));
|
||||
const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] });
|
||||
if ('code' in loc) return fail(cmd, loc);
|
||||
const json = bools.has('--json');
|
||||
|
||||
+28
-3
@@ -19,13 +19,14 @@
|
||||
* gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran
|
||||
* gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>> close a phase accepted-with-findings
|
||||
* gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by <human|agent:<id>> the owner's journaled cap change
|
||||
* gstack-autoplan check --out <dir> --attempt <id> --result <file> --model <id> | --unavailable <reason> the delta check before a carry
|
||||
* gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad
|
||||
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
|
||||
*
|
||||
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
|
||||
* lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts.
|
||||
* Workflow commands end with GSTACK_RESULT: skill=autoplan status=<s> run=<dir>; the run is never approved here.
|
||||
* Mutating verbs (next, submit, answer, recommend, revise, close, budget, export, run) pass admission first (lib/autoplan-admission.ts)
|
||||
* Mutating verbs (next, submit, answer, recommend, revise, close, budget, check, export, run) pass admission first (lib/autoplan-admission.ts)
|
||||
* and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
@@ -33,6 +34,7 @@ import * as path from 'node:path';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
|
||||
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
|
||||
import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget';
|
||||
import { DEFAULT_CARRY_CHECK_CAP_USD, carryLines, submitCarryCheck } from '../lib/autoplan-carry';
|
||||
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';
|
||||
@@ -60,6 +62,9 @@ DRIVER: ${driverFile()}
|
||||
--estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
|
||||
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
|
||||
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
|
||||
--no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new
|
||||
--carry-check-cap <usd> (first next): the most one delta check may reserve before a carry (default ${DEFAULT_CARRY_CHECK_CAP_USD.toFixed(2)})
|
||||
the first next prints REVIEW_ESTIMATE: the most the review can reserve at this scope and round cap
|
||||
prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result
|
||||
submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
|
||||
--model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
|
||||
@@ -84,6 +89,9 @@ DRIVER: ${driverFile()}
|
||||
resume --out <dir> [--from <checkpoint>] [--json] --from restores a hash-verified checkpoint into an empty --out first
|
||||
(a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command
|
||||
leaving the run at rest publishes a generation under <dir>/<run>/ and prints CHECKPOINT: on stderr)
|
||||
check --out <dir> --attempt <id> --result <file> --model <id> [--same-family "<why>"] | --unavailable "<reason>"
|
||||
bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's
|
||||
last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before
|
||||
checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
|
||||
close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>>
|
||||
close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their
|
||||
@@ -180,7 +188,10 @@ function doNext(out: string, rest: string[], json: boolean): number {
|
||||
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
|
||||
const maxRounds = num(rest, '--max-rounds');
|
||||
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
|
||||
const carryCheckCapUsd = num(rest, '--carry-check-cap');
|
||||
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
|
||||
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
|
||||
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd,
|
||||
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
|
||||
}
|
||||
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
|
||||
@@ -257,7 +268,7 @@ function doClose(out: string, rest: string[], json: boolean): number {
|
||||
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
|
||||
moment = 'transition';
|
||||
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
|
||||
if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state, out)); }
|
||||
if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
|
||||
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
|
||||
writeState(state);
|
||||
lines.push(resultLineFor(state.status, out));
|
||||
@@ -265,6 +276,19 @@ function doClose(out: string, rest: string[], json: boolean): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */
|
||||
function doCheck(out: string, rest: string[], json: boolean): number {
|
||||
const unavailable = flag(rest, '--unavailable');
|
||||
const result = flag(rest, '--result');
|
||||
if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt <id> and --result <file> --model <id>, or --unavailable "<reason>"');
|
||||
const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') });
|
||||
moment = 'transition';
|
||||
const status = r.state.status;
|
||||
const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)];
|
||||
emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out });
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function doBudget(out: string, rest: string[], json: boolean): number {
|
||||
const state = readState(out);
|
||||
const lines = changeBudget(state, { spendCap: num(rest, '--spend-cap'), maxRounds: num(rest, '--max-rounds'), by: flag(rest, '--by') });
|
||||
@@ -338,6 +362,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json));
|
||||
if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json));
|
||||
if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json));
|
||||
if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json));
|
||||
if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); }
|
||||
if (cmd === 'status') {
|
||||
const state = readState(out);
|
||||
@@ -416,7 +441,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (e instanceof RunError) {
|
||||
process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n');
|
||||
if (e.after.lines?.length) process.stdout.write(e.after.lines.join('\n') + '\n');
|
||||
if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
|
||||
if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget', 'check'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
|
||||
return e.exit === 3 ? EXIT.refused : EXIT.fail;
|
||||
}
|
||||
process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`);
|
||||
|
||||
+19
-1
@@ -23,7 +23,13 @@
|
||||
# applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on
|
||||
# success re-execs the NEW checkout's copy of this script with the same
|
||||
# arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the
|
||||
# installer runs. Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# installer runs. A live lease under 120 minutes old (the lease prune window)
|
||||
# held by this session's own process chain also skips it (#3571):
|
||||
# gstack-upgrade never counts the caller's own session, but an install run from
|
||||
# inside a workflow (/ship's docs step) must not change gstack under it. The age
|
||||
# bound matters on Capy, where the leased harness pid is the machine's
|
||||
# long-lived capy-server and a live lease alone would never end the deferral;
|
||||
# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
|
||||
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
|
||||
# --check never moves the checkout; behind origin it prints
|
||||
@@ -107,6 +113,18 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
|
||||
elif ! behind; then echo "SELF_UPGRADE: none (current)"
|
||||
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN"
|
||||
export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)"
|
||||
elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
|
||||
&& OWN=$("$ROOT/bin/gstack-lease" live --root "$ROOT" 2>/dev/null | awk '{ a = $2; sub(/m$/, "", a); if (a + 0 < 120) print }') \
|
||||
&& [ -n "$OWN" ] \
|
||||
&& ! "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ >/dev/null 2>&1; then
|
||||
HOLDERS=""
|
||||
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_OWN
|
||||
$OWN
|
||||
EOF_OWN
|
||||
REASON="a gstack run in this session holds a workflow lease on this installation: $HOLDERS, so the upgrade waits until the lease is 120 minutes old; fix: $ROOT/bin/gstack-upgrade once the run ends (UPGRADE_BUSY)"
|
||||
echo "SELF_UPGRADE: skipped ($REASON)"
|
||||
report_behind "$ROOT/bin/gstack-upgrade"
|
||||
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
|
||||
else
|
||||
OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null)
|
||||
UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$?
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* and a cell whose first byte a spreadsheet would run as a formula (=, +, -, @)
|
||||
* gets a leading apostrophe, so evidence taken from PR titles, filenames or
|
||||
* generated text cannot execute when a reviewer opens the file. License text:
|
||||
* licenses/MIT-pstack.txt; NOTICE.md carries the entry.
|
||||
* licenses/MIT-pstack.txt; NOTICE.md carries the entry. The append rules live in
|
||||
* lib/decision-trail.ts, shared with the autoplan runner.
|
||||
*
|
||||
* The start-row rule is gstack's mechanisation of the same skill's prose: a run
|
||||
* (one agent conversation, with its later turns) that writes to a trail already
|
||||
@@ -26,11 +27,8 @@
|
||||
* id, and an append with `--run <id>` refuses until that run's start row is the
|
||||
* newest start row in the file (DECISION_TRAIL_START_REQUIRED, exit 3).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { appendTrailRow as appendRow, readTrailRows as readRows, type TrailRow as Row } from "../lib/decision-trail";
|
||||
|
||||
const COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const;
|
||||
const HEADER = COLUMNS.join("\t") + "\n";
|
||||
const USAGE = `Usage:
|
||||
gstack-decision-trail [--run <id>] <file> <phase> <decision> <why> <evidence> <result>
|
||||
gstack-decision-trail start <file> <run-id>
|
||||
@@ -56,58 +54,6 @@ letters, digits and . _ : @ / - (a Capy jam_ id, a session id, a PR number).
|
||||
when the trail has rows and the newest start row is another run's.
|
||||
`;
|
||||
|
||||
type Row = { line: number; cells: string[] };
|
||||
|
||||
function clean(cell: string): string {
|
||||
const flat = cell.replace(/[\t\n\r]/g, " ");
|
||||
return /^[=+\-@]/.test(flat) ? `'${flat}` : flat;
|
||||
}
|
||||
|
||||
function readRows(file: string): Row[] {
|
||||
if (!fs.existsSync(file)) return [];
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
const rows: Row[] = [];
|
||||
lines.forEach((raw, i) => {
|
||||
let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
||||
if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
|
||||
// A repeated header (the append-on-flaky-mount case) is never a row.
|
||||
if (text === "" || text === HEADER.trimEnd()) return;
|
||||
rows.push({ line: i + 1, cells: text.split("\t") });
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function appendRow(file: string, cells: readonly string[]): number {
|
||||
const dir = path.dirname(file);
|
||||
if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
// Append the header too, never truncate: on a flaky mount the worst case is
|
||||
// one stray header line, not lost rows.
|
||||
// A file with no header line anywhere (empty, blank, or rows written by hand)
|
||||
// gets one appended once; readRows skips a header line wherever it sits.
|
||||
const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
||||
if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
|
||||
if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, HEADER);
|
||||
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
|
||||
return readRows(file).length;
|
||||
}
|
||||
|
||||
function hasHeader(file: string): boolean {
|
||||
return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n")
|
||||
.some(line => line.replace(/\r$/, "") === HEADER.trimEnd());
|
||||
}
|
||||
|
||||
function endsWithNewline(file: string, size: number): boolean {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const last = Buffer.alloc(1);
|
||||
fs.readSync(fd, last, 0, 1, size - 1);
|
||||
return last[0] === 0x0a;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
const RUN_ID = /^[A-Za-z0-9._:@/-]+$/;
|
||||
function checkRunId(runId: string): void {
|
||||
if (!RUN_ID.test(runId)) fail(`run id must match ${RUN_ID} (letters, digits, . _ : @ / -), got ${JSON.stringify(runId)}`, 2);
|
||||
|
||||
+3
-3
@@ -40,7 +40,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
import { cancelActive } from '../lib/pregate/supervise';
|
||||
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
|
||||
import { GATE_AHEAD_REL } from '../lib/restamp';
|
||||
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard';
|
||||
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard';
|
||||
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { fromGateAhead } from '../lib/tree-receipt';
|
||||
|
||||
@@ -244,13 +244,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
|
||||
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
|
||||
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
|
||||
const report = 'error' in listed
|
||||
? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote }
|
||||
? sealedErrorReport(listed.error, remoteUrl ?? remote)
|
||||
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
|
||||
const blocks = report.status === 'refused' || report.status === 'error';
|
||||
if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; }
|
||||
process.stderr.write(renderSealed(report, 'gstack-pregate sealed'));
|
||||
const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
|
||||
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} policy=${policy}\n`);
|
||||
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
|
||||
return blocks ? EXIT.fail : EXIT.ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import * as path from "path";
|
||||
import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine";
|
||||
import { mkdirpSync } from "../lib/fs-utils";
|
||||
import { resolveStateRoot } from "../lib/state-root";
|
||||
import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard";
|
||||
import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard";
|
||||
|
||||
const ZERO = /^0+$/;
|
||||
let emptyTree: string | undefined;
|
||||
@@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
|
||||
const targets: Array<{ commits: string[]; pushedSha: string }> = [];
|
||||
for (const ref of refs) {
|
||||
const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName });
|
||||
if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" };
|
||||
if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
|
||||
targets.push({ commits: published.commits, pushedSha: ref.localSha });
|
||||
}
|
||||
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
|
||||
|
||||
+24
-6
@@ -547,6 +547,8 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`);
|
||||
const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head);
|
||||
if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`);
|
||||
const reviewedBase = receipt.base.replace(/^origin\//, '');
|
||||
const retarget = reviewedBase !== base.replace(/^origin\//, '');
|
||||
let plan: ReturnType<typeof planCarry>;
|
||||
if (!ancestor) {
|
||||
// Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded.
|
||||
@@ -565,11 +567,25 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests));
|
||||
const testHead = asId(patchId(repoRoot, headBase, head, spec.tests));
|
||||
for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
|
||||
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
|
||||
// and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
|
||||
// path the base changed, none of which can differ between the two base diffs.
|
||||
// A stacked child whose parent landed on the base (#3570): the receipt reviewed the child's own range against the
|
||||
// parent branch, and the PR now targets the base. That reviewed range (the recorded product patch-id, else the one
|
||||
// against origin/<receipt base> when it still resolves) must be HEAD's whole product diff against the base, computed
|
||||
// the way write records it (headPatch): the parent fully landed and the child is unchanged. Anything else fails closed.
|
||||
let reviewed = productFrom;
|
||||
if (retarget) {
|
||||
const parentRef = `origin/${reviewedBase}`;
|
||||
const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id)
|
||||
: git(['rev-parse', '--verify', '--quiet', `${parentRef}^{commit}`], repoRoot) ? productPatchAgainstBase(repoRoot, rules, parentRef, from) : { error: `the receipt records no product patch-id and ${parentRef} no longer resolves` };
|
||||
if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the receipt was reviewed against ${reviewedBase}, not ${base}, and its reviewed product diff is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`);
|
||||
reviewed = recorded;
|
||||
}
|
||||
const current = retarget ? headPatch : productHead;
|
||||
const changedProduct: string[] = [];
|
||||
if (productFrom !== productHead) {
|
||||
if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`);
|
||||
else if (productFrom !== productHead && !retarget) {
|
||||
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
|
||||
// and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
|
||||
// path the base changed, none of which can differ between the two base diffs.
|
||||
const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean));
|
||||
for (const p of [...touched].sort()) {
|
||||
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
|
||||
@@ -583,7 +599,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
|
||||
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
|
||||
plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
|
||||
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) });
|
||||
}
|
||||
if (!plan.ok) return refused(plan.code, plan.message);
|
||||
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
|
||||
@@ -611,7 +627,9 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`);
|
||||
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } };
|
||||
}
|
||||
const carried = carryReceipt({ ...receipt, ...pregateField, ...depthField }, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
|
||||
// The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on.
|
||||
const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
|
||||
const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
|
||||
const errors = validateReceipt(carried);
|
||||
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
|
||||
const newBody = placeReceiptFirst(body, carried);
|
||||
|
||||
@@ -14,7 +14,7 @@ Spend cap: <focused paid runs per PR, from SPEND>
|
||||
|
||||
1. Plan (when Plan: required). After the session-start install check, install for autoplan
|
||||
and follow autoplan/DRIVER.md "On Capy" (launch file sourced, GSTACK_AUTOPLAN_CHECKPOINT_DIR
|
||||
set, run dir under ~/.capy/work). Write the plan with its ## Before → after,
|
||||
set, run dir under ~/.capy/work). Write the plan with its ## ELI5, ## Before → after,
|
||||
## Why it matters, ## What caused this work, ## Big decisions and ## Implementation plan
|
||||
sections, then run gstack-autoplan next --out <run dir> --plan <plan.md> <scope flags>
|
||||
sized to this brief (a one-file fix: --no-ui --no-dx --max-rounds 1 --estimate-usd <n>
|
||||
@@ -25,7 +25,8 @@ Spend cap: <focused paid runs per PR, from SPEND>
|
||||
(gstack-autoplan answer --out <run dir> --gate-rev <n> --reply <tokens> --rec-hash <h>
|
||||
--by agent:<your thread> --owner-reply "<their words>"), incorporate every accepted plan
|
||||
change (revise), finish every reopened phase, and start product edits only when the run
|
||||
reports APPROVED. Then gstack-approvals import --autoplan <run dir> --by "<owner>".
|
||||
reports APPROVED (a later gate that asks nothing new prints CARRIED: and needs no second
|
||||
ask). Then gstack-approvals import --autoplan <run dir> --by "<owner>" (--stack b1,b2 for a stack).
|
||||
New scope you take on part-way goes back to step 1 with its own plan.
|
||||
3. Ship. CHANGELOG entry under [Unreleased]; GUARD_CHECKS, pregate and /ship at the final
|
||||
head; one unstamped READY block (captain/templates/READY.md) to me; the PR body carries
|
||||
|
||||
@@ -7,6 +7,9 @@ shape from an autoplan run; write it by hand for any other plan.
|
||||
```text
|
||||
Decision needed on <thread>: <one-line title>. Reply **rec** to take every recommendation.
|
||||
|
||||
## ELI5
|
||||
<5-10 plain sentences: what changes, for whom, and what it costs>
|
||||
|
||||
## Before → after
|
||||
<what the user or maintainer sees today; what they will see after; a command and its
|
||||
output, a screen, a cost or time number>
|
||||
|
||||
@@ -48,6 +48,7 @@ gstack-approvals add --ask "Spend up to \$40 on a paid eval rerun for the ship c
|
||||
gstack-approvals add --outcome standing --ask "Merge #3330 when green" \
|
||||
--words "yes merge when green" --by "Garry, 2026-10-16 (Captain rule)" --scope "every crew PR, through a train"
|
||||
gstack-approvals import --autoplan <run dir> --by Garry # after gstack-autoplan answer; idempotent
|
||||
gstack-approvals import --autoplan <run dir> --by Garry --stack b1,b2 # a stacked PR set: every branch, or none
|
||||
gstack-approvals render # the ## Approvals section
|
||||
```
|
||||
|
||||
@@ -93,15 +94,15 @@ project can follow it:
|
||||
the plan names (docs, trains, cleanups) included, and carry them through to done with no further ask.
|
||||
Say in one line what it covers. For an autoplan gate, "do it" answers as `rec`; record the words.
|
||||
|
||||
For an autoplan run, write parts 1 to 4 as `## Before → after`, `## Why it matters`,
|
||||
`## What caused this work` and `## Big decisions` sections in the plan. Then, at the gate:
|
||||
For an autoplan run, open the plan with `## ELI5` (5-10 plain sentences), then write parts 1 to 4 as
|
||||
`## Before → after`, `## Why it matters`, `## What caused this work` and `## Big decisions` sections. Then, at the gate:
|
||||
|
||||
```bash
|
||||
gstack-owner-brief render --captain --out <run dir>
|
||||
```
|
||||
|
||||
It prints a header line ("Decision needed … Reply **rec** …"), the four plan sections (each capped, with a
|
||||
pointer to the plan for the rest), and "What we need from you", which lists every choice `rec` takes, in
|
||||
It prints a header line ("Decision needed … Reply **rec** …"), the `### ELI5` summary (on a run whose gate
|
||||
carries `eli5`), 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, 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:
|
||||
@@ -112,6 +113,11 @@ gstack-autoplan answer --out <run dir> --gate-rev <n> --reply rec --rec-hash <h>
|
||||
gstack-approvals import --autoplan <run dir> --by <name>
|
||||
```
|
||||
|
||||
A later gate that asks nothing new is answered for you with the words you recorded (`CARRIED:`, then `APPROVED:`);
|
||||
the brief says "Nothing pending. Carried forward", so import and build without a second ask. `CARRY_CHECK:` means
|
||||
one reviewer from another model family reads the unreviewed delta first (`gstack-autoplan check`). Anything
|
||||
else prints `CARRY: no (<why>)` and goes to the person as today (`docs/unattended.md#carry-forward`).
|
||||
|
||||
If the person answers in words ("split it into two PRs"), map the words to tokens, answer, and confirm in one
|
||||
line what you recorded ("Recorded: split into two PRs; everything else as recommended"). A plan missing a
|
||||
section renders "Not ready for approval" with no reply invitation, and exits 1 (`CAPTAIN_BRIEF_INCOMPLETE`):
|
||||
|
||||
+8
-5
@@ -138,8 +138,9 @@ this rule shipped cites this section by name.
|
||||
**What the lane sends.** The plan and its gate in plain language a person outside the
|
||||
project can follow: what changes, why now, what the change does, what it costs, the lane's
|
||||
pick, and the recommended one-word reply. `gstack-owner-brief render --captain --out <run dir>`
|
||||
prints that from the plan's `## Before → after`, `## Why it matters`,
|
||||
`## What caused this work` and `## Big decisions` sections, with the cost lines
|
||||
prints that from the plan's `## ELI5`, `## Before → after`, `## Why it matters`,
|
||||
`## What caused this work` and `## Big decisions` sections (also saved as `eli5` in
|
||||
`gate.json`, so `gstack-gate render` opens with them), with the cost lines
|
||||
(`GATE_SPEND:`, `GATE_REC_COST:`) and a last line for the controller; a plan missing a
|
||||
section exits `CAPTAIN_BRIEF_INCOMPLETE`, so the lane adds the section and renders again
|
||||
([capy/skill/gstack/captain.md](../capy/skill/gstack/captain.md) § 3 lists them). The lane
|
||||
@@ -156,10 +157,12 @@ controller and been answered into the run
|
||||
every accepted plan change has been incorporated (`revise`), every reopened phase has
|
||||
finished, and the run reports `APPROVED` (`status=complete` after `export`). An answered
|
||||
gate that still prints `PLAN_AMEND_PENDING` or `REOPENED:` is not a completed plan, and an
|
||||
imported approval row alone is not completion.
|
||||
imported approval row alone is not completion. A later gate revision that asks nothing new
|
||||
is answered with the owner's recorded words and says `CARRIED:`; that is the owner's word,
|
||||
not a new ask ([carry-forward](unattended.md#carry-forward)).
|
||||
|
||||
**The record.** `gstack-approvals import --autoplan <run dir> --by "<owner>"` writes the
|
||||
approval row (date, the ask, the owner's exact words, the lane that answered as relayer,
|
||||
**The record.** `gstack-approvals import --autoplan <run dir> --by "<owner>"` (with
|
||||
`--stack <b1,b2,...>` for a stacked PR set) writes the approval row (date, the ask, the owner's exact words, the lane that answered as relayer,
|
||||
what it covers); `gstack-approvals render` puts it in the report and the PR body. Before
|
||||
training the PR the controller compares that row's words and `gate_rev` with the ask it
|
||||
relayed and the reply it carried back; a row that does not match is not READY (shape A).
|
||||
|
||||
+10
-1
@@ -155,7 +155,16 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after
|
||||
a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start
|
||||
self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy)
|
||||
while a lease held by a live process outside its own chain exists, naming each
|
||||
holder as `pid <p> (<age>m <command>)`. The team-mode SessionStart hook
|
||||
holder as `pid <p> (<age>m <command>)`. The install-mode self-upgrade also
|
||||
defers while its own chain holds a live lease under 120 minutes old. An install
|
||||
run from inside a workflow (/ship's docs step running `install.sh --for
|
||||
document-release`, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
|
||||
session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`,
|
||||
leaves the checkout where the run started and stays ok with `behind=`. Each
|
||||
`gstack-skill-start` renews the lease. On Capy the leased pid is the machine's
|
||||
long-lived `capy-server`, so the deferral ends when the lease turns 120
|
||||
minutes old, not when the process exits; `bin/gstack-upgrade` run by hand
|
||||
moves the checkout sooner. The team-mode SessionStart hook
|
||||
(`bin/gstack-session-update`) honors the same leases: it logs
|
||||
`SKIP leased pid=<p>/<age>m` to `analytics/session-update.log` and tries again at
|
||||
the next session start, without writing its hourly throttle stamp. A lease whose
|
||||
|
||||
+35
-3
@@ -768,11 +768,13 @@ with a timing flake: never raise the bound.
|
||||
|
||||
```json
|
||||
{ "sealed": {
|
||||
"paths": ["heldout/**"],
|
||||
"paths": ["heldout/**", "**/sealed/**"],
|
||||
"transcripts": ["runs/**/transcript*.jsonl"],
|
||||
"allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"],
|
||||
"answer_markers": ["SEALED ANSWER:"],
|
||||
"seed_patterns": ["shuffle_seed=\\d+"],
|
||||
"local_paths": true } }
|
||||
"local_paths": true,
|
||||
"case_insensitive": true } }
|
||||
```
|
||||
|
||||
A repo with a `sealed` key gets a configured local check against accidental
|
||||
@@ -792,8 +794,38 @@ gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--js
|
||||
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
|
||||
(`/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\`) check each commit's added
|
||||
lines. Findings print rule, `file[:line]` and commit, never the text.
|
||||
- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole
|
||||
repo-relative path from the repository root. `*` and `?` stay inside one
|
||||
path segment; `**/` spans zero or more directories; a `**` not followed by
|
||||
`/` matches across directories (`heldout/**` covers every depth under
|
||||
`heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`:
|
||||
to guard a `sealed/` directory wherever it sits, write `**/sealed/**`.
|
||||
- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case
|
||||
for `paths` and `transcripts` only, so `**/heldout/**` also catches
|
||||
`HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`,
|
||||
`held_out/`, `heldout-v2/`) are not folded: list each as its own glob.
|
||||
The effective value is the base branch's OR the pushed one (it only tightens).
|
||||
- **Exceptions.** `allow_paths` exempts publishable files that sit next to
|
||||
sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and
|
||||
`sealed.transcript`, applied after the deny globs and before waivers. They
|
||||
are read from the destination base branch only: an exception a push adds
|
||||
takes effect once it lands on the base branch, so a branch can never widen
|
||||
its own. They never fold case. An entry whose file name contains `**`, or
|
||||
is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`,
|
||||
`sealed/**.json`), is refused; a partial wildcard such as `MANIFEST*.json`
|
||||
is accepted and matches every file it spells, so name the file. An exempt file is checked by the
|
||||
text rules only, so configure `answer_markers` when you exempt files next to
|
||||
sealed data. Each exempt path is listed on every push (`exempt <rule>
|
||||
<file> commit <sha> (sealed.allow_paths <glob>)`) and counted as
|
||||
`exempt=<n>` on the `SEALED:` line.
|
||||
- **Version skew.** A gstack older than the release whose CHANGELOG adds
|
||||
`sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as
|
||||
unknown keys, so every push and `gstack-pregate run` in the repository
|
||||
fail on that machine. Raise `min_gstack_version` in
|
||||
`.gstack/ship-policy.json` to that release before landing either key.
|
||||
- **Policy.** The union of the destination base branch's `sealed` and the
|
||||
pushed commit's: a push can tighten it, never relax it. A malformed or
|
||||
pushed commit's: a push can tighten it, never relax it (`allow_paths` come
|
||||
from the base branch alone, and a pushed deny glob cannot override one). A malformed or
|
||||
unreadable policy refuses. Waivers come only from the base branch's
|
||||
`.gstack/sealed-allow.tsv` (`rule<TAB>path<TAB>sha<TAB>reason`) and are
|
||||
listed on every push.
|
||||
|
||||
@@ -954,6 +954,22 @@ receipt when the base moves:
|
||||
| a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) |
|
||||
| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) |
|
||||
| any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) |
|
||||
| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent <branch> landed`) |
|
||||
|
||||
**Stacked PRs (#3570).** A child PR's receipt is written against its parent
|
||||
branch, so its `base` names that branch and its `product_patch_id` is the
|
||||
child's own range. After the parent lands, the PR is retargeted to the base, and
|
||||
/ship's carry check runs with `--base <base>`. When that base differs from the
|
||||
receipt's, the carry compares the reviewed range (the recorded
|
||||
`product_patch_id`, or for an older receipt the range against
|
||||
`origin/<parent>` while that branch still resolves) with HEAD's whole product
|
||||
diff against the base, computed the way `write` records it, so a parent's
|
||||
release-metadata change that never reached the base counts too. Equal means
|
||||
the parent fully landed and the child is unchanged, so the receipt carries
|
||||
and from then on names the base it was carried to. A parent that has not landed, a child edited
|
||||
since, or a parent that landed with changes the child never merged all leave a
|
||||
different diff and refuse with `RECEIPT_CARRY_PRODUCT_CHANGED`. An older
|
||||
receipt whose parent branch is gone refuses with `RECEIPT_CARRY_REFUSED`.
|
||||
|
||||
Since the release that added `review_scope`. A `/ship` receipt names the head it
|
||||
reviewed, so a conflict-only merge of the base or a test-only fix used to
|
||||
|
||||
+60
-15
@@ -1566,11 +1566,19 @@ process has exited never blocks, and neither does a stale one: each lease
|
||||
records its process's start identity, so a pid reused after a reboot or a
|
||||
machine wake (GBRA-105's lease named pid 379, which had become dbus-daemon)
|
||||
is reclaimed instead of blocking. The upgrader's own session (the pid chain it
|
||||
runs under) never counts.
|
||||
runs under) never counts for `bin/gstack-upgrade` run by hand. A Capy install
|
||||
in install mode (`bin/gstack-capy-session`) counts it while that lease is under
|
||||
120 minutes old: an install run from inside a workflow that holds the lease
|
||||
(/ship's docs step, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
|
||||
session holds a workflow lease on this installation: pid <p> (<age>m
|
||||
<command>), so the upgrade waits until the lease is 120 minutes old; fix:
|
||||
<checkout>/bin/gstack-upgrade once the run ends (UPGRADE_BUSY))` and leaves the
|
||||
checkout where the run started. Each `gstack-skill-start` renews the lease.
|
||||
|
||||
**Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows
|
||||
every lease with its age and `alive|dead|stale`; a holder is named as
|
||||
`pid <p> (<age>m <command>)`), then re-run `bin/gstack-upgrade`.
|
||||
`pid <p> (<age>m <command>)`), then re-run `bin/gstack-upgrade`. For the
|
||||
session's own run, run the printed `bin/gstack-upgrade` after the run ends.
|
||||
When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
|
||||
proceeds. At a Capy session start the same refusal appears as
|
||||
`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`,
|
||||
@@ -1729,8 +1737,11 @@ restate them. Rotate the secret if it was real.
|
||||
|
||||
**Meaning.** The record is kept per branch (one Capy thread ships one branch), and
|
||||
HEAD is detached or the working directory is not a git repository. Exit 3.
|
||||
From `import --autoplan <dir> --stack <b1,b2,...>` it means a listed branch exists
|
||||
neither locally nor on origin; the line names it, and no branch's record was written.
|
||||
|
||||
**Fix.** Check out the branch the work ships from, or pass `--branch <name>`.
|
||||
**Fix.** Check out the branch the work ships from, or pass `--branch <name>`. For
|
||||
`--stack`, correct or fetch the named branch and import again.
|
||||
|
||||
<a id="approval-record-unavailable"></a>
|
||||
### `gstack-approvals: ... (APPROVAL_RECORD_UNAVAILABLE)`
|
||||
@@ -1769,7 +1780,9 @@ import again. Imports are idempotent, so a retry never records an answer twice.
|
||||
### `gstack-owner-brief render --captain: ... (CAPTAIN_BRIEF_INCOMPLETE)`
|
||||
|
||||
**Meaning.** The plan is missing at least one section the brief needs: `## Before → after`,
|
||||
`## Why it matters`, `## What caused this work`, `## Big decisions`. The brief still prints,
|
||||
`## Why it matters`, `## What caused this work`, `## Big decisions`, and `## ELI5` (alias
|
||||
`## In plain words`) on a run whose `gate.json` carries an `eli5` field (gstack 1.91.305 and
|
||||
later; older runs render as before). The brief still prints,
|
||||
but it opens with "Not ready for approval" and has no reply invitation. Exit 1.
|
||||
|
||||
**Fix.** Add the named sections to the plan (`gstack-autoplan revise --plan <file>` if
|
||||
@@ -2474,6 +2487,14 @@ and an E.164 `+14155551234` still report, so the finding means the id stands
|
||||
without a run word (`the id is 40269992077`, `40269992077 failed`) or follows
|
||||
a word that is not one (`see 40323989742`).
|
||||
|
||||
Separately, the scanner skips a bare number that is exactly a power of two
|
||||
from 2^30 through 2^49, one less than such a power, or one of eleven 32-bit
|
||||
hash and PRNG constants (Knuth's multiplicative hash, the golden-ratio
|
||||
constant, the glibc and Numerical Recipes LCGs, FNV-1a, MurmurHash2 and 3, the
|
||||
MT19937 seeder and the largest 32-bit prime; `isNumericConstant` in
|
||||
`lib/redact-patterns.ts`). Only the exact digits with no separator are exempt:
|
||||
the same digits with a `+`, spaces, dashes, dots or parentheses still report.
|
||||
|
||||
**Fix.** Write the id next to a run word (`run 40269992077`) or as an
|
||||
`/actions/runs/` link. For anything else, rerun with `--confirm <token>`
|
||||
using the `TOKEN:` line the refusal printed; MEDIUM findings do not block
|
||||
@@ -2514,6 +2535,15 @@ An address only another remote knows still reports. If the hook also printed
|
||||
`existing-email suppression was limited for this push`, it could not read the
|
||||
history in 5 seconds, so known authors may be listed too.
|
||||
|
||||
A filename with an `@` in the package@version style is not an address and
|
||||
never reports: the part after the `@` is a version or tag (one dotless label
|
||||
or a dotted version, never a domain name) followed by file extensions no
|
||||
top-level domain uses, or a dotted all-digit version followed by
|
||||
an extension that doubles as a top-level domain (`looksLikeAtFilename` in
|
||||
`lib/redact-patterns.ts`). A file named after a real address, an address at an
|
||||
all-digit domain and an address whose top-level domain doubles as a file
|
||||
extension still report.
|
||||
|
||||
**Fix.** Remove the address, or allow it for this repo:
|
||||
`git config --add gstack.redact.allowEmail <address>`. If it is your own
|
||||
address, check `git config user.email`. The allowlist only covers `pii.email`;
|
||||
@@ -3238,7 +3268,10 @@ cannot be downgraded; every other check id in `warn` is honoured only from the
|
||||
base branch's copy or the user-owned file. For the user-owned file, edit it
|
||||
directly at the path the message names. A user file inside the repository, or
|
||||
reached through a symlink into it, is ignored rather than read (`--explain`
|
||||
names it).
|
||||
names it). An unknown `sealed` key that a newer gstack defines (such as
|
||||
`allow_paths` or `case_insensitive`) means this machine's gstack is older than
|
||||
the policy: upgrade gstack here, and raise `min_gstack_version` in the
|
||||
repository's `.gstack/ship-policy.json` before landing such a key.
|
||||
|
||||
<a id="pregate-secrets"></a>
|
||||
### `PREGATE_SECRETS`
|
||||
@@ -3744,7 +3777,11 @@ finding: rule, `file[:line]`, commit. They never print the matched text.
|
||||
|
||||
**Fix.** Remove the output from every published commit before the branch
|
||||
reaches the public repository (a later commit that deletes it still publishes
|
||||
it). A reviewed false positive gets one line in the base branch's
|
||||
it). A publishable file kept next to sealed data (a `MANIFEST.json` of hashes
|
||||
and counts) can be exempted from the path rules with a `sealed.allow_paths`
|
||||
glob in the base branch's `.gstack/pregate.json`; the text rules still read it
|
||||
([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets
|
||||
one line in the base branch's
|
||||
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`. A private
|
||||
destination sets `"visibility": "private"` under `sealed` in the base branch's
|
||||
`.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so
|
||||
@@ -4819,8 +4856,10 @@ to HEAD: the PR body has no valid receipt, the working tree has uncommitted or
|
||||
untracked changes (a carry certifies a commit, never a tree that differs from
|
||||
it), the receipt's head left HEAD's history and the receipt records no tree to
|
||||
compare (a receipt written before `head_tree`, whose head is not in this
|
||||
clone), or git could not compare the two product diffs or read the working
|
||||
tree's status. It also refuses when an owner exception the receipt records
|
||||
clone), the PR was retargeted to the base after its stack parent landed but
|
||||
the receipt records no `product_patch_id` and `origin/<parent>` no longer
|
||||
resolves, so the reviewed range cannot be recomputed, or git could not
|
||||
compare the two product diffs or read the working tree's status. It also refuses when an owner exception the receipt records
|
||||
no longer stands, and, for a receipt from a gstack that records review
|
||||
depth, when HEAD needs a deeper review than the receipt records, `--head` is
|
||||
not the checked-out HEAD, or the receipt records `gstack_version` without
|
||||
@@ -4831,9 +4870,10 @@ ready` refuses `QUEUE_RECEIPT_STALE`.
|
||||
**Fix.** Commit or stash every working-tree change, then rerun
|
||||
`gstack-ship-receipt carry --pr <n>` at the new head. When the history is
|
||||
already rewritten and the receipt predates `head_tree`, run /ship on the new
|
||||
head; next time merge the base in instead of rebasing. For a depth or
|
||||
exception refusal, run /ship on the new head (or check out `--head` and carry
|
||||
there).
|
||||
head; next time merge the base in instead of rebasing. For a retargeted stacked
|
||||
PR whose older receipt lost its parent branch, or a depth or exception
|
||||
refusal, run /ship on the new head. When `--head` is not the checked-out HEAD,
|
||||
check it out and carry there.
|
||||
|
||||
<a id="review-loop-exception-invalid"></a>
|
||||
### `REVIEW_LOOP_EXCEPTION_INVALID`
|
||||
@@ -4872,13 +4912,18 @@ registered output the pregate's regen check proved (listed under `proven`,
|
||||
checked against the same base registry), nor part of the base merge has
|
||||
a different patch than the reviewed head had. After a rebase it means HEAD has
|
||||
the reviewed tree but the base moved under it, so the diff a reviewer would see
|
||||
is not the reviewed one. The reviewed verdict does not cover HEAD, so the
|
||||
receipt is not carried. The refusal names the changed product paths.
|
||||
is not the reviewed one. For a stacked PR retargeted to the base after its
|
||||
parent landed, it means HEAD's product diff against the base is not the range
|
||||
reviewed against the parent branch: the parent has not fully landed, or the
|
||||
branch changed since. The reviewed verdict does not cover HEAD, so the
|
||||
receipt is not carried. The refusal names the changed product paths, or for a
|
||||
retargeted stacked PR the parent branch and the base.
|
||||
|
||||
**Fix.** Run /ship on the new head. The receipt carries only across a
|
||||
conflict-only base merge, a test-only change, release metadata, a registered
|
||||
output the pregate's regen check proved, or a rebase onto a covered tree with
|
||||
the recorded product diff, which the
|
||||
output the pregate's regen check proved, a rebase onto a covered tree with
|
||||
the recorded product diff, or a stacked PR's retarget once its parent fully
|
||||
landed on the base, which the
|
||||
`carry` object in the carried receipt records with the product patch-id and
|
||||
the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)).
|
||||
|
||||
|
||||
@@ -504,6 +504,53 @@ by its runner. Result codes: `EXECUTION_UNKNOWN`, `RUN_LOCKED`,
|
||||
decision ids are renamed and journaled); one written by a newer gstack is
|
||||
refused.
|
||||
|
||||
### Carry-forward
|
||||
|
||||
A gate revision after the first (`gate_rev` 2 and later, written after a
|
||||
`revise`) is answered with the owner's recorded words from the revision before
|
||||
it when nothing new needs their decision. `gstack-autoplan` prints
|
||||
`CARRIED: gate_rev=<n> <- gate_rev=<m> words="<their words>" by=<relayer> reason=<why>`,
|
||||
then the usual `APPROVED:` lines, and journals `gate_carried`. The carry needs
|
||||
all of: the earlier gate approved (`p1a` or `p1b`) with `rec` or "do it"
|
||||
(not item tokens), no item there left pending, and the owner's words
|
||||
recorded; that gate was not itself carried; every accepted change incorporated
|
||||
or settled; the plan file's bytes equal those recorded at the gate or at the
|
||||
`revise` that took the changes (any later edit refuses, an ELI5-only one
|
||||
included); no urgent or
|
||||
outside-plan finding open; and every new gate item one of the cases below.
|
||||
Anything else prints `CARRY: no (<why>)` (journal `carry_refused`) and the gate
|
||||
goes to the owner as before. `--no-carry-forward` on the first `next` turns it
|
||||
off for the run.
|
||||
|
||||
- **"do it".** When the owner's words were "do it" (their words, not a
|
||||
`captain:` relay of them), a new `auto` item that changes no scope, cost or
|
||||
direction, whose recommended option changes no plan text, and is no round
|
||||
or budget grant takes its recommendation. The
|
||||
decision row gets `carried_under: do it`, the output prints
|
||||
`CARRIED_UNDER_DO_IT:` per item, and the approval record lists them as
|
||||
"taken under "do it", not seen by the owner". `rec` carries no new item.
|
||||
- **Unreviewed post-cap delta.** A "plan changed after <phase> cap" item
|
||||
recommending `a` (accept) is read by one reviewer before it carries. `CARRY_CHECK:` names the attempt and
|
||||
the prompt (the delta against `plan-at-gate.r<m>.md`, the accepted changes,
|
||||
the full plan); the run reserves the estimate in `spend.json` and refuses
|
||||
when it is over `--carry-check-cap <usd>` (default 5) or the run's cap.
|
||||
Dispatch it to a model family other than the plan reviewers', then
|
||||
`check --out <dir> --attempt <id> --result <file> --model <id>`
|
||||
(`--same-family "<why>"` when no other family is available). A High or
|
||||
Critical finding, or `--unavailable "<reason>"`, leaves the gate for the
|
||||
owner. The verdict is saved as `carry_check` on `gate.json`, on the
|
||||
decision rows (`carry_check`) and in `<dir>/decisions.tsv`. A pending
|
||||
check is void, its reservation released and the gate left for the owner,
|
||||
when the owner answers the gate, the plan is revised, a new gate is
|
||||
written, or the delta changed after dispatch.
|
||||
|
||||
Carried rows keep `answered_by: agent:gstack-autoplan` and name their source
|
||||
(`carried_gate_from`, `carried_gate_rev`, `carried_answered_by`);
|
||||
`gstack-approvals import --autoplan` writes the carried gate as its own row,
|
||||
and `gstack-owner-brief render --captain` opens with "Nothing pending.
|
||||
Carried forward from gate_rev <m>", followed by any items taken under "do it"
|
||||
without being shown to the owner.
|
||||
|
||||
## Owner brief
|
||||
|
||||
`gstack-owner-brief render --out <dir>` is what the owner sees at the final
|
||||
|
||||
+18
-5
@@ -52,6 +52,11 @@ export type Env = NodeJS.ProcessEnv;
|
||||
|
||||
const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 });
|
||||
|
||||
/** A branch this checkout knows: a local head or an origin remote-tracking ref. */
|
||||
export function branchExists(cwd: string, branch: string): boolean {
|
||||
return [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`].some(ref => git(cwd, ['rev-parse', '--verify', '--quiet', ref]).status === 0);
|
||||
}
|
||||
|
||||
/** One contained path segment for a branch: readable prefix plus a hash of the exact ref. */
|
||||
export function branchKey(branch: string): string {
|
||||
const readable = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, 60) || 'branch';
|
||||
@@ -226,13 +231,15 @@ export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?
|
||||
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n');
|
||||
}
|
||||
|
||||
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string }
|
||||
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string;
|
||||
carried_gate_from?: string; carried_gate_rev?: number; carried_answered_by?: string; carried_under?: string; carry_check?: string }
|
||||
|
||||
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
|
||||
|
||||
/**
|
||||
* Rows for every answered gate in an autoplan run: one row per answer event (gate_rev, time, answerer), covering
|
||||
* the items it settled. Carried-forward answers were recorded when first given and are skipped. Ids are stable,
|
||||
* the items it settled. Item answers carried from an earlier revision were recorded when first given and are skipped;
|
||||
* a whole gate carried forward (lib/autoplan-carry.ts) gets its own row naming the revision its words came from. Ids are stable,
|
||||
* so a rerun (or a retry after an interrupted import) adds nothing twice.
|
||||
*/
|
||||
export function importAutoplan(runDir: string, branch: string, by?: string): ApprovalRow[] | RecordError {
|
||||
@@ -265,10 +272,16 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
|
||||
const changed = items.filter(r => r.chosen && r.recommended && r.chosen !== r.recommended);
|
||||
const stillPending = rows.filter(r => r.gate_rev === rev && r.status === 'pending');
|
||||
const answerer = first.answered_by ?? 'unknown';
|
||||
const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
|
||||
const scope = `the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`;
|
||||
const carried = p1?.carried_gate_from ? p1 : undefined;
|
||||
const relayed = carried?.carried_answered_by && carried.carried_answered_by !== 'human' ? `, relayed by ${carried.carried_answered_by}` : '';
|
||||
const who = carried ? `${by ?? 'the owner'} (words from gate_rev ${carried.carried_gate_rev}${relayed}), carried forward by gstack-autoplan`
|
||||
: answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
|
||||
const unseen = items.filter(r => r.carried_under).map(r => stripRef(r.title ?? ''));
|
||||
const checked = [...new Set(items.map(r => r.carry_check).filter((c): c is string => !!c))];
|
||||
const scope = [`the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`,
|
||||
unseen.length ? `taken under "do it", not seen by the owner: ${unseen.join('; ')}` : '', checked.length ? `post-cap delta checked by ${checked.join(', ')}` : ''].filter(Boolean).join('; ');
|
||||
const built = buildRow({
|
||||
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
|
||||
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${carried ? `gate_rev ${rev} carried forward from gate_rev ${carried.carried_gate_rev}, ` : ''}${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
|
||||
words: first.owner_reply ?? first.reply ?? '(no words recorded)',
|
||||
by: who.slice(0, CAPS.by),
|
||||
scope: scope.slice(0, CAPS.scope),
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* autoplan-carry — carry the owner's last answer to a later gate revision (GSTA-155,
|
||||
* docs/unattended.md#carry-forward). When a gate_rev N+1 asks nothing the owner has not
|
||||
* already decided, the runner answers it with the owner's recorded gate_rev N words instead
|
||||
* of a second round trip, and records that it did (decisions.jsonl `carried_gate_from`,
|
||||
* journal `gate_carried`, `<out>/decisions.tsv`). It never invents an answer: every
|
||||
* carried answer is the owner's own earlier words, and every refusal says which condition failed.
|
||||
*
|
||||
* A carry needs all of: carry not turned off (`--no-carry-forward`); gate_rev N approved (p1 a or b)
|
||||
* with the owner's words recorded, and not itself a carry; every change accepted at N
|
||||
* incorporated or settled; the plan file unchanged since the gate or the revise that took those
|
||||
* changes, and the reviewed plan equal to it; no open urgent finding; and every new item already
|
||||
* carried. Two kinds of new item do not block: items a "do it" covers (the owner's words are
|
||||
* "do it": an auto item that changes no scope, cost or direction and is no round grant), and a
|
||||
* capped-delta item recommending "accept", which first gets one reviewer on just the delta
|
||||
* (`CARRY_CHECK:`, answered with `gstack-autoplan check`); a High or Critical finding there
|
||||
* means no carry, and the gate goes to the owner as before.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { attemptEstimate } from './autoplan-budget';
|
||||
import { lineDiff } from './autoplan-confirm';
|
||||
import { answerGate } from './autoplan-gate';
|
||||
import { driftLine } from './autoplan-plan-text';
|
||||
import { BOUNDARY, RESULT_FORMAT, REVIEWER_TRAILER } from './autoplan-prompts';
|
||||
import { parseReviewResult } from './autoplan-reconcile';
|
||||
import { FILES, RunError, journal, now, readDecisions, readJournal, readState, writeDecisions, writeState, type RunState } from './autoplan-state';
|
||||
import { appendTrailRow } from './decision-trail';
|
||||
import { decisionId, parseReply, type GateItem, type GateList } from './gate-list';
|
||||
import { readJsonl } from './headless-artifacts';
|
||||
import { modelFamily } from './outside-voice-runner';
|
||||
import { release, reserve, settle } from './spend-ledger';
|
||||
|
||||
export const DEFAULT_CARRY_CHECK_CAP_USD = 5;
|
||||
const HIGH = ['Critical', 'High'];
|
||||
const DO_IT = /^\s*(?:(?![^:]*\bcaptain\b)[a-z]+(?: [a-z]+){0,2}:\s*)?do it[\s.!]*$/i;
|
||||
/** The owner's verbatim words are a "do it" (optionally after their name), not a Captain's words or an item token (`p3b: do it`) naming one. */
|
||||
export const isDoIt = (words: string | undefined): boolean => !!words && DO_IT.test(words);
|
||||
|
||||
export interface CarryCheck {
|
||||
attempt: string; prompt: string; result: string; delta_sha256: string; items: string[]; estimate_usd: number;
|
||||
status: 'dispatched' | 'passed' | 'failed' | 'unavailable'; model?: string; max_severity?: string; findings?: number; at: string;
|
||||
}
|
||||
export interface CarryRecord {
|
||||
gate_rev: number; from_gate_rev?: number; verdict: 'carried' | 'refused' | 'check_pending';
|
||||
reason: string; words?: string; under_do_it?: string[]; check?: CarryCheck; lines: string[]; at: string;
|
||||
}
|
||||
|
||||
const sha256 = (text: string | Buffer) => createHash('sha256').update(text).digest('hex');
|
||||
const planFile = (state: RunState) => state.plan_input ?? state.plan_source;
|
||||
const fileSha = (file: string) => (fs.existsSync(file) ? sha256(fs.readFileSync(file)) : undefined);
|
||||
export const atGateCopy = (out: string, rev: number) => path.join(out, `plan-at-gate.r${rev}.md`);
|
||||
|
||||
/** What finishGate journals beside gate_written: the plan file's bytes and a copy of the reviewed plan the owner is asked about. */
|
||||
export function gateEvidence(state: RunState, rev: number): { source_file_sha256?: string } {
|
||||
if (fs.existsSync(state.active_plan)) fs.copyFileSync(state.active_plan, atGateCopy(state.out, rev));
|
||||
return { source_file_sha256: fileSha(planFile(state)) };
|
||||
}
|
||||
|
||||
/** The lines the latest carry decision printed, for the command that wrote this gate revision. */
|
||||
export function carryLines(state: RunState): string[] {
|
||||
const c = state.carry;
|
||||
return c && c.gate_rev === state.gate_rev ? c.lines : [];
|
||||
}
|
||||
|
||||
function record(state: RunState, c: Omit<CarryRecord, 'at'>): CarryRecord {
|
||||
const full = { ...c, at: now() };
|
||||
state.carry = full;
|
||||
journal(state.out, { event: c.verdict === 'carried' ? 'gate_carried' : c.verdict === 'check_pending' ? 'carry_check_dispatched' : 'carry_refused', gate_rev: c.gate_rev, from_gate_rev: c.from_gate_rev ?? null, reason: c.reason, ...(c.under_do_it?.length ? { under_do_it: c.under_do_it } : {}), ...(c.check ? { check: c.check.attempt, check_status: c.check.status } : {}) });
|
||||
appendTrailRow(path.join(state.out, 'decisions.tsv'), ['gate', c.verdict === 'carried' ? `carried gate_rev ${c.gate_rev} from gate_rev ${c.from_gate_rev}` : c.verdict === 'check_pending' ? `delta check before carrying gate_rev ${c.gate_rev}` : `no carry for gate_rev ${c.gate_rev}`,
|
||||
c.reason, c.check ? path.basename(c.check.result) : 'decisions.jsonl', c.check?.status ?? c.verdict]);
|
||||
return full;
|
||||
}
|
||||
const refuse = (state: RunState, rev: number, from: number | undefined, reason: string) => record(state, { gate_rev: rev, from_gate_rev: from, verdict: 'refused', reason, lines: [`CARRY: no (${reason})`] });
|
||||
|
||||
/** Drop a delta check still waiting for its reviewer (the owner answered, the plan was revised, a new gate was written) and release its reservation. */
|
||||
export function voidPendingCheck(state: RunState, why: string): string[] {
|
||||
const c = state.carry;
|
||||
if (c?.verdict !== 'check_pending' || !c.check) return [];
|
||||
release(path.join(state.out, FILES.spend), c.check.attempt);
|
||||
const out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check ${c.check.attempt} void: ${why}`);
|
||||
out.check = { ...c.check, status: 'unavailable' };
|
||||
return out.lines;
|
||||
}
|
||||
|
||||
/** The reviewed plan's changes since the copy taken when the owner was asked at `rev`, and their hash. */
|
||||
function deltaSince(state: RunState, rev: number): { diff: string; sha: string } | undefined {
|
||||
const before = atGateCopy(state.out, rev);
|
||||
if (!fs.existsSync(before)) return undefined;
|
||||
const diff = lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(state.active_plan, 'utf8')).filter(l => l.op !== ' ').map(l => `${l.op} ${l.text}`).join('\n');
|
||||
return { diff, sha: sha256(diff) };
|
||||
}
|
||||
|
||||
interface Source { id: string; gate_rev: number; words: string; by?: string }
|
||||
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number };
|
||||
|
||||
/** Every condition but the delta check; returns why not, or what the carry takes. */
|
||||
function assess(state: RunState, prev: GateList, list: GateList): Plan {
|
||||
const from = prev.gate_rev;
|
||||
if (state.carry_forward === false) return { reason: 'carry-forward is off for this run (--no-carry-forward)', from };
|
||||
const p1 = readDecisions(state.out).find(r => r.gate_rev === from && (r.label ?? '').toLowerCase() === 'p1');
|
||||
if (!p1 || p1.status === 'pending' || !p1.answered_at) return { reason: `gate_rev ${from} was not answered`, from };
|
||||
const unanswered = readDecisions(state.out).find(r => r.gate_rev === from && r.status === 'pending');
|
||||
if (unanswered) return { reason: `gate_rev ${from} left ${unanswered.label ?? unanswered.id} unanswered`, from };
|
||||
if (p1.chosen !== 'a' && p1.chosen !== 'b') return { reason: `gate_rev ${from} answer was p1${p1.chosen}, not an approval`, from };
|
||||
if (p1.carried_gate_from) return { reason: `gate_rev ${from} was itself carried; the owner answers this one`, from };
|
||||
if (parseReply(p1.reply ?? '', prev).mode !== 'rec') return { reason: `gate_rev ${from} was answered with item tokens, not rec or "do it"`, from };
|
||||
const words = p1.owner_reply ?? (p1.answered_by === 'human' ? p1.reply : undefined);
|
||||
if (!words?.trim()) return { reason: `no owner words recorded at gate_rev ${from}`, from };
|
||||
const amendments = (state.amendments ?? []).filter(a => a.gate_rev === from);
|
||||
const open = amendments.find(a => a.status !== 'incorporated' && a.status !== 'settled');
|
||||
if (open) return { reason: `accepted change ${open.decision} is ${open.status}`, from };
|
||||
const journalRows = readJournal(state.out);
|
||||
const at = journalRows.map(r => r.event === 'gate_written' && r.gate_rev === from).lastIndexOf(true);
|
||||
let expected: string | undefined = at >= 0 ? journalRows[at]!.source_file_sha256 : undefined;
|
||||
for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') expected = r.source_file_sha256;
|
||||
if (!expected) return { reason: 'no plan evidence recorded for the gate or the revise (a run written before carry-forward)', from };
|
||||
if (fileSha(planFile(state)) !== expected) return { reason: `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it`, from };
|
||||
if (driftLine(state, 'PLAN_SOURCE_CHANGED')) return { reason: 'the reviewed plan differs from the plan file', from };
|
||||
const urgent = readJsonl(path.join(state.out, 'findings.jsonl')).rows.find(r => (r.urgent === true || r.outside_plan === true) && !r.settled_by);
|
||||
if (urgent) return { reason: `urgent finding open: ${String(urgent.title ?? urgent.id)}`, from };
|
||||
const doIt: GateItem[] = []; const deltas: GateItem[] = [];
|
||||
for (const item of list.items) {
|
||||
if (item.id === 'p1' || item.carried) continue;
|
||||
const option = item.options.find(o => o.key === item.recommended);
|
||||
if (item.cap?.kind === 'delta' && !option?.changes_plan && item.recommended === 'a') { deltas.push(item); continue; }
|
||||
if (isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
|
||||
return { reason: `new item ${item.id}: ${item.title}`, from };
|
||||
}
|
||||
return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by }, doIt, deltas };
|
||||
}
|
||||
|
||||
/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */
|
||||
export function evaluateCarry(state: RunState, prev: GateList | undefined, list: GateList): CarryRecord | undefined {
|
||||
voidPendingCheck(state, `gate_rev ${list.gate_rev} was written`);
|
||||
if (!prev || list.gate_rev < 2) return undefined;
|
||||
try {
|
||||
const plan = assess(state, prev, list);
|
||||
if ('reason' in plan) return refuse(state, list.gate_rev, plan.from, plan.reason);
|
||||
if (plan.deltas.length) return dispatchCheck(state, prev, list, plan);
|
||||
return carry(state, list, plan.source, plan.doIt, plan.deltas);
|
||||
} catch (e: any) {
|
||||
if (readDecisions(state.out).some(r => r.gate_rev === list.gate_rev && r.answered_at && !r.carried_from)) throw e;
|
||||
return refuse(state, list.gate_rev, prev.gate_rev, `error: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Extract<Plan, { source: Source }>): CarryRecord {
|
||||
const rev = list.gate_rev;
|
||||
const cap = state.carry_check_cap_usd ?? DEFAULT_CARRY_CHECK_CAP_USD;
|
||||
const estimate = attemptEstimate(state, undefined, process.env).usd;
|
||||
if (estimate > cap) return refuse(state, rev, prev.gate_rev, `delta check estimate ${estimate.toFixed(2)} is over the per-gate cap ${cap.toFixed(2)} (--carry-check-cap)`);
|
||||
const delta = deltaSince(state, prev.gate_rev);
|
||||
if (!delta) return refuse(state, rev, prev.gate_rev, `no copy of the plan the owner answered at gate_rev ${prev.gate_rev}`);
|
||||
const current = fs.readFileSync(state.active_plan, 'utf8');
|
||||
const { diff, sha: deltaSha } = delta;
|
||||
const attempt = `${state.run}-c${rev}`;
|
||||
const prompt = path.join(state.out, `carry-check-g${rev}-prompt.md`);
|
||||
const result = path.join(state.out, `carry-check-g${rev}.md`);
|
||||
try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: 'carry-check' }); }
|
||||
catch (e: any) { return refuse(state, rev, prev.gate_rev, `delta check does not fit the run's spend cap: ${e?.message ?? e}`); }
|
||||
const accepted = (state.amendments ?? []).filter(a => a.gate_rev === prev.gate_rev).map(a => `- ${a.title} -> ${a.text}`).join('\n') || '- (none recorded)';
|
||||
fs.writeFileSync(prompt, [BOUNDARY, '',
|
||||
`You are checking one edit to a reviewed plan before gstack reuses the owner's approval of gate_rev ${prev.gate_rev} for gate_rev ${rev}. The owner accepted these changes:`, accepted, '',
|
||||
'The plan was edited after that answer, but the phases that would re-review the edit were at their round cap, so no reviewer has read it. Read the delta and say whether it implements the accepted changes, adds nothing the owner did not accept (scope, cost or direction), and leaves the plan correct. Rate a finding High or Critical only when the edit adds an unaccepted change, contradicts an accepted one, or would make the plan fail a test, break production, or lose data or secrets; anything else is Medium or lower.', '',
|
||||
`Delta (reviewed plan at gate_rev ${prev.gate_rev} -> now; + added, - removed; SHA-256 ${deltaSha}):`, '```diff', diff, '```', '',
|
||||
'The complete current plan follows as context.', '', current,
|
||||
REVIEWER_TRAILER, RESULT_FORMAT('carry-check', deltaSha, result)].join('\n'));
|
||||
const check: CarryCheck = { attempt, prompt, result, delta_sha256: deltaSha, items: plan.deltas.map(i => i.id), estimate_usd: estimate, status: 'dispatched', at: now() };
|
||||
const family = state.native_model ? `not ${modelFamily(state.native_model)}` : 'differs from the plan reviewers where available';
|
||||
return record(state, { gate_rev: rev, from_gate_rev: prev.gate_rev, verdict: 'check_pending', words: plan.source.words, under_do_it: plan.doIt.map(i => i.id), check,
|
||||
reason: `only an unreviewed post-cap delta is new (${check.items.join(',')}); one reviewer reads it first`,
|
||||
lines: [`CARRY_CHECK: attempt=${attempt} items=${check.items.join(',')} prompt=${prompt} result=${result} model_family=${family} estimate=${estimate.toFixed(2)} cap=${cap.toFixed(2)}`,
|
||||
`NEXT: dispatch one reviewer on the prompt, then gstack-autoplan check --out ${state.out} --attempt ${attempt} --result ${result} --model <id> (or --unavailable "<reason>" to relay the gate as today)`] });
|
||||
}
|
||||
|
||||
function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck): CarryRecord {
|
||||
const rev = list.gate_rev;
|
||||
const p1 = list.items.find(i => i.id === 'p1')!;
|
||||
const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
|
||||
const reason = [doIt.length || deltas.length ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '',
|
||||
'plan unchanged since the accepted changes'].filter(Boolean).join('; ');
|
||||
const answered = answerGate(state, rev, reply, 'agent:gstack-autoplan', { carry: { from: source.id, gate_rev: source.gate_rev, words: source.words, by: source.by, reason } });
|
||||
const marks = new Map<string, Record<string, string>>([...doIt.map(i => [decisionId(list.run, rev, i.id), { carried_under: 'do it' }] as const), ...deltas.map(i => [decisionId(list.run, rev, i.id), { carry_check: check?.attempt ?? '' }] as const)]);
|
||||
if (marks.size) writeDecisions(state.out, readDecisions(state.out).map(r => (marks.has(r.id) ? { ...r, ...marks.get(r.id) } : r)));
|
||||
const lines = [`CARRIED: gate_rev=${rev} <- gate_rev=${source.gate_rev} words=${JSON.stringify(source.words)} by=${source.by ?? 'unknown'} reason=${reason}`,
|
||||
...doIt.map(i => `CARRIED_UNDER_DO_IT: ${i.id} ${i.title} -> ${i.options.find(o => o.key === i.recommended)?.text ?? i.recommended} (the owner has not seen this item)`),
|
||||
...answered.lines];
|
||||
return record(state, { gate_rev: rev, from_gate_rev: source.gate_rev, verdict: 'carried', reason, words: source.words, under_do_it: doIt.map(i => i.id), ...(check ? { check } : {}), lines });
|
||||
}
|
||||
|
||||
export interface CheckOptions { out: string; attempt?: string; result?: string; model?: string; unavailable?: string; sameFamily?: string }
|
||||
|
||||
/** `gstack-autoplan check`: bind the delta reviewer's result (or its absence) and carry, or leave the gate for the owner. */
|
||||
export function submitCarryCheck(o: CheckOptions): { state: RunState; lines: string[] } {
|
||||
const state = readState(path.resolve(o.out));
|
||||
const c = state.carry;
|
||||
if (!c || c.verdict !== 'check_pending' || !c.check || state.status !== 'gate_pending' || c.gate_rev !== state.gate_rev) throw new RunError('PHASE_NOT_AWAITING', `no delta check is pending (run is ${state.status}, gate_rev=${state.gate_rev ?? 'none'})`, 3);
|
||||
const check = c.check;
|
||||
if (o.attempt !== check.attempt) throw new RunError('ATTEMPT_MISMATCH', `pass --attempt ${check.attempt}`, 3);
|
||||
const stale = readDecisions(state.out).some(r => r.gate_rev === c.gate_rev && r.answered_at && !r.carried_from) ? `the owner answered gate_rev ${c.gate_rev}`
|
||||
: deltaSince(state, c.from_gate_rev ?? 0)?.sha !== check.delta_sha256 ? 'the plan changed after the delta check was dispatched' : undefined;
|
||||
if (stale) {
|
||||
const lines = voidPendingCheck(state, stale);
|
||||
writeState(state);
|
||||
return { state, lines };
|
||||
}
|
||||
const gateFile = path.join(state.out, 'gate.json');
|
||||
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
|
||||
const spend = path.join(state.out, FILES.spend);
|
||||
let out: CarryRecord;
|
||||
if (o.unavailable !== undefined) {
|
||||
release(spend, check.attempt);
|
||||
list.carry_check = { attempt: check.attempt, model: 'none', verdict: 'unavailable', max_severity: 'none', findings: 0, at: now() };
|
||||
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
|
||||
out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check unavailable: ${o.unavailable}`);
|
||||
out.check = { ...check, status: 'unavailable' };
|
||||
} else {
|
||||
if (!o.result || !o.model) throw new RunError('RESULT_RECEIPT_MISSING', 'check needs --result <file> and --model <id>, or --unavailable "<reason>"', 3);
|
||||
if (!fs.existsSync(o.result)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${o.result}`);
|
||||
const family = modelFamily(o.model);
|
||||
const author = state.native_model ? modelFamily(state.native_model) : undefined;
|
||||
if (!o.sameFamily && (family === 'unknown' || family === author)) throw new RunError('MODEL_FAMILY_CONFLICT', `${o.model} is ${family === 'unknown' ? 'of no recognized family' : `the plan reviewers' family (${family})`}; use another family, or pass --same-family "<why none is available>"`, 3);
|
||||
const text = fs.readFileSync(o.result, 'utf8');
|
||||
const parsed = parseReviewResult(text, { phase: 'carry-check', sha256: check.delta_sha256 });
|
||||
if (!parsed.receipt.ok) throw new RunError('RESULT_RECEIPT_MISSING', parsed.errors[0]!);
|
||||
if (!parsed.fenceFound || parsed.errors.length) throw new RunError('RESULT_FINDINGS_MISSING', parsed.errors.join('; '));
|
||||
fs.writeFileSync(path.join(state.out, `carry-check-g${c.gate_rev}.bound.md`), text, process.platform === 'win32' ? {} : { mode: 0o444 });
|
||||
settle(spend, check.attempt, 'unknown');
|
||||
const order = ['Informational', 'Low', 'Medium', 'High', 'Critical'];
|
||||
const max = parsed.findings.map(f => String(f.severity)).reduce((a, b) => (order.indexOf(b) > order.indexOf(a) ? b : a), 'none');
|
||||
const blocking = parsed.findings.find(f => HIGH.includes(String(f.severity)));
|
||||
const done: CarryCheck = { ...check, status: blocking ? 'failed' : 'passed', model: o.model, max_severity: max, findings: parsed.findings.length, at: now() };
|
||||
list.carry_check = { attempt: check.attempt, model: o.model + (o.sameFamily ? ` (same family: ${o.sameFamily})` : ''), verdict: blocking ? 'failed' : 'passed', max_severity: max, findings: parsed.findings.length, at: done.at };
|
||||
if (blocking) for (const item of list.items) if (check.items.includes(item.id)) (item.flags ??= []).push(`carry check ${o.model}: ${blocking.severity} ${blocking.title}`);
|
||||
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
|
||||
if (blocking) {
|
||||
out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check found ${blocking.severity}: ${blocking.title}`);
|
||||
out.check = done;
|
||||
} else {
|
||||
const plan = assess(state, { ...list, gate_rev: c.from_gate_rev! } as GateList, list);
|
||||
if ('reason' in plan) { out = refuse(state, c.gate_rev, c.from_gate_rev, plan.reason); out.check = done; }
|
||||
else {
|
||||
out = carry(state, list, plan.source, plan.doIt, plan.deltas, done);
|
||||
out.lines.unshift(`CARRY_CHECK: passed attempt=${check.attempt} model=${o.model} findings=${done.findings} highest=${max}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeState(state);
|
||||
return { state, lines: out.lines };
|
||||
}
|
||||
+37
-10
@@ -24,7 +24,8 @@ import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHAN
|
||||
import type { Phase } from './autoplan-prompts';
|
||||
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, voiceTerminal, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state';
|
||||
import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget';
|
||||
import { capyRelayLine } from './captain-brief';
|
||||
import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry';
|
||||
import { buildEli5, capyRelayLine } from './captain-brief';
|
||||
import { previousReview, reviewRows, settledIds } from './autoplan-confirm';
|
||||
import { phaseClose } from './autoplan-timing';
|
||||
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
|
||||
@@ -237,7 +238,8 @@ export function buildGate(state: RunState, gateRev: number): GateList {
|
||||
}
|
||||
items.push(...capItems(state, { d: () => `d${++d}`, uc: () => `uc${++uc}` }));
|
||||
items.push(planApproval(state, items));
|
||||
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 };
|
||||
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0,
|
||||
...(fs.existsSync(state.active_plan) ? { eli5: buildEli5(fs.readFileSync(state.active_plan, 'utf8'), state.active_plan) } : {}) };
|
||||
const problems = validateGateList(list);
|
||||
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
|
||||
return list;
|
||||
@@ -285,9 +287,29 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
|
||||
...[capyRelayLine(state.out)].filter((l): l is string => !!l)];
|
||||
}
|
||||
|
||||
/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */
|
||||
/** The gate list the owner last answered: gate.json, or its archive (`gate.r<rev>[.v<k>].json`) once a reopen moved it. */
|
||||
function previousGate(out: string, rev: number): GateList | undefined {
|
||||
if (rev < 1) return undefined;
|
||||
const re = new RegExp(`^gate\\.r${rev}(?:\\.v(\\d+))?\\.json$`);
|
||||
const archived = fs.readdirSync(out).map(f => [f, re.exec(f)] as const).filter(([, m]) => m).sort((a, b) => Number(b[1]![1] ?? 1) - Number(a[1]![1] ?? 1)).map(([f]) => f);
|
||||
for (const name of ['gate.json', ...archived]) {
|
||||
const file = path.join(out, name);
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const list = JSON.parse(fs.readFileSync(file, 'utf8')) as GateList;
|
||||
if (list.gate_rev === rev) return list;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged
|
||||
* answered proposals carry forward, and a later revision that asks nothing new carries the owner's last answer
|
||||
* (lib/autoplan-carry.ts; its lines are on `carryLines(state)`).
|
||||
*/
|
||||
export function finishGate(state: RunState): GateList {
|
||||
const gateRev = (state.gate_rev ?? 0) + 1;
|
||||
const gateFile = path.join(state.out, 'gate.json');
|
||||
const prev = previousGate(state.out, state.gate_rev ?? 0);
|
||||
const list = buildGate(state, gateRev);
|
||||
const kept = readDecisions(state.out).filter(r => r.gate_rev !== gateRev);
|
||||
const answered = new Map(kept.filter(r => r.origin && r.chosen != null && r.status !== 'pending').map(r => [r.origin!, r]));
|
||||
@@ -295,7 +317,7 @@ export function finishGate(state: RunState): GateList {
|
||||
const prev = item.origin ? answered.get(item.origin) : undefined;
|
||||
if (prev && item.options.some(o => o.key === prev.chosen)) item.carried = { from: prev.carried_from ?? prev.id, chosen: prev.chosen!, gate_rev: prev.gate_rev };
|
||||
}
|
||||
fs.writeFileSync(path.join(state.out, 'gate.json'), JSON.stringify(list, null, 2) + '\n');
|
||||
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
|
||||
const rows = decisionRows(list).map(row => {
|
||||
const item = list.items.find(i => decisionId(list.run, gateRev, i.id) === row.id)!;
|
||||
if (!item.carried) return row;
|
||||
@@ -308,7 +330,8 @@ export function finishGate(state: RunState): GateList {
|
||||
state.status = 'gate_pending';
|
||||
state.gate = { rev: gateRev, items: list.items.length, written_at: now() };
|
||||
exportRun(state);
|
||||
journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id) });
|
||||
journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id), ...gateEvidence(state, gateRev) });
|
||||
evaluateCarry(state, prev, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -321,6 +344,8 @@ export interface AnswerOptions {
|
||||
recHash?: string;
|
||||
/** The owner's verbatim words; required for every `--by agent:<id>` answer. */
|
||||
ownerReply?: string;
|
||||
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision and the owner's words it reuses. Replaces ownerReply and its re-parse. */
|
||||
carry?: { from: string; gate_rev: number; words: string; by?: string; reason: string };
|
||||
}
|
||||
|
||||
/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */
|
||||
@@ -361,7 +386,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
|
||||
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
|
||||
if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3);
|
||||
if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3);
|
||||
if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3);
|
||||
if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined && !o.carry) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3);
|
||||
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
|
||||
const rows = readDecisions(state.out);
|
||||
const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
|
||||
@@ -372,12 +397,13 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
|
||||
if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3);
|
||||
if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit <id><option> tokens`, 3);
|
||||
if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
|
||||
const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
|
||||
const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
|
||||
const ownerParsed = o.ownerReply !== undefined && !o.carry ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
|
||||
const provenance = o.carry ? { owner_reply: o.carry.words, carried_gate_from: o.carry.from, carried_gate_rev: o.carry.gate_rev, carry_reason: o.carry.reason, ...(o.carry.by ? { carried_answered_by: o.carry.by } : {}) }
|
||||
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
|
||||
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
|
||||
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
|
||||
const plan = parsed.choices.find(c => c.id === 'p1');
|
||||
const lines: string[] = [];
|
||||
const lines: string[] = o.carry ? [] : voidPendingCheck(state, `the owner answered gate_rev ${gateRev}`);
|
||||
const answeredAt = now();
|
||||
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => {
|
||||
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
|
||||
@@ -399,7 +425,8 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
|
||||
const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []);
|
||||
record();
|
||||
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance };
|
||||
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance });
|
||||
const answerClass = plan.chosen !== 'a' && plan.chosen !== 'b' ? `p1${plan.chosen}` : changing.length ? 'plan-changes' : overrides.length ? 'overrides' : 'as-recommended';
|
||||
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, answer_class: answerClass, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance });
|
||||
if (plan.chosen === 'c') {
|
||||
lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`);
|
||||
return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines };
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
* written and `revise_intent` journaled before anything moves; `resume`
|
||||
* (recoverRevise) completes an interrupted revise from the stage.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { exportRun } from './autoplan-export';
|
||||
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
|
||||
import { carryLines, voidPendingCheck } from './autoplan-carry';
|
||||
import { atCap, escalateLine, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
|
||||
import { diffStat, lineDiff } from './autoplan-confirm';
|
||||
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
|
||||
@@ -109,8 +111,10 @@ export function decisionSeverity(state: RunState, decision: string): string {
|
||||
function approveOrRegate(state: RunState, at: string): string[] {
|
||||
if (pendingDeltas(state).length) {
|
||||
finishGate(state);
|
||||
const carried = carryLines(state);
|
||||
if (state.status !== 'gate_pending') return carried;
|
||||
const escalate = escalateLine(state);
|
||||
return [`GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
|
||||
return [...carried, `GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
|
||||
}
|
||||
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
|
||||
}
|
||||
@@ -176,6 +180,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
|
||||
const delta = capped.length || quiet ? diffStat(lineDiff(fs.readFileSync(state.active_plan, 'utf8'), fs.readFileSync(path.join(staging, FILES.active), 'utf8'))) : { added: 0, removed: 0 };
|
||||
const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined;
|
||||
journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle });
|
||||
voidPendingCheck(state, 'the plan was revised');
|
||||
|
||||
const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : [];
|
||||
for (const phase of capped) {
|
||||
@@ -199,7 +204,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
|
||||
if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : pendingDeltas(state).length ? 'gate_pending' : 'complete';
|
||||
// A quiet revise that ends at gate_pending still has an earlier capped delta waiting: the owner gets a new gate for it.
|
||||
const regate = (capped.length > 0 || quiet) && state.status === 'gate_pending';
|
||||
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, reopened: reopen, added, at_gate_rev: atGate ?? null };
|
||||
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, source_file_sha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), reopened: reopen, added, at_gate_rev: atGate ?? null };
|
||||
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n');
|
||||
publish(state, staging, renames, committed, settled);
|
||||
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`,
|
||||
|
||||
+22
-6
@@ -20,6 +20,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
|
||||
import { amendmentSteps, applyGuidance, closeDeferred, closePhase, finishGate } from './autoplan-gate';
|
||||
import { carryLines } from './autoplan-carry';
|
||||
import { GSTACK_VERSION } from './autoplan-migrate';
|
||||
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
|
||||
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
|
||||
@@ -71,7 +72,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
|
||||
// ---------------------------------------------------------------------------
|
||||
// init
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number }
|
||||
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
|
||||
|
||||
/** `lines` receives what init itself reports (SCAFFOLDED:). A plan with one section heading missing refuses, or with `scaffold` is reviewed from a scaffolded copy. */
|
||||
export function initRun(o: InitOptions, lines: string[] = []): RunState {
|
||||
@@ -121,6 +122,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
|
||||
...(o.light ? { light: true } : {}),
|
||||
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
|
||||
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
|
||||
...(o.carryForward === false ? { carry_forward: false } : {}), ...(o.carryCheckCapUsd !== undefined ? { carry_check_cap_usd: o.carryCheckCapUsd } : {}),
|
||||
};
|
||||
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
|
||||
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
|
||||
@@ -176,6 +178,17 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
|
||||
journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) });
|
||||
}
|
||||
|
||||
/** The first next's price line: the most the review can reserve at this scope and round cap (a reservation total, not a spend ceiling). */
|
||||
export function reviewEstimateLine(state: RunState, perVoiceUsd: number): string {
|
||||
const voices = state.phase_order.map(p => (!state.outside_enabled || (p === 'ceo' && state.scope.docs_only) ? 1 : 2));
|
||||
const perRound = voices.reduce((a, b) => a + b, 0);
|
||||
const rounds = state.max_rounds ?? null;
|
||||
const per = perVoiceUsd > 0 ? perVoiceUsd.toFixed(2) : 'unknown';
|
||||
const attempts = rounds === null ? `${perRound} per round, rounds uncapped` : `<=${perRound * rounds}`;
|
||||
const total = rounds === null || perVoiceUsd <= 0 ? 'unknown' : `<=${(perRound * rounds * perVoiceUsd).toFixed(2)}`;
|
||||
return `REVIEW_ESTIMATE: phases=${state.phase_order.join(',')} voices=${voices.join('+')} rounds${rounds === null ? '=uncapped' : `<=${rounds}`} attempts${attempts.startsWith('<=') ? attempts : `=${attempts}`} per_voice=${per} reservations${total === 'unknown' ? '=unknown' : total} (a reservation total, not a spend ceiling; actual host spend stays unknown; round grants at the gate add more)`;
|
||||
}
|
||||
|
||||
export function nextStep(o: NextOptions): NextResult {
|
||||
const state = readState(path.resolve(o.out));
|
||||
const lines: string[] = [];
|
||||
@@ -193,7 +206,7 @@ export function nextStep(o: NextOptions): NextResult {
|
||||
lines.push(...closeDeferred(state, phase, { reason: `round cap: round ${roundOf(state, phase)} would pass max ${roundCap(state, phase)}`, by: 'runner', cause: 'cap' }).lines);
|
||||
phase = currentPhase(state);
|
||||
}
|
||||
if (!phase) { finishGate(state); writeState(state); return result; }
|
||||
if (!phase) { finishGate(state); lines.push(...carryLines(state)); writeState(state); return result; }
|
||||
const first = !state.phase_order.some(p => state.phases[p]?.snapshot);
|
||||
openPhase(state, phase, o.cwd);
|
||||
const ps = state.phases[phase]!;
|
||||
@@ -208,6 +221,7 @@ export function nextStep(o: NextOptions): NextResult {
|
||||
const prompts = ps.prompts!;
|
||||
const estimate = attemptEstimate(state, o.estimateUsd, process.env);
|
||||
if (estimate.line) lines.push(estimate.line);
|
||||
if (first) lines.push(reviewEstimateLine(state, estimate.usd));
|
||||
const fresh = (['native', 'outside'] as const).filter(voice => { const v = voiceState(state, phase!, voice); const open = openAttempt(v); return !voiceTerminal(v) && (!open || (open.state === 'execution_unknown' && o.redispatch?.includes(open.attempt))); });
|
||||
const before = summarize(readLedger(path.join(state.out, FILES.spend), state.spend_cap_usd));
|
||||
if (state.spend_cap_usd !== null && fresh.length && before.worst_case_usd + fresh.length * estimate.usd > state.spend_cap_usd + 1e-9) {
|
||||
@@ -369,8 +383,9 @@ export function submitResult(o: SubmitOptions): SubmitResult {
|
||||
|
||||
function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] {
|
||||
const close = closePhase(state, phase, { overridesFile: reconcile });
|
||||
if (!currentPhase(state)) finishGate(state); else state.status = 'running';
|
||||
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`];
|
||||
const gated = !currentPhase(state);
|
||||
if (gated) finishGate(state); else state.status = 'running';
|
||||
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`, ...(gated ? carryLines(state) : [])];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,14 +466,15 @@ export function resumeRun(out: string): ResumeResult {
|
||||
if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {});
|
||||
}
|
||||
const stopped = resumeStop(state);
|
||||
if (!currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state);
|
||||
const gated = !currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status);
|
||||
if (gated) finishGate(state);
|
||||
else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') {
|
||||
const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v))));
|
||||
state.status = anyOpen ? 'awaiting_result' : 'running';
|
||||
}
|
||||
journal(out, { event: 'resumed', unknown, rebound, status: state.status });
|
||||
writeState(state);
|
||||
const lines = [...recovered, ...stopped, `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
|
||||
const lines = [...recovered, ...stopped, ...(gated ? carryLines(state) : []), `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
|
||||
for (const a of unknown) lines.push(`EXECUTION_UNKNOWN: ${a}`);
|
||||
return { state, unknown, rebound, lines };
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface RunState extends RunManifest {
|
||||
stop?: RunStop; stops?: RunStop[]; capped_deltas?: CappedDelta[];
|
||||
/** Written by export from attempts.jsonl; est_input_tokens is dispatched prompt bytes / 4, a relative estimate only. Absent on older runs. */
|
||||
dispatches?: DispatchCounts; est_input_tokens?: number;
|
||||
/** Carry-forward (lib/autoplan-carry.ts): false with --no-carry-forward; the per-gate cap on the delta check; the latest carry decision. */
|
||||
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
|
||||
}
|
||||
|
||||
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
|
||||
|
||||
+21
-5
@@ -14,7 +14,7 @@ import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { gateCost, gateCostSentence, priorChoices } from './autoplan-gate-cost';
|
||||
import { parseReply, recHash, type GateItem } from './gate-list';
|
||||
import { parseReply, recHash, type GateEli5, type GateItem } from './gate-list';
|
||||
import { renderUrgentBlock } from './headless-artifacts';
|
||||
import { findSection, firstTitle } from './markdown-sections';
|
||||
import type { BriefInputs } from './owner-brief';
|
||||
@@ -25,6 +25,8 @@ export const BRIEF_SECTIONS = [
|
||||
{ title: 'What caused this work', aliases: ['What caused this work', 'What caused us to work on this', 'What caused this', 'Trigger'] },
|
||||
{ title: 'Big decisions', aliases: ['Big decisions', 'Big decisions we made', 'Key decisions'] },
|
||||
] as const;
|
||||
/** The 5-10 plain sentences a plan opens its ask with; required in a captain brief only when the gate carries `eli5` (written by gstack 1.91.305+). */
|
||||
export const ELI5_SECTION = { title: 'ELI5', aliases: ['ELI5', 'In plain words'] } as const;
|
||||
const SECTION_LINES = 24;
|
||||
const SECTION_CHARS = 3000;
|
||||
|
||||
@@ -60,6 +62,13 @@ function capped(body: string, planPath: string): string {
|
||||
return hidden > 0 || out.length < body.length ? `${out}\n\n_+ more in the plan (${path.basename(planPath)})._` : out;
|
||||
}
|
||||
|
||||
/** gate.json `eli5`: the plan's ELI5 and the four approvals-shape sections, capped as the brief caps them. */
|
||||
export function buildEli5(planText: string, planFile: string): GateEli5 {
|
||||
const body = (aliases: readonly string[]) => { const s = findSection(planText, [...aliases]); return s?.body.trim() ? capped(s.body.trim(), planFile) : ''; };
|
||||
const [before, why, cause, decisions] = BRIEF_SECTIONS.map(s => body(s.aliases));
|
||||
return { summary: body(ELI5_SECTION.aliases), before_after: before!, why: why!, cause: cause!, decisions: decisions!, plan_file_sha256: createHash('sha256').update(planText).digest('hex') };
|
||||
}
|
||||
|
||||
export interface CaptainBrief { text: string; missing: string[]; pending: boolean; recHash: string }
|
||||
|
||||
export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }): CaptainBrief {
|
||||
@@ -71,8 +80,10 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
|
||||
const rowOf = (id: string) => inputs.decisions.filter(d => d.gate_rev === list.gate_rev).find(d => (d.label ?? '').toLowerCase() === id);
|
||||
const p1Row = p1 ? rowOf('p1') : undefined;
|
||||
const pending = !p1Row || p1Row.status === 'pending';
|
||||
const sections = BRIEF_SECTIONS.map(s => ({ title: s.title, found: findSection(plan.text, [...s.aliases]) }));
|
||||
const missing = sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title);
|
||||
const eli5 = list.eli5 ? buildEli5(plan.text, plan.file ?? 'plan.md') : undefined;
|
||||
const fromGate = eli5 ? [eli5.before_after, eli5.why, eli5.cause, eli5.decisions] : [];
|
||||
const sections = BRIEF_SECTIONS.map((s, i) => ({ title: s.title, found: eli5 ? { body: fromGate[i]! } : findSection(plan.text, [...s.aliases]) }));
|
||||
const missing = [...(eli5 && !eli5.summary.trim() ? [ELI5_SECTION.title] : []), ...sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title)];
|
||||
const urgent = inputs.findings.some(r => r.urgent === true || r.outside_plan === true) ? [renderUrgentBlock(inputs.findings).trimEnd(), ''] : [];
|
||||
const items = list.items.filter(i => i.id !== 'p1');
|
||||
const prior = priorChoices(o.outDir, list.gate_rev);
|
||||
@@ -83,7 +94,11 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
|
||||
const keptNote = (i: GateItem) => i.carried?.chosen === recKey(i) ? ` (your answer at gate_rev ${i.carried.gate_rev}, carried forward)` : ' (your answer at this gate)';
|
||||
|
||||
let header: string[];
|
||||
if (!pending) {
|
||||
if (!pending && p1Row?.carried_gate_from) {
|
||||
const unseen = inputs.decisions.filter(d => d.gate_rev === list.gate_rev && d.carried_under).map(d => (d.title ?? '').replace(/\s*\[[^\]]*\]\s*$/, ''));
|
||||
header = [`**Nothing pending.** Carried forward from gate_rev ${p1Row.carried_gate_rev}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'} under the owner's words “${p1Row?.owner_reply ?? ''}”, because nothing new needed deciding.`,
|
||||
...(unseen.length ? ['', `Taken under "do it" without being shown to the owner: ${unseen.join('; ')}.`] : [])];
|
||||
} else if (!pending) {
|
||||
header = [`**Nothing pending.** Already decided${p1Row?.answered_at ? ` on ${p1Row.answered_at.slice(0, 10)}` : ''}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'}${p1Row?.owner_reply ?? p1Row?.reply ? ` (“${p1Row?.owner_reply ?? p1Row?.reply}”)` : ''}.`];
|
||||
} else if (missing.length) {
|
||||
header = [`**Not ready for approval:** the plan "${title}" is missing ${missing.join(', ')}. The thread will add them and send this again; do not approve from this draft.`];
|
||||
@@ -94,7 +109,8 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
|
||||
];
|
||||
}
|
||||
|
||||
const body = sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? capped(s.found.body.trim(), plan.file ?? 'plan.md') : '_Not written for this plan yet._', '']);
|
||||
const summary = eli5?.summary.trim() ? [`### ${ELI5_SECTION.title}`, '', eli5.summary.trim(), ''] : [];
|
||||
const body = [...summary, ...sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? (eli5 ? s.found.body.trim() : capped(s.found.body.trim(), plan.file ?? 'plan.md')) : '_Not written for this plan yet._', ''])];
|
||||
const need: string[] = ['### What we need from you', ''];
|
||||
if (!pending) need.push('Nothing. This gate is answered.', '');
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* decision-trail — the append rules of a run's decision trail (docs/unattended.md#the-decision-trail):
|
||||
* a TSV with one line per decision (ts, phase, decision, why, evidence, result). The header is
|
||||
* written once and only ever appended, cells lose their tabs and line breaks, and a cell whose first
|
||||
* byte a spreadsheet would run as a formula (=, +, -, @) gets a leading apostrophe. Shared by
|
||||
* bin/gstack-decision-trail and the autoplan runner (lib/autoplan-carry.ts records its carry verdicts).
|
||||
* The rules are a port of pstack's show-me-your-work/scripts/log.sh (Copyright Lauren Tan, MIT);
|
||||
* licenses/MIT-pstack.txt, NOTICE.md.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export const TRAIL_COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const;
|
||||
export const TRAIL_HEADER = TRAIL_COLUMNS.join("\t") + "\n";
|
||||
|
||||
export type TrailRow = { line: number; cells: string[] };
|
||||
|
||||
function clean(cell: string): string {
|
||||
const flat = cell.replace(/[\t\n\r]/g, " ");
|
||||
return /^[=+\-@]/.test(flat) ? `'${flat}` : flat;
|
||||
}
|
||||
|
||||
export function readTrailRows(file: string): TrailRow[] {
|
||||
if (!fs.existsSync(file)) return [];
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
const rows: TrailRow[] = [];
|
||||
lines.forEach((raw, i) => {
|
||||
let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
||||
if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
|
||||
// A repeated header (the append-on-flaky-mount case) is never a row.
|
||||
if (text === "" || text === TRAIL_HEADER.trimEnd()) return;
|
||||
rows.push({ line: i + 1, cells: text.split("\t") });
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Append one row (ts stamped in UTC) and return the row count. */
|
||||
export function appendTrailRow(file: string, cells: readonly string[]): number {
|
||||
const dir = path.dirname(file);
|
||||
if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
// Append the header too, never truncate: on a flaky mount the worst case is
|
||||
// one stray header line, not lost rows.
|
||||
// A file with no header line anywhere (empty, blank, or rows written by hand)
|
||||
// gets one appended once; readTrailRows skips a header line wherever it sits.
|
||||
const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
||||
if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
|
||||
if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, TRAIL_HEADER);
|
||||
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
|
||||
return readTrailRows(file).length;
|
||||
}
|
||||
|
||||
function hasHeader(file: string): boolean {
|
||||
return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n")
|
||||
.some(line => line.replace(/\r$/, "") === TRAIL_HEADER.trimEnd());
|
||||
}
|
||||
|
||||
function endsWithNewline(file: string, size: number): boolean {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const last = Buffer.alloc(1);
|
||||
fs.readSync(fd, last, 0, 1, size - 1);
|
||||
return last[0] === 0x0a;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -42,7 +42,14 @@ export interface GateItem {
|
||||
}
|
||||
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string;
|
||||
/** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */
|
||||
rec_rev?: number }
|
||||
rec_rev?: number;
|
||||
/** The plan's plain-language ask, read from the reviewed plan (lib/captain-brief.ts `buildEli5`); printed first. */
|
||||
eli5?: GateEli5;
|
||||
/** The one-reviewer check of an unreviewed post-cap delta before a carry (lib/autoplan-carry.ts). */
|
||||
carry_check?: { attempt: string; model: string; verdict: 'passed' | 'failed' | 'unavailable'; max_severity: string; findings: number; at: string } }
|
||||
/** The captain approvals shape (docs/captain.md § Approvals), section text taken from the reviewed plan; '' when absent. */
|
||||
export interface GateEli5 { summary: string; before_after: string; why: string; cause: string; decisions: string; plan_file_sha256: string }
|
||||
export const ELI5_FIELDS = [['before_after', 'Before → after'], ['why', 'Why it matters'], ['cause', 'What caused this work'], ['decisions', 'Big decisions']] as const;
|
||||
|
||||
export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean }
|
||||
export interface ParsedReply {
|
||||
@@ -64,6 +71,10 @@ export function validateGateList(list: unknown): string[] {
|
||||
if (g.schema_version !== 1) problems.push('schema_version must be 1');
|
||||
if (typeof g.run !== 'string' || !g.run) problems.push('run is required');
|
||||
if (!Number.isInteger(g.gate_rev) || (g.gate_rev as number) < 1) problems.push('gate_rev must be an integer >= 1');
|
||||
if (g.eli5 !== undefined) {
|
||||
if (!g.eli5 || typeof g.eli5 !== 'object') problems.push('eli5 must be an object');
|
||||
else for (const k of ['summary', 'before_after', 'why', 'cause', 'decisions', 'plan_file_sha256'] as const) if (typeof g.eli5[k] !== 'string') problems.push(`eli5.${k} must be a string`);
|
||||
}
|
||||
if (!Array.isArray(g.items) || g.items.length === 0) return [...problems, 'items must be a non-empty array'];
|
||||
const seen = new Set<string>();
|
||||
g.items.forEach((item, i) => {
|
||||
@@ -103,6 +114,8 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
|
||||
const lines: string[] = [];
|
||||
lines.push(`GATE: run=${list.run} gate_rev=${list.gate_rev} items=${list.items.length} page=${current}/${pages}${list.title ? ` — ${list.title}` : ''}`);
|
||||
lines.push('');
|
||||
if (list.eli5 && current === 1) lines.push(...renderEli5(list.eli5), '');
|
||||
if (list.carry_check) lines.push(`Carry check ${list.carry_check.attempt}: ${list.carry_check.verdict} (${list.carry_check.model}; ${list.carry_check.findings} finding(s), highest ${list.carry_check.max_severity}).`, '');
|
||||
list.items.slice(start, start + pageSize).forEach((item, i) => {
|
||||
const n = start + i + 1;
|
||||
lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis}${item.recommended_by ? `; set by ${item.recommended_by}` : ''})` : ''}${item.phase ? `; from ${item.phase}` : ''})`);
|
||||
@@ -121,6 +134,13 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** The ELI5 block: the summary, then each approvals-shape section under its title. An empty summary says so (CAPTAIN_BRIEF_INCOMPLETE). */
|
||||
export function renderEli5(e: GateEli5): string[] {
|
||||
const lines = e.summary.trim() ? ['ELI5', e.summary.trim()] : [];
|
||||
for (const [key, title] of ELI5_FIELDS) if (e[key].trim()) lines.push(...(lines.length ? [''] : []), `${title}:`, e[key].trim());
|
||||
return lines;
|
||||
}
|
||||
|
||||
function exampleTokens(list: GateList): string {
|
||||
return list.items.slice(0, 2).map(i => `${i.id}${i.options.find(o => o.key !== i.recommended)?.key ?? i.recommended}`).join(' ');
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface DecisionRow {
|
||||
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
|
||||
/** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */
|
||||
owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string;
|
||||
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision whose owner words this answer reuses, its gate_rev, why, who relayed them, items taken under "do it", and the delta check attempt. */
|
||||
carried_gate_from?: string; carried_gate_rev?: number; carry_reason?: string; carried_answered_by?: string; carried_under?: string; carry_check?: string;
|
||||
}
|
||||
export interface TaskRow {
|
||||
schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[];
|
||||
@@ -170,6 +172,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
|
||||
answered_at: str, reply: str, source: str, cost: str,
|
||||
recommended_basis: str, changes: str, origin: str, carried_from: str, chosen_text: str,
|
||||
carried_gate_from: str, carried_gate_rev: { type: 'integer', minimum: 1 }, carry_reason: str, carried_answered_by: str, carried_under: str, carry_check: str,
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
|
||||
@@ -321,7 +321,7 @@ export function explainConfig(): string {
|
||||
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
|
||||
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
|
||||
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
|
||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
|
||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
|
||||
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
|
||||
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
|
||||
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
|
||||
|
||||
+59
-2
@@ -201,6 +201,35 @@ function looksLikeCompactTimestamp(span: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare integers that programs print as constants: 2^30 through 2^49 and each
|
||||
* one minus one (`4294967296`, `2147483647`), and the 32-bit multipliers,
|
||||
* offsets and primes of common hashes and PRNGs (Knuth's multiplicative hash,
|
||||
* the golden-ratio constant, the glibc and Numerical Recipes LCGs, FNV-1a,
|
||||
* MurmurHash2 and 3, the MT19937 seeder, the largest 32-bit prime). pii.phone.e164
|
||||
* reads each as a bare 10-15 digit number. Only the exact separator-free value
|
||||
* is exempt: the same digits with a `+`, spaces, dashes or dots still report,
|
||||
* so an E.164 number, which carries its `+`, is never exempt.
|
||||
*/
|
||||
const NUMERIC_CONSTANTS: ReadonlySet<string> = new Set([
|
||||
...Array.from({ length: 20 }, (_, i) => 2n ** BigInt(30 + i)).flatMap((p) => [String(p), String(p - 1n)]),
|
||||
"2654435761",
|
||||
"2654435769",
|
||||
"1103515245",
|
||||
"1013904223",
|
||||
"2166136261",
|
||||
"1540483477",
|
||||
"3432918353",
|
||||
"2246822507",
|
||||
"3266489909",
|
||||
"1812433253",
|
||||
"4294967291",
|
||||
]);
|
||||
|
||||
export function isNumericConstant(span: string): boolean {
|
||||
return NUMERIC_CONSTANTS.has(span);
|
||||
}
|
||||
|
||||
/** Context window for pairing a normalized parcel ID with its punctuated form. */
|
||||
const PARCEL_CONTEXT_CHARS = 400;
|
||||
|
||||
@@ -446,6 +475,31 @@ export function isDotenvFilename(match: RegExpExecArray): boolean {
|
||||
return spanStart > 0 && input[spanStart - 1] === ".";
|
||||
}
|
||||
|
||||
/** A trailing run of file extensions that are not top-level domains (checked
|
||||
* against the IANA root zone list, 2026-10-28). Extensions that are also TLDs
|
||||
* (`.md`, `.py`, `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on
|
||||
* purpose. */
|
||||
const NON_TLD_EXTENSIONS = /(?:\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff))+$/i;
|
||||
|
||||
/**
|
||||
* True when a pii.email span is a filename with an `@` in it rather than an
|
||||
* address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`,
|
||||
* `eslint-config@next.yaml`. The package@version convention puts a version or
|
||||
* tag after the `@`, so either sign decides: the "domain" ends in file
|
||||
* extensions no TLD uses and what precedes them cannot be a domain (one dotless
|
||||
* label or a dotted version), or it is a dotted version (two or more all-digit
|
||||
* labels) followed by an extension that doubles as a TLD (`a@1.2.md`). A file
|
||||
* named after an address (`jane@acme.com.json`), `user@163.com`,
|
||||
* `alice@1.2.com` and an address at a TLD that doubles as an extension
|
||||
* (`dev@acme.sh`, `ops@acme.md`) still report.
|
||||
*/
|
||||
export function looksLikeAtFilename(span: string): boolean {
|
||||
const domain = span.slice(span.lastIndexOf("@") + 1);
|
||||
const stem = domain.replace(NON_TLD_EXTENSIONS, "");
|
||||
if (stem !== domain) return !/\.[A-Za-z]{2,}$/.test(stem);
|
||||
return /^\d+(?:\.\d+)+\.(?:md|py|sh|rs|so|zip|mov|map)$/i.test(domain);
|
||||
}
|
||||
|
||||
/** Extensions that mark a `<name>.local.<ext>` span as a file. Closed on
|
||||
* purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front
|
||||
* of a public one, so after `.local` it can only be an extension. */
|
||||
@@ -955,6 +1009,7 @@ export const PATTERNS: RedactPattern[] = [
|
||||
regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/,
|
||||
autoRedactable: true,
|
||||
redactToken: "<REDACTED-EMAIL>",
|
||||
validate: (span) => !looksLikeAtFilename(span),
|
||||
// Engine layers the email allowlist (example.com, noreply@, user's own,
|
||||
// repo-public authors) on top of this — see redact-engine.ts.
|
||||
},
|
||||
@@ -969,12 +1024,14 @@ export const PATTERNS: RedactPattern[] = [
|
||||
// A digit-only UUID's hyphen groups read as national phone formatting, and
|
||||
// so do a county tax-map parcel ID (see looksLikeParcelId), vector
|
||||
// coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON
|
||||
// values (isNumericMetadataValue), and GitHub Actions run and job ids in
|
||||
// their id positions (isCiRunIdentifier).
|
||||
// values (isNumericMetadataValue), GitHub Actions run and job ids in
|
||||
// their id positions (isCiRunIdentifier), and bare powers of two and
|
||||
// hash/PRNG constants (isNumericConstant).
|
||||
validate: (span, match) =>
|
||||
!insideUuid(match) &&
|
||||
span.replace(/\D/g, "").length >= 10 &&
|
||||
!looksLikeCompactTimestamp(span) &&
|
||||
!isNumericConstant(span) &&
|
||||
!looksLikeParcelId(span, match) &&
|
||||
!looksLikeDecimalCoordinates(span) &&
|
||||
!isNumericMetadataValue(span, match) &&
|
||||
|
||||
@@ -41,7 +41,7 @@ export const CAPY_RESULT_CODES = {
|
||||
},
|
||||
CAPTAIN_BRIEF_INCOMPLETE: {
|
||||
anchor: 'captain-brief-incomplete',
|
||||
summary: 'the plan lacks one or more of the sections the Captain brief needs (Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval',
|
||||
summary: 'the plan lacks one or more of the sections the Captain brief needs (ELI5 on a run whose gate carries it, Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval',
|
||||
fix: 'add the named sections to the plan, revise the run if the plan is under review, and render again; --allow-incomplete prints a preview that still says it is not ready',
|
||||
},
|
||||
PR_SHOTS_INPUT_NOT_FOUND: {
|
||||
|
||||
+1
-1
@@ -598,7 +598,7 @@ export const RESULT_CODES = {
|
||||
},
|
||||
UPGRADE_BUSY: {
|
||||
anchor: 'upgrade-busy',
|
||||
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), so moving the checkout would change gstack under it',
|
||||
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, so moving the checkout would change gstack under it',
|
||||
fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle',
|
||||
},
|
||||
UPGRADE_FETCH_FAILED: {
|
||||
|
||||
+44
-18
@@ -9,6 +9,8 @@
|
||||
* answer_markers regexes for a quoted sealed answer in added text
|
||||
* seed_patterns regexes for a sampling seed in added text
|
||||
* local_paths true: a local home path (/home/<u>/, /Users/<u>/) in added text
|
||||
* allow_paths globs exempt from the path rules (read from the base branch only; never folds case)
|
||||
* case_insensitive true: `paths` and `transcripts` match regardless of case
|
||||
* visibility "public" | "private": the destination override (reviewed on the base branch)
|
||||
*
|
||||
* The publication boundary is every commit the push publishes: the local sha
|
||||
@@ -18,8 +20,9 @@
|
||||
* copies, independent of content (binary, empty, add-then-delete); text rules
|
||||
* see each commit's added lines. The effective policy is the union of the
|
||||
* destination base branch's `sealed` and the pushed one (a push can tighten,
|
||||
* never relax); waivers come only from the base branch's
|
||||
* `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
|
||||
* never relax); `allow_paths` exceptions come only from the base branch's
|
||||
* policy and remove path findings before waivers, which come only from the
|
||||
* base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
|
||||
* closed). Visibility: the base override, else a cache under the state root
|
||||
* (a `public` answer for 24 h, a `private` one for 1 h before it may
|
||||
* downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
|
||||
@@ -28,19 +31,20 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
|
||||
import { containedRelative, globToRegExp } from './ship-policy';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
|
||||
export const SEALED_CONFIG_PATH = '.gstack/pregate.json';
|
||||
export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv';
|
||||
export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility';
|
||||
export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path';
|
||||
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' }
|
||||
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' }
|
||||
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string }
|
||||
export type ExemptFinding = SealedFinding & { glob: string };
|
||||
export interface AllowEntry { rule: string; path: string; sha: string; reason: string }
|
||||
export type Visibility = 'public' | 'private' | 'unknown';
|
||||
|
||||
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility'];
|
||||
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility'];
|
||||
const MAX_ENTRIES = 64;
|
||||
const MAX_REGEX = 256;
|
||||
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/;
|
||||
@@ -50,9 +54,9 @@ const PRIVATE_TTL_MS = 3600_000;
|
||||
/** `<key>: <message>` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */
|
||||
export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> {
|
||||
const errors: Array<{ path: string; message: string }> = [];
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }];
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }];
|
||||
const o = raw as Record<string, unknown>;
|
||||
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` });
|
||||
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` });
|
||||
const list = (key: string, check: (s: string) => string | null) => {
|
||||
if (!(key in o)) return;
|
||||
const v = o[key];
|
||||
@@ -62,8 +66,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
|
||||
};
|
||||
const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } };
|
||||
const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } };
|
||||
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex);
|
||||
if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' });
|
||||
const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); };
|
||||
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception);
|
||||
for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' });
|
||||
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
|
||||
return errors;
|
||||
}
|
||||
@@ -88,15 +93,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string)
|
||||
const errors = sealedErrors(raw.sealed);
|
||||
if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label };
|
||||
const s = raw.sealed;
|
||||
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label };
|
||||
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label };
|
||||
}
|
||||
|
||||
/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */
|
||||
/** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */
|
||||
export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null {
|
||||
if (!base && !pushed) return null;
|
||||
const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])];
|
||||
const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined;
|
||||
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility };
|
||||
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility };
|
||||
}
|
||||
|
||||
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
|
||||
@@ -126,12 +131,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string
|
||||
|
||||
const HEX = /^[0-9a-f]{40,64}$/;
|
||||
|
||||
/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */
|
||||
function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null {
|
||||
const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; });
|
||||
return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; };
|
||||
}
|
||||
|
||||
/** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */
|
||||
function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
|
||||
if (!p.paths.length && !p.transcripts.length) return [];
|
||||
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n');
|
||||
if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`);
|
||||
const out: SealedFinding[] = [];
|
||||
const transcript = globMatcher(p.transcripts, p.case_insensitive);
|
||||
const sealedPath = globMatcher(p.paths, p.case_insensitive);
|
||||
const tokens = r.out.split('\0');
|
||||
let commit = '';
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
@@ -141,8 +154,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
|
||||
const renamed = /^[RC]\d*$/.test(t);
|
||||
const file = renamed ? tokens[i += 2]! : tokens[++i]!;
|
||||
if (!renamed && !t.includes('A')) continue;
|
||||
if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit });
|
||||
else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit });
|
||||
if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit });
|
||||
else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -216,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
|
||||
|
||||
export interface SealedReport {
|
||||
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
|
||||
policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array<SealedFinding & { reason: string }>;
|
||||
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
|
||||
visibility: Visibility; visibilitySource: string; target: string; error?: string;
|
||||
}
|
||||
|
||||
@@ -225,9 +238,14 @@ export interface EvaluateOptions {
|
||||
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
|
||||
}
|
||||
|
||||
/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
|
||||
export function sealedErrorReport(error: string, target: string): SealedReport {
|
||||
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target };
|
||||
}
|
||||
|
||||
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
|
||||
export function evaluateSealed(o: EvaluateOptions): SealedReport {
|
||||
const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array<SealedFinding & { reason: string }>, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target };
|
||||
const blank = { ...sealedErrorReport('', o.target), error: undefined };
|
||||
const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' };
|
||||
if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` };
|
||||
let policy: SealedPolicy | null = base.policy;
|
||||
@@ -243,6 +261,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
|
||||
let found: SealedFinding[];
|
||||
try { found = scanCommits(o.repoRoot, commits, policy); }
|
||||
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; }
|
||||
const exception = globMatcher(policy.allow_paths, false);
|
||||
const exempt: ExemptFinding[] = [];
|
||||
found = found.filter(f => {
|
||||
const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null;
|
||||
if (glob) exempt.push({ ...f, glob });
|
||||
return !glob;
|
||||
});
|
||||
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
|
||||
const waived: SealedReport['waived'] = [];
|
||||
const findings = found.filter(f => {
|
||||
@@ -252,7 +277,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
|
||||
});
|
||||
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh });
|
||||
const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
|
||||
return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
|
||||
return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
|
||||
}
|
||||
|
||||
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
|
||||
@@ -260,6 +285,7 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$
|
||||
/** stderr text for a report: findings one per line (`<rule> <file[:line]> commit <sha12>`), never the matched text. */
|
||||
export function renderSealed(r: SealedReport, context: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
|
||||
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`);
|
||||
const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`;
|
||||
const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
|
||||
@@ -268,7 +294,7 @@ export function renderSealed(r: SealedReport, context: string): string {
|
||||
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
|
||||
} else if (r.status === 'refused') {
|
||||
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
|
||||
lines.push(`Remove it from every published commit before this branch reaches a public repository, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
|
||||
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
|
||||
} else if (r.status === 'warned') {
|
||||
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
|
||||
}
|
||||
|
||||
+3
-1
@@ -166,6 +166,8 @@ export interface CarryPlanInput {
|
||||
changedProduct: string[];
|
||||
mergeCommits: number;
|
||||
rangeDiff: string;
|
||||
/** The base the receipt was reviewed against when it differs from the carry's (a stack parent that landed, #3570): productPatchId.from is then the recorded diff against it. */
|
||||
retargetedFrom?: string;
|
||||
}
|
||||
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }
|
||||
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string };
|
||||
@@ -232,7 +234,7 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
|
||||
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
|
||||
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : [];
|
||||
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
|
||||
if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change');
|
||||
if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.310",
|
||||
"version": "1.91.316",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -196,3 +196,43 @@ describe('Capy mirror', () => {
|
||||
expect(read.state).toBe('present');
|
||||
});
|
||||
});
|
||||
|
||||
describe('import --autoplan --stack', () => {
|
||||
function stackRepo(): string {
|
||||
const d = repo('feature/one');
|
||||
const g = (args: string[]) => expect(spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], { cwd: d, timeout: 30_000 }).status).toBe(0);
|
||||
g(['commit', '-q', '--allow-empty', '-m', 'seed']);
|
||||
g(['branch', 'feature/two']);
|
||||
return d;
|
||||
}
|
||||
|
||||
test('writes the same rows to every branch, each in its own record; a retry adds nothing', () => {
|
||||
const run = tmp('appr-run-');
|
||||
autoplanRun(run, [{ by: 'agent:GSTA-36', words: 'Garry: Do it', at: '2026-10-29T03:40:00.000Z', p1: 'a' }]);
|
||||
const cwd = stackRepo(); const state = tmp('appr-state-');
|
||||
const env = { GSTACK_STATE_ROOT: state };
|
||||
const r = bin(['import', '--autoplan', run, '--by', 'Garry', '--stack', 'feature/one,feature/two'], { cwd, env });
|
||||
expect(r.status, r.err).toBe(0);
|
||||
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/one n=1 skipped=0 /m);
|
||||
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/two n=1 skipped=0 /m);
|
||||
expect(r.out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=2$/m);
|
||||
for (const b of ['feature/one', 'feature/two']) expect(bin(['render', '--branch', b], { cwd, env }).out).toContain('Garry: Do it');
|
||||
expect(bin(['import', '--autoplan', run, '--by', 'Garry', '--stack', 'feature/one,feature/two'], { cwd, env }).out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=0$/m);
|
||||
});
|
||||
|
||||
test('one unknown branch writes nothing to any branch', () => {
|
||||
const run = tmp('appr-run-');
|
||||
autoplanRun(run, [{ by: 'agent:GSTA-36', words: 'rec', at: '2026-10-29T03:40:00.000Z', p1: 'a' }]);
|
||||
const cwd = stackRepo(); const state = tmp('appr-state-');
|
||||
const r = bin(['import', '--autoplan', run, '--stack', 'feature/one,feature/nope'], { cwd, env: { GSTACK_STATE_ROOT: state } });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.err).toContain('feature/nope');
|
||||
expect(r.err).toContain('(APPROVAL_RECORD_NO_BRANCH)');
|
||||
expect(bin(['render', '--branch', 'feature/one'], { cwd, env: { GSTACK_STATE_ROOT: state } }).out).not.toContain('Approve the plan');
|
||||
});
|
||||
|
||||
test('--stack goes with import --autoplan and without --branch', () => {
|
||||
const cwd = stackRepo();
|
||||
expect(bin(['import', '--autoplan', tmp('appr-run-'), '--branch', 'feature/one', '--stack', 'feature/one'], { cwd }).status).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe('answer', () => {
|
||||
}, 30_000);
|
||||
|
||||
test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => {
|
||||
const out = gatedRun('reopen');
|
||||
const out = gatedRun('reopen', ['--no-carry-forward']);
|
||||
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const runId = readRun(out).run;
|
||||
@@ -166,7 +166,7 @@ describe('answer', () => {
|
||||
}, 30_000);
|
||||
|
||||
test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
|
||||
const out = gatedRun('eng-only', ['--max-rounds', '3']);
|
||||
const out = gatedRun('eng-only', ['--max-rounds', '3', '--no-carry-forward']);
|
||||
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
|
||||
const runId = readRun(out).run;
|
||||
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] });
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Carry-forward (GSTA-155, lib/autoplan-carry.ts): a later gate revision that asks nothing new is answered with the
|
||||
* owner's recorded words from the revision before it, and recorded as a carry; every other case prints why not and
|
||||
* stays at the gate. A "do it" also covers new auto items that change no scope, cost or direction. A capped,
|
||||
* unreviewed delta gets one reviewer on the delta first (CARRY_CHECK, gstack-autoplan check): High or above means no
|
||||
* carry. Runs on the simulated Capy host.
|
||||
*/
|
||||
import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { isDoIt } from '../lib/autoplan-carry';
|
||||
import { importAutoplan } from '../lib/approval-record';
|
||||
import { renderCaptainBrief } from '../lib/captain-brief';
|
||||
import { recHash, renderGate } from '../lib/gate-list';
|
||||
import { loadBriefInputs } from '../lib/owner-brief';
|
||||
import { SMALL_PLAN } from './helpers/autoplan-run-fixture';
|
||||
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
const r = capyRunner();
|
||||
afterAll(() => r.cleanup());
|
||||
let n = 0;
|
||||
const NONE = { native: [], outside: [] };
|
||||
const AGREE = {
|
||||
native: [{ id: 'F1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
|
||||
outside: [{ id: 'O1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
|
||||
};
|
||||
const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1');
|
||||
const gateOf = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
|
||||
const decisions = (out: string) => readJsonl(path.join(out, 'decisions.jsonl'));
|
||||
const journal = (out: string) => readJsonl(path.join(out, 'attempts.jsonl'));
|
||||
|
||||
/** Gate 1 with one recommended plan change (uc1b), answered with the owner's words, then revised to take it. */
|
||||
function answeredAndRevised(args: string[] = [], words = 'Garry: Do it', ceo: { native?: unknown[]; outside?: unknown[] } = AGREE) {
|
||||
const dir = path.join(r.host.base, `carry${++n}`);
|
||||
const plan = writePlan(dir);
|
||||
const out = path.join(dir, 'run');
|
||||
expect(r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', ceo);
|
||||
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
|
||||
const run = readRunJson(out).run;
|
||||
const ans = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', recHashOf(out), '--by', 'agent:gstack-minder', '--owner-reply', words]);
|
||||
expect(ans.status, ans.stderr).toBe(0);
|
||||
expect(ans.stdout).toMatch(/^APPLY: /m);
|
||||
const v2 = path.join(dir, 'plan-v2.md');
|
||||
fs.writeFileSync(v2, V2);
|
||||
const rev = r.ap(['revise', '--out', out, '--plan', v2, '--incorporates', 'uc1']);
|
||||
expect(rev.status, rev.stderr).toBe(0);
|
||||
return { dir, out, run, v2, rev };
|
||||
}
|
||||
const recHashOf = (out: string) => recHash(gateOf(out));
|
||||
|
||||
describe('carry-forward', () => {
|
||||
test('a second gate with nothing new carries the owner\'s words: CARRIED, APPROVED, the record names its source', () => {
|
||||
const s = answeredAndRevised();
|
||||
phaseVia(r, s.out, 'ceo', NONE);
|
||||
const eng = phaseVia(r, s.out, 'eng', NONE).outside;
|
||||
expect(eng.status, eng.stderr).toBe(0);
|
||||
expect(eng.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 words="Garry: Do it" by=agent:gstack-minder reason=no new choice; plan unchanged since the accepted changes$/m);
|
||||
expect(eng.stdout).toMatch(/^APPROVED: gate_rev=2/m);
|
||||
expect(eng.stdout).toContain('status=complete');
|
||||
const p1 = decisions(s.out).find(d => d.gate_rev === 2 && d.label === 'P1');
|
||||
expect(p1).toMatchObject({ chosen: 'a', owner_reply: 'Garry: Do it', answered_by: 'agent:gstack-autoplan', carried_gate_from: `${s.run}-p1`, carried_gate_rev: 1, carried_answered_by: 'agent:gstack-minder' });
|
||||
expect(journal(s.out).find(e => e.event === 'gate_carried')).toMatchObject({ gate_rev: 2, from_gate_rev: 1 });
|
||||
expect(journal(s.out).find(e => e.event === 'answered' && e.gate_rev === 1)).toMatchObject({ answer_class: 'plan-changes' });
|
||||
expect(fs.readFileSync(path.join(s.out, 'decisions.tsv'), 'utf8')).toContain('carried gate_rev 2 from gate_rev 1');
|
||||
expect(r.ap(['export', '--out', s.out]).status).toBe(0);
|
||||
expect(r.validate(s.out).status).toBe(0);
|
||||
const rows = importAutoplan(s.out, 'feature/x', 'Garry');
|
||||
if ('code' in rows) throw new Error(rows.message);
|
||||
const carried = rows.find(x => x.source.endsWith('gate_rev=2'))!;
|
||||
expect(carried).toMatchObject({ words: 'Garry: Do it', outcome: 'approved' });
|
||||
expect(carried.ask).toContain('gate_rev 2 carried forward from gate_rev 1');
|
||||
expect(carried.by).toBe('Garry (words from gate_rev 1, relayed by agent:gstack-minder), carried forward by gstack-autoplan');
|
||||
const loaded = loadBriefInputs(s.out);
|
||||
if ('error' in loaded) throw new Error(String(loaded.error));
|
||||
expect(renderCaptainBrief(loaded.inputs, { outDir: s.out }).text).toContain('**Nothing pending.** Carried forward from gate_rev 1: approved as reviewed under the owner\'s words “Garry: Do it”');
|
||||
});
|
||||
|
||||
test('--no-carry-forward: the second gate goes to the owner', () => {
|
||||
const off = answeredAndRevised(['--no-carry-forward']);
|
||||
phaseVia(r, off.out, 'ceo', NONE);
|
||||
const o = phaseVia(r, off.out, 'eng', NONE).outside;
|
||||
expect(o.stdout).toMatch(/^CARRY: no \(carry-forward is off for this run \(--no-carry-forward\)\)$/m);
|
||||
expect(o.stdout).toMatch(/^GATE: gate_rev=2 /m);
|
||||
expect(o.stdout).toContain('status=gate_pending');
|
||||
});
|
||||
|
||||
test('an edit to the plan file after the revise (an ELI5-only line) refuses the carry', () => {
|
||||
const edited = answeredAndRevised();
|
||||
fs.appendFileSync(edited.v2, '\nAn ELI5 sentence added after the revise.\n');
|
||||
phaseVia(r, edited.out, 'ceo', NONE);
|
||||
const e = phaseVia(r, edited.out, 'eng', NONE).outside;
|
||||
expect(e.stdout).toMatch(/^CARRY: no \(the plan file plan-v2\.md changed after the owner's answer or the revise that took it\)$/m);
|
||||
expect(readRunJson(edited.out).status).toBe('gate_pending');
|
||||
});
|
||||
|
||||
test('a new scope challenge or an urgent finding refuses the carry', () => {
|
||||
const scope = answeredAndRevised();
|
||||
phaseVia(r, scope.out, 'ceo', NONE);
|
||||
const s2 = phaseVia(r, scope.out, 'eng', { native: [{ id: 'F9', severity: 'High', title: 'Add a dead-letter queue', user_challenge: true, changes: 'scope', fix: 'dlq' }], outside: [] }).outside;
|
||||
expect(s2.stdout).toMatch(/^CARRY: no \(new item \w+: Add a dead-letter queue/m);
|
||||
|
||||
const urgent = answeredAndRevised();
|
||||
phaseVia(r, urgent.out, 'ceo', NONE);
|
||||
const u = phaseVia(r, urgent.out, 'eng', { native: [{ id: 'F8', severity: 'Medium', title: 'Tokens logged in plain text', urgent: true, suggested_owner: 'security' }], outside: [] }).outside;
|
||||
expect(u.stdout).toMatch(/^CARRY: no \(urgent finding open: Tokens logged in plain text\)$/m);
|
||||
});
|
||||
|
||||
test('"do it" also covers a new packaging challenge; "rec" does not', () => {
|
||||
const PKG = { native: [{ id: 'F7', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'own PR' }], outside: [] };
|
||||
const doIt = answeredAndRevised();
|
||||
phaseVia(r, doIt.out, 'ceo', NONE);
|
||||
const d = phaseVia(r, doIt.out, 'eng', PKG).outside;
|
||||
expect(d.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason="do it" covers \w+; plan unchanged/m);
|
||||
expect(d.stdout).toMatch(/^CARRIED_UNDER_DO_IT: \w+ Split the backoff into its own PR .*\(the owner has not seen this item\)$/m);
|
||||
expect(decisions(doIt.out).find(x => x.gate_rev === 2 && /Split the backoff/.test(x.title))).toMatchObject({ carried_under: 'do it' });
|
||||
const loaded = loadBriefInputs(doIt.out);
|
||||
if ('error' in loaded) throw new Error(String(loaded.error));
|
||||
expect(renderCaptainBrief(loaded.inputs, { outDir: doIt.out }).text).toContain('Taken under "do it" without being shown to the owner: Split the backoff into its own PR.');
|
||||
const rec = answeredAndRevised([], 'rec');
|
||||
phaseVia(r, rec.out, 'ceo', NONE);
|
||||
expect(phaseVia(r, rec.out, 'eng', PKG).outside.stdout).toMatch(/^CARRY: no \(new item \w+: Split the backoff/m);
|
||||
});
|
||||
|
||||
test('an earlier answer in item tokens, or one that left an item unanswered, is not carried', () => {
|
||||
const dir = path.join(r.host.base, `carry${++n}`);
|
||||
const out = path.join(dir, 'run');
|
||||
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', AGREE);
|
||||
phaseVia(r, out, 'eng', NONE);
|
||||
const g = gateOf(out);
|
||||
const tokens = g.items.map((i: any) => `${i.id}${i.recommended}`).join(' ');
|
||||
expect(r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', tokens, '--by', 'human']).status).toBe(0);
|
||||
const v2 = path.join(dir, 'plan-v2.md');
|
||||
fs.writeFileSync(v2, V2);
|
||||
expect(r.ap(['revise', '--out', out, '--plan', v2, '--incorporates', 'uc1']).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', NONE);
|
||||
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toMatch(/^CARRY: no \(gate_rev 1 was answered with item tokens, not rec or "do it"\)$/m);
|
||||
});
|
||||
|
||||
test('an answer that left an approval item pending is not carried', () => {
|
||||
const dir = path.join(r.host.base, `carry${++n}`);
|
||||
const out = path.join(dir, 'run');
|
||||
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', AGREE);
|
||||
phaseVia(r, out, 'eng', NONE);
|
||||
const p1 = gateOf(out).items.find((i: any) => i.id === 'p1');
|
||||
const ans = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', `p1${p1.recommended}`, '--by', 'human']);
|
||||
expect(ans.status, ans.stderr).toBe(0);
|
||||
expect(decisions(out).some(x => x.gate_rev === 1 && x.status === 'pending')).toBe(true);
|
||||
const v2 = path.join(dir, 'plan-v2.md');
|
||||
fs.writeFileSync(v2, V2);
|
||||
expect(r.ap(['revise', '--out', out, '--plan', v2]).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', NONE);
|
||||
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toMatch(/^CARRY: no \(gate_rev 1 left \S+ unanswered\)$/m);
|
||||
});
|
||||
|
||||
test('isDoIt reads the owner\'s words, not a Captain\'s', () => {
|
||||
for (const w of ['Do it', 'do it.', 'Garry: Do it', 'Garry Tan: do it!']) expect(isDoIt(w)).toBe(true);
|
||||
for (const w of ['rec', 'accept all', 'Captain: Do it', 'GStack Captain: do it', 'p3b: do it', "Captain: rec, Highs as build tasks under Garry's 'Do it'", 'Do it and also improve it']) expect(isDoIt(w)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the delta check before a carry', () => {
|
||||
function capped() {
|
||||
const s = answeredAndRevised(['--max-rounds', '1']);
|
||||
expect(s.rev.stdout).toMatch(/^CAPPED: ceo used round 1 of 1/m);
|
||||
expect(s.rev.stdout).toMatch(/^CARRY_CHECK: attempt=\S+-c2 items=uc\d+,uc\d+ prompt=\S+ result=\S+ model_family=not anthropic estimate=5\.00 cap=5\.00$/m);
|
||||
expect(s.rev.stdout).toMatch(/^GATE: gate_rev=2 /m);
|
||||
const run = readRunJson(s.out);
|
||||
expect(run.status).toBe('gate_pending');
|
||||
const check = run.carry.check;
|
||||
const prompt = fs.readFileSync(check.prompt, 'utf8');
|
||||
expect(prompt).toContain('+ ');
|
||||
expect(prompt).toContain(`INPUT: carry-check ${check.delta_sha256}`);
|
||||
return { ...s, check };
|
||||
}
|
||||
const result = (file: string, sha: string, findings: unknown[]) => fs.writeFileSync(file, `INPUT: carry-check ${sha}\n\nReviewed the delta.\n\n` + '```gstack-findings\n' + findings.map(f => JSON.stringify(f)).join('\n') + '\n```\n');
|
||||
|
||||
test('a clean delta check carries; the verdict is on the gate, the decisions and the trail', () => {
|
||||
const s = capped();
|
||||
result(s.check.result, s.check.delta_sha256, [{ id: 'F1', severity: 'Low', title: 'Wording' }]);
|
||||
const same = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'anthropic/claude-opus-5-5']);
|
||||
expect(same.status).toBe(3);
|
||||
expect(same.stderr).toContain('MODEL_FAMILY_CONFLICT');
|
||||
const ok = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
|
||||
expect(ok.status, ok.stderr).toBe(0);
|
||||
expect(ok.stdout).toMatch(/^CARRY_CHECK: passed attempt=\S+ model=openai\/gpt-6\.1-sol findings=1 highest=Low$/m);
|
||||
expect(ok.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason=delta check passed on uc\d+,uc\d+; plan unchanged/m);
|
||||
expect(ok.stdout).toContain('status=complete');
|
||||
expect(gateOf(s.out).carry_check).toMatchObject({ verdict: 'passed', model: 'openai/gpt-6.1-sol', max_severity: 'Low', findings: 1 });
|
||||
expect(decisions(s.out).filter(x => x.gate_rev === 2 && x.carry_check).length).toBe(2);
|
||||
expect(fs.readFileSync(path.join(s.out, 'decisions.tsv'), 'utf8')).toContain('delta check before carrying gate_rev 2');
|
||||
expect(r.ap(['export', '--out', s.out]).status).toBe(0);
|
||||
expect(r.validate(s.out).status).toBe(0);
|
||||
});
|
||||
|
||||
test('a High finding, or no reviewer, leaves the gate for the owner', () => {
|
||||
const s = capped();
|
||||
result(s.check.result, s.check.delta_sha256, [{ id: 'F1', severity: 'High', title: 'The edit also drops the jitter' }]);
|
||||
const bad = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
|
||||
expect(bad.status, bad.stderr).toBe(0);
|
||||
expect(bad.stdout).toMatch(/^CARRY: no \(delta check found High: The edit also drops the jitter\)$/m);
|
||||
expect(bad.stdout).toMatch(/^GATE: gate_rev=2 /m);
|
||||
const g = gateOf(s.out);
|
||||
expect(g.carry_check).toMatchObject({ verdict: 'failed', max_severity: 'High' });
|
||||
expect(g.items.filter((i: any) => i.cap?.kind === 'delta').every((i: any) => i.flags.some((f: string) => f.includes('The edit also drops the jitter')))).toBe(true);
|
||||
const again = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--unavailable', 'x']);
|
||||
expect(again.status).toBe(3);
|
||||
const u = capped();
|
||||
const none = r.ap(['check', '--out', u.out, '--attempt', u.check.attempt, '--unavailable', 'no other family online']);
|
||||
expect(none.status, none.stderr).toBe(0);
|
||||
expect(none.stdout).toMatch(/^CARRY: no \(delta check unavailable: no other family online\)$/m);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(u.out, 'spend.json'), 'utf8')).released.map((x: any) => x.attempt)).toContain(u.check.attempt);
|
||||
});
|
||||
|
||||
// Value: protects=a delta check over --carry-check-cap or with the wrong --attempt never carries the owner's answer;
|
||||
// fails_when=the cap comparison or the attempt guard is dropped or inverted; why_new=tests above run at the default cap
|
||||
// with the right attempt only; seam=none; no_claim=real reviewer dispatch (simulated Capy host)
|
||||
test('a check over --carry-check-cap refuses the carry; a wrong --attempt is refused', () => {
|
||||
const over = answeredAndRevised(['--max-rounds', '1', '--carry-check-cap', '1']);
|
||||
expect(over.rev.stdout).toMatch(/^CARRY: no \(delta check estimate 5\.00 is over the per-gate cap 1\.00 \(--carry-check-cap\)\)$/m);
|
||||
expect(over.rev.stdout).not.toMatch(/^CARRY_CHECK:/m);
|
||||
expect(readRunJson(over.out).status).toBe('gate_pending');
|
||||
const s = capped();
|
||||
result(s.check.result, s.check.delta_sha256, []);
|
||||
const wrong = r.ap(['check', '--out', s.out, '--attempt', 'not-the-attempt', '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
|
||||
expect(wrong.status).toBe(3);
|
||||
expect(wrong.stderr).toContain('ATTEMPT_MISMATCH');
|
||||
expect(readRunJson(s.out).status).toBe('gate_pending');
|
||||
});
|
||||
|
||||
test('an owner answer at the gate voids a pending delta check and releases its reservation', () => {
|
||||
const s = capped();
|
||||
const ans = r.ap(['answer', '--out', s.out, '--gate-rev', '2', '--reply', 'p1c', '--by', 'human']);
|
||||
expect(ans.status, ans.stderr).toBe(0);
|
||||
expect(ans.stdout).toContain(`CARRY: no (delta check ${s.check.attempt} void: the owner answered gate_rev 2)`);
|
||||
result(s.check.result, s.check.delta_sha256, []);
|
||||
const late = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
|
||||
expect(late.status).toBe(3);
|
||||
expect(late.stderr).toContain('PHASE_NOT_AWAITING');
|
||||
expect(readRunJson(s.out).status).not.toBe('complete');
|
||||
expect(decisions(s.out).some(x => x.gate_rev === 2 && x.carried_gate_from)).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(s.out, 'spend.json'), 'utf8')).released.map((x: any) => x.attempt)).toContain(s.check.attempt);
|
||||
});
|
||||
|
||||
test('a plan edited after the check was dispatched is not carried on that check', () => {
|
||||
const s = capped();
|
||||
fs.appendFileSync(readRunJson(s.out).active_plan, '\nA line the reviewer never saw.\n');
|
||||
result(s.check.result, s.check.delta_sha256, []);
|
||||
const c = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
|
||||
expect(c.status, c.stderr).toBe(0);
|
||||
expect(c.stdout).toContain('void: the plan changed after the delta check was dispatched');
|
||||
expect(c.stdout).not.toMatch(/^CARRIED:/m);
|
||||
expect(readRunJson(s.out).status).toBe('gate_pending');
|
||||
});
|
||||
|
||||
test('--same-family binds a same-family reviewer and records why', () => {
|
||||
const s = capped();
|
||||
result(s.check.result, s.check.delta_sha256, []);
|
||||
const ok = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'anthropic/claude-opus-5-5', '--same-family', 'no other family online']);
|
||||
expect(ok.status, ok.stderr).toBe(0);
|
||||
expect(gateOf(s.out).carry_check.model).toBe('anthropic/claude-opus-5-5 (same family: no other family online)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the review estimate', () => {
|
||||
test('the first next prints the most the review can reserve', () => {
|
||||
const dir = path.join(r.host.base, 'estimate');
|
||||
const out = path.join(dir, 'run');
|
||||
const first = r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']);
|
||||
expect(first.stdout).toMatch(/^REVIEW_ESTIMATE: phases=ceo,eng voices=2\+2 rounds<=2 attempts<=8 per_voice=5\.00 reservations<=40\.00 \(a reservation total, not a spend ceiling/m);
|
||||
expect(r.ap(['next', '--out', out]).stdout).not.toContain('REVIEW_ESTIMATE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate eli5', () => {
|
||||
const SECTIONS = '\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
|
||||
function gated(name: string, text: string) {
|
||||
const dir = path.join(r.host.base, name);
|
||||
const out = path.join(dir, 'run');
|
||||
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir, text), '--no-ui']).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', NONE);
|
||||
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
|
||||
return out;
|
||||
}
|
||||
|
||||
test('gate.json carries the plan\'s ELI5 and four sections, and page 1 of the render opens with them', () => {
|
||||
const out = gated('eli5', SMALL_PLAN + '\n## ELI5\n\nRetries wait longer each time.\n' + SECTIONS);
|
||||
const gate = gateOf(out);
|
||||
expect(gate.eli5).toMatchObject({ summary: 'Retries wait longer each time.', before_after: 'Before: retries hammer. After: they back off.', why: 'Outages.', cause: 'Incident 7.', decisions: '1. Exponential backoff.' });
|
||||
expect(gate.eli5.plan_file_sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
const page = renderGate(gate).split('\n');
|
||||
expect(page.slice(2, 4)).toEqual(['ELI5', 'Retries wait longer each time.']);
|
||||
expect(page.indexOf('Before → after:')).toBeGreaterThan(3);
|
||||
expect(page.indexOf('Before → after:')).toBeLessThan(page.findIndex(l => /^1\. \[/.test(l)));
|
||||
const loaded = loadBriefInputs(out);
|
||||
if ('error' in loaded) throw new Error(String(loaded.error));
|
||||
const brief = renderCaptainBrief(loaded.inputs, { outDir: out });
|
||||
expect(brief.missing).toEqual([]);
|
||||
expect(brief.text.indexOf('### ELI5')).toBeLessThan(brief.text.indexOf('### Before → after'));
|
||||
});
|
||||
|
||||
test('a plan without ## ELI5: the Captain brief is not ready and names it; the plain gate render does not ask for it', () => {
|
||||
const out = gated('no-eli5', SMALL_PLAN + SECTIONS);
|
||||
const page = renderGate(gateOf(out));
|
||||
expect(page).not.toContain('CAPTAIN_BRIEF_INCOMPLETE');
|
||||
expect(page).toContain('Before → after:');
|
||||
const loaded = loadBriefInputs(out);
|
||||
if ('error' in loaded) throw new Error(String(loaded.error));
|
||||
const brief = renderCaptainBrief(loaded.inputs, { outDir: out });
|
||||
expect(brief.missing).toEqual(['ELI5']);
|
||||
expect(brief.text).toStartWith('**Not ready for approval:**');
|
||||
});
|
||||
|
||||
test('a gate written before the eli5 field renders and briefs as before', () => {
|
||||
const out = gated('legacy', SMALL_PLAN + SECTIONS);
|
||||
const gate = gateOf(out);
|
||||
delete gate.eli5;
|
||||
fs.writeFileSync(path.join(out, 'gate.json'), JSON.stringify(gate, null, 2) + '\n');
|
||||
expect(renderGate(gate)).not.toContain('ELI5');
|
||||
const loaded = loadBriefInputs(out);
|
||||
if ('error' in loaded) throw new Error(String(loaded.error));
|
||||
expect(renderCaptainBrief(loaded.inputs, { outDir: out }).missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe('gstack-owner-brief render --captain', () => {
|
||||
describe('carried decisions at a cap gate (#3546)', () => {
|
||||
const r = capyRunner();
|
||||
afterAll(() => r.cleanup());
|
||||
const SECTIONS = '\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
|
||||
const SECTIONS = '\n## ELI5\n\nRetries wait longer each time.\n\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
|
||||
const PLAN = SMALL_PLAN + SECTIONS;
|
||||
const WORDS: Record<string, string> = { 'keep your direction': 'keep the plan as written', 'accept the change': "make the reviewer's change" };
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ afterAll(() => r.cleanup());
|
||||
|
||||
const PLAN = `# Retry backoff for the webhook worker
|
||||
|
||||
## ELI5
|
||||
|
||||
A failed webhook now waits longer before each retry, so the partner stops dropping them.
|
||||
|
||||
## Before → after
|
||||
|
||||
Before: a failed webhook retries every second until it is dropped. After: retries back off from 500 ms to 30 s.
|
||||
|
||||
@@ -393,6 +393,32 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
|
||||
}
|
||||
});
|
||||
|
||||
// #3571: /ship's docs step ran install.sh --for document-release from inside the run that
|
||||
// holds the lease, the upgrader excluded its own ancestry, and the checkout moved mid-/ship.
|
||||
// On Capy the leased pid is the long-lived capy-server, so the deferral ends by the lease's age.
|
||||
test('install mode inside a run that holds a fresh workflow lease: the self-upgrade is deferred, HEAD stays and the trailer stays ok; once that lease is 120 minutes old the same install upgrades', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
||||
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
|
||||
const leaseDir = path.join(f.host.base, 'leases');
|
||||
const script = [
|
||||
'bash "$1" touch --pid $$ --root "$2" || exit 9',
|
||||
'bash "$3" --for document-release; echo "exit=$?"',
|
||||
'git -C "$2" rev-parse HEAD',
|
||||
'"$5" -e "const t = Date.now() / 1000 - 121 * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "$4/$$"',
|
||||
'bash "$3" --for document-release; echo "exit=$?"',
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script, 'run', path.join(f.root, 'bin/gstack-lease'), f.root, BOOT, leaseDir, process.execPath], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_ROOT: f.root, GSTACK_CAPY_REPO: `file://${f.bare}`, WRAP_LOG: path.join(f.host.base, 'wrap.log'), GSTACK_LEASE_DIR: leaseDir }, timeout: 120_000 });
|
||||
const [inRun, aged] = r.stdout.split(/^exit=0$/m);
|
||||
expect(r.stdout.match(/^exit=0$/gm), r.stdout + r.stderr).toHaveLength(2);
|
||||
expect(inRun).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease on this installation: pid \d+ \(0m bash\), so the upgrade waits until the lease is 120 minutes old; fix: .+bin\/gstack-upgrade once the run ends \(UPGRADE_BUSY\)\)$/m);
|
||||
expect(inRun).not.toContain('SELF_UPGRADE: done');
|
||||
expect(inRun).toMatch(/blocked=a gstack run in this session holds a workflow lease .+ \(UPGRADE_BUSY\)$/m);
|
||||
expect(aged).toContain(f.b);
|
||||
expect(aged).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
|
||||
});
|
||||
|
||||
test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
||||
|
||||
@@ -193,6 +193,12 @@ describe('pregate config: sealed (CD4)', () => {
|
||||
expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']);
|
||||
expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']);
|
||||
expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']);
|
||||
expect(codes({ sealed: { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json', '**/protected/MANIFEST.json'], case_insensitive: true } })).toEqual([]);
|
||||
expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
|
||||
expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']);
|
||||
expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
|
||||
expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/*.json', 'sealed/*.*', '**/sealed/**.json', '**/sealed/*/MANIFEST.json', '**/sealed/*/transcript-*.jsonl', '.hidden'] } })).toEqual([0, 1, 2, 3, 4, 5].map(i => `PREGATE_CONFIG_INVALID:sealed.allow_paths[${i}]`));
|
||||
expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Two MEDIUM false positives from the gstack-evals runs (gstack 1.91.299-301):
|
||||
* pii.phone.e164 on `4294967296` (2^32, a PRNG constant) and pii.email on
|
||||
* `terminal-bench@2.0.json` (a package@version filename). Each guard is
|
||||
* pinned in both directions: the false positive is clean, and the real shape
|
||||
* next to it still reports.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { scan } from "../lib/redact-engine";
|
||||
import { isNumericConstant, looksLikeAtFilename } from "../lib/redact-patterns";
|
||||
|
||||
const flags = (id: string, s: string): boolean =>
|
||||
scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === id);
|
||||
|
||||
describe("pii.phone.e164 and bare numeric constants", () => {
|
||||
// Value: protects=a bare power of two (or one less) and the common 32-bit hash/PRNG constants raise no phone finding; fails_when=the guard is
|
||||
// removed and `4294967296` reports pii.phone.e164 again; why_new=no constant guard existed; seam=none (scan); no_claim=other constants not listed
|
||||
test("powers of two, their max values and hash/PRNG constants are not phone numbers", () => {
|
||||
for (const s of [
|
||||
"const RANGE = 4294967296;",
|
||||
"mask = 4294967295",
|
||||
"INT32_MAX 2147483647",
|
||||
"x % 2147483648",
|
||||
"1073741824 bytes",
|
||||
"2^49 is 562949953421312",
|
||||
"h *= 2654435761",
|
||||
"seed ^= 2654435769",
|
||||
"a = 1103515245",
|
||||
"basis 2166136261",
|
||||
"mt init 1812433253",
|
||||
"p = 4294967291",
|
||||
]) {
|
||||
expect([s, flags("pii.phone.e164", s)]).toEqual([s, false]);
|
||||
}
|
||||
expect(isNumericConstant("4294967296")).toBe(true);
|
||||
expect(isNumericConstant("4294967297")).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the same digits written as a phone number (a +, spaces, dashes, parens) and ordinary bare numbers still report; fails_when=the
|
||||
// guard exempts by digits alone or by length, so +14294967296 or 429-496-7296 goes quiet; why_new=pins the narrowness of the new guard;
|
||||
// seam=none (scan); no_claim=international formats beyond the ones listed
|
||||
test("real phone numbers still report, including the constant's digits in phone form", () => {
|
||||
for (const s of [
|
||||
"tel: +14294967296",
|
||||
"call 429-496-7296",
|
||||
"reach +1 429 496 7296",
|
||||
"phone (429) 496-7296",
|
||||
"p 429.496.7296",
|
||||
"call 4294967297 now",
|
||||
"ring +44 20 7946 0958",
|
||||
"office 4155550123",
|
||||
]) {
|
||||
expect([s, flags("pii.phone.e164", s)]).toEqual([s, true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("pii.email and package@version filenames", () => {
|
||||
// Value: protects=a filename with an @ (package@version, or a name ending in a file extension that is not a TLD) raises no email finding;
|
||||
// fails_when=the guard is removed and terminal-bench@2.0.json reports pii.email again; why_new=no filename guard existed; seam=none (scan);
|
||||
// no_claim=extensions outside the closed list
|
||||
test("package@version and @-named files are not email addresses", () => {
|
||||
for (const s of [
|
||||
"results in terminal-bench@2.0.json",
|
||||
"npm pack wrote lodash@4.17.21.tgz",
|
||||
"config eslint-config@next.yaml",
|
||||
"cache react-dom@18.2.0.tar.gz",
|
||||
"pin left-pad@1.3.0.lock",
|
||||
"notes a@1.2.md",
|
||||
]) {
|
||||
expect([s, flags("pii.email", s)]).toEqual([s, false]);
|
||||
}
|
||||
expect(looksLikeAtFilename("terminal-bench@2.0.json")).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=real addresses still report, including numeric domain labels (163.com, 1.2.com), TLDs that double as file extensions (.sh, .md,
|
||||
// .py, .zip) and a file named after an address; fails_when=the guard treats any digit label, any extension-like TLD or any trailing extension as a
|
||||
// filename; why_new=pins the narrowness of the new guard; seam=none (scan); no_claim=every IANA TLD
|
||||
test("real addresses still report, numeric domains and extension-shaped TLDs included", () => {
|
||||
for (const s of [
|
||||
"mail alex@acme-corp.com",
|
||||
"reach li.wei@163.com",
|
||||
"ops dev@acme.sh",
|
||||
"docs ops@acme.md",
|
||||
"py team@acme.py",
|
||||
"files sales@acme.zip",
|
||||
"contact jo@mail.acme.io",
|
||||
"reach alice@1.2.com",
|
||||
"export jane.doe@acme-corp.com.json",
|
||||
"log at ops-team@acme.io.log",
|
||||
"uploaded jdoe@acme-corp.com.PDF",
|
||||
"archive li.wei@163.com.tar.gz",
|
||||
]) {
|
||||
expect([s, flags("pii.email", s)]).toEqual([s, true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,143 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR
|
||||
});
|
||||
});
|
||||
|
||||
const MANIFEST = { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json'], answer_markers: ['SEALED ANSWER:'] };
|
||||
function preflight(s: Scratch, args: string[] = []) {
|
||||
const r = spawnSync(PREGATE, ['sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
|
||||
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
|
||||
}
|
||||
function landOnMain(s: Scratch, files: Record<string, string>): void {
|
||||
sh(s.work, s.env, ['checkout', '-q', 'main']);
|
||||
write(s.work, files);
|
||||
sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'base change']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']);
|
||||
sh(s.work, s.env, ['checkout', '-q', 'results']);
|
||||
}
|
||||
|
||||
describe('sealed.allow_paths: base-branch exceptions applied after the deny globs', () => {
|
||||
test('a deny with an allowed exception refuses the sealed file, lists the manifest as exempt, and a manifest-only push passes', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'sealed/s1/tasks.json': '{}\n', 'sealed/s1/MANIFEST.json': '{"n":1}\n', 'tasks/x/sealed/s2/MANIFEST.json': '{"n":2}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/tasks\.json/m);
|
||||
expect(r.err).not.toMatch(/^ sealed\.path\s+\S*MANIFEST\.json/m);
|
||||
expect(r.err).toMatch(/exempt sealed\.path sealed\/s1\/MANIFEST\.json commit [0-9a-f]{12} \(sealed\.allow_paths \*\*\/sealed\/\*\/MANIFEST\.json\)/);
|
||||
expect(r.err).toContain('exempt sealed.path tasks/x/sealed/s2/MANIFEST.json');
|
||||
const only = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(only, { 'sealed/s1/MANIFEST.json': '{"n":1}\n' });
|
||||
expect(hook(only).code).toBe(0);
|
||||
const pre = preflight(only);
|
||||
expect(pre.code).toBe(0);
|
||||
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
|
||||
const json = JSON.parse(preflight(only, ['--json']).out);
|
||||
expect(json.exempt).toHaveLength(1);
|
||||
expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' });
|
||||
});
|
||||
|
||||
test('an exception that does not match still refuses: another case and another depth', () => {
|
||||
for (const file of ['sealed/s1/manifest.json', 'sealed/s1/x/MANIFEST.json']) {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { [file]: '{}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain(`sealed.path ${file}`);
|
||||
expect(r.err).not.toContain('exempt sealed.');
|
||||
}
|
||||
});
|
||||
|
||||
test('an exception added on the pushed head is ignored, on top of a base list too; a malformed pushed allow_paths refuses', () => {
|
||||
const fresh = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
|
||||
commit(fresh, { '.gstack/pregate.json': policy(MANIFEST), 'sealed/s1/MANIFEST.json': '{}\n' });
|
||||
const f = hook(fresh);
|
||||
expect(f.code).toBe(1);
|
||||
expect(f.err).toContain('sealed.path sealed/s1/MANIFEST.json');
|
||||
const extra = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(extra, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: [...MANIFEST.allow_paths, '**/sealed/*/notes.md'] }), 'sealed/s1/notes.md': 'n\n' });
|
||||
expect(hook(extra).err).toContain('sealed.path sealed/s1/notes.md');
|
||||
const bad = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(bad, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: '**/sealed/*/MANIFEST.json' }), 'docs/x.md': 'x\n' });
|
||||
const b = hook(bad);
|
||||
expect(b.code).toBe(1);
|
||||
expect(b.err).toContain('sealed.allow_paths');
|
||||
});
|
||||
|
||||
test('an allowed manifest that quotes a sealed answer still refuses on the text rule', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'sealed/s1/MANIFEST.json': '{"note":"SEALED ANSWER: b"}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/sealed\.answer\s+sealed\/s1\/MANIFEST\.json:1/);
|
||||
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
|
||||
});
|
||||
|
||||
test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) });
|
||||
const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' });
|
||||
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` });
|
||||
const pre = preflight(s);
|
||||
expect(pre.code).toBe(0);
|
||||
expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl');
|
||||
expect(pre.out).toMatch(/findings=0 .* waived=0 exempt=1 /);
|
||||
});
|
||||
|
||||
test('a preflight error carries the same report shape: exit 1, SEALED: error with exempt=0, no stack trace', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'docs/x.md': 'x\n' });
|
||||
const r = preflight(s, ['--range', 'nosuch..HEAD']);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.out).toMatch(/^SEALED: error findings=0 commits=0 .* waived=0 exempt=0 /m);
|
||||
expect(r.err).not.toMatch(/\n\s+at /);
|
||||
expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).exempt).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sealed.case_insensitive: deny globs fold case, exceptions never do', () => {
|
||||
test('off (the default), HELDOUT/ passes a lowercase deny glob; on, HELDOUT/, HeldOut/ and a Transcript refuse, and hold-out/ still passes', () => {
|
||||
const off = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
|
||||
commit(off, { 'HELDOUT/q.json': '{}\n' });
|
||||
expect(hook(off).code).toBe(0);
|
||||
const on = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], case_insensitive: true }) });
|
||||
commit(on, { 'HELDOUT/q.json': '{}\n', 'HeldOut/q.json': '{}\n', 'Runs/c1/Transcript.jsonl': '{}\n', 'hold-out/q.json': '{}\n' });
|
||||
const r = hook(on);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain('sealed.path HELDOUT/q.json');
|
||||
expect(r.err).toContain('sealed.path HeldOut/q.json');
|
||||
expect(r.err).toContain('sealed.transcript Runs/c1/Transcript.jsonl');
|
||||
expect(r.err).not.toContain('hold-out/q.json');
|
||||
});
|
||||
|
||||
test('on, allow_paths stay case-sensitive: MANIFEST.json is exempt and manifest.json refuses', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ ...MANIFEST, case_insensitive: true }) });
|
||||
commit(s, { 'sealed/s1/MANIFEST.json': '{}\n', 'sealed/s1/manifest.json': '{}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
|
||||
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/manifest\.json/m);
|
||||
});
|
||||
|
||||
test('set only on the pushed head, it still tightens (base OR pushed)', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
|
||||
commit(s, { '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], case_insensitive: true }), 'HELDOUT/q.json': '{}\n' });
|
||||
expect(hook(s).err).toContain('sealed.path HELDOUT/q.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('root-anchoring (the docs/pregate.md example)', () => {
|
||||
test('globs match from the repo root: sealed/** misses a nested sealed/ directory and **/sealed/** catches it at any depth', () => {
|
||||
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8');
|
||||
expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`');
|
||||
expect(docs).toContain('write `**/sealed/**`');
|
||||
const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) });
|
||||
commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' });
|
||||
expect(hook(anchored).code).toBe(0);
|
||||
const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
|
||||
commit(any, { 'tasks/x/sealed/a.json': '{}\n', 'sealed/b.json': '{}\n' });
|
||||
const a = hook(any);
|
||||
expect(a.err).toContain('sealed.path tasks/x/sealed/a.json');
|
||||
expect(a.err).toContain('sealed.path sealed/b.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility (cache under the state root; unknown counts as public)', () => {
|
||||
test('a public answer is cached; a cached private answer older than an hour is re-queried before it can downgrade; a failed lookup is unknown', () => {
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-vis-'));
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* gstack-ship-receipt carry for a stacked child after its parent squash-merges (#3570,
|
||||
* docs/ship-policy.md#receipt-carry): the receipt reviewed the child's own range against the parent
|
||||
* branch, and that range must be HEAD's whole product diff against the base the PR now targets.
|
||||
* Anything else (the parent not landed, the child changed) fails closed.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { bindShippedReceipt } from '../lib/ship-receipt-depth';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
|
||||
const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n";
|
||||
|
||||
const RETRY4 = "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n";
|
||||
|
||||
/** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */
|
||||
function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/util.js': SUB }, childFiles: Record<string, string> = { 'lib/retry.js': RETRY4 }): { parent: string; child: string; body: string } {
|
||||
repo.git('checkout', '-q', '-b', 'parent');
|
||||
const parent = commit(repo, parentFiles, 'parent: sub');
|
||||
repo.git('push', '-q', 'origin', 'parent');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('reset', '-q', '--hard', parent);
|
||||
const child = commit(repo, childFiles, 'child: four retries');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'parent', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
|
||||
expect(w.status).toBe(EXIT.ok);
|
||||
return { parent, child, body };
|
||||
}
|
||||
/** Land `files` on main as one commit (the parent's squash merge; none when the parent never lands), then an unrelated main commit, and merge main into the child. */
|
||||
function landOnMain(repo: FixtureRepo, files: Record<string, string>): void {
|
||||
repo.git('checkout', '-q', 'main');
|
||||
if (Object.keys(files).length) {
|
||||
for (const [rel, text] of Object.entries(files)) write(repo.work, rel, text);
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'squash: parent (#7)');
|
||||
}
|
||||
write(repo.work, 'docs/README.md', '# docs after the parent\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
}
|
||||
|
||||
describe('carry after a stack parent squash-merges (#3570)', () => {
|
||||
// Value: protects=a stacked child keeps its receipt when its parent squash-merges and main is merged in (gstack-evals#19
|
||||
// paid a full /ship for it); fails_when=carry compares the child's whole diff from the old fork point; seam=none;
|
||||
// no_claim=gh pr edit (--body stands in)
|
||||
test('the child carries when the range it was reviewed for is HEAD\'s whole product diff against main', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const { child, body } = stack(repo);
|
||||
const reviewed = receiptIn(body).product_patch_id;
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.ok);
|
||||
expect(out.out).toContain(`CARRY: carried from ${child.slice(0, 12)} to ${head.slice(0, 12)}`);
|
||||
const r = receiptIn(body);
|
||||
expect(r.head).toBe(head);
|
||||
expect(r.carry!.reason).toBe('stack parent parent landed + base merge');
|
||||
expect(r.product_patch_id).toBe(reviewed);
|
||||
expect(r.base).toBe('main');
|
||||
expect(r.review_depth!.base).toBe(repo.git('rev-parse', 'origin/main'));
|
||||
expect(r.review_depth!.gate_scan.head).toBe(head);
|
||||
expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '8', [head]).ok).toBe(true);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
commit(repo, { 'docs/CHANGES.md': '# main moves again\n' }, 'main moves again');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const again = carry(repo, body);
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge + base merge');
|
||||
expect(receiptIn(body).product_patch_id).toBe(reviewed);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=a receipt written before product_patch_id still carries while the parent branch resolves, and refuses by
|
||||
// name once it is gone; fails_when=the fallback reads another ref or carries with no reviewed diff; seam=none; no_claim=-
|
||||
test('without a recorded product patch-id the child range comes from origin/<parent>, and a deleted parent branch refuses', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const { parent, child, body } = stack(repo);
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
const { product_patch_id: _dropped, head_tree: _tree, ...legacy } = receiptIn(body);
|
||||
fs.writeFileSync(body, renderReceiptBlock(legacy as any));
|
||||
expect(receiptIn(body).product_patch_id).toBeUndefined();
|
||||
repo.git('push', '-q', 'origin', '--delete', 'parent');
|
||||
repo.git('fetch', '-q', '--prune', 'origin');
|
||||
const gone = carry(repo, body);
|
||||
expect(gone.status).toBe(EXIT.refused);
|
||||
expect(gone.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)');
|
||||
expect(gone.err).toContain('the receipt was reviewed against parent, not main, and its reviewed product diff is unavailable');
|
||||
expect(receiptIn(body).head).toBe(child);
|
||||
repo.git('push', '-q', 'origin', `${parent}:refs/heads/parent`);
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
expect(carry(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge');
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=a child with no product change of its own (product_patch_id none) still carries once the parent lands;
|
||||
// fails_when=the recorded 'none' is compared as a literal id; seam=none; no_claim=-
|
||||
test('a test-only child carries after its parent lands', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const { body } = stack(repo, { 'lib/util.js': SUB }, { 'test/retry.test.js': "const test = require('node:test');\ntest('retries', () => {});\n" });
|
||||
expect(receiptIn(body).product_patch_id).toBe('none');
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge');
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=fail closed: a child changed after the parent merged, or a parent that never reached main, takes a fresh
|
||||
// /ship; fails_when=the comparison ignores the child's remainder or the parent's absence; seam=none; no_claim=-
|
||||
test('a product edit on the child after the merge, or a parent that never landed, refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const { child, body } = stack(repo);
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 5;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }, 'child: five');
|
||||
const edited = carry(repo, body);
|
||||
expect(edited.status).toBe(EXIT.refused);
|
||||
expect(edited.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
expect(receiptIn(body).head).toBe(child);
|
||||
|
||||
const other = makeFixtureRepo(); repos.push(other);
|
||||
const s = stack(other);
|
||||
landOnMain(other, {});
|
||||
const abandoned = carry(other, s.body);
|
||||
expect(abandoned.status).toBe(EXIT.refused);
|
||||
expect(abandoned.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
expect(abandoned.err).toContain('is not the one reviewed against parent (the parent has not fully landed on main');
|
||||
expect(receiptIn(s.body).head).toBe(s.child);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=fail closed on the parent's release-metadata paths: a package.json change (a dependency and a postinstall
|
||||
// script) the parent made but main never received is product in HEAD's diff against main, though the child never touched it;
|
||||
// fails_when=the comparison takes its pathspec from the reviewed head..HEAD range, where package.json is unchanged; seam=none;
|
||||
// no_claim=-
|
||||
test('a parent package.json change that never reached main refuses', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const pkg = JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node --test test/*.test.js', generate: 'node gen.js', postinstall: 'node gen.js' }, dependencies: { leftpad: '1.0.0' } }, null, 2) + '\n';
|
||||
const { child, body } = stack(repo, { 'lib/util.js': SUB, 'package.json': pkg });
|
||||
landOnMain(repo, { 'lib/util.js': SUB });
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.refused);
|
||||
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
expect(receiptIn(body).head).toBe(child);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=the docs row "a parent that landed with changes the child never merged" refuses; fails_when=a merge that
|
||||
// keeps the child's tree (-s ours) over the landed parent reads as unchanged; seam=none; no_claim=-
|
||||
test('a parent that landed with changes the child never merged refuses', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const { child, body } = stack(repo);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
commit(repo, { 'lib/util.js': SUB + "exports.mul = (a, b) => a * b;\n" }, 'squash: parent with a review fix (#7)');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--no-edit', '-s', 'ours', 'origin/main');
|
||||
const out = carry(repo, body);
|
||||
expect(out.status).toBe(EXIT.refused);
|
||||
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
expect(receiptIn(body).head).toBe(child);
|
||||
}, 90_000);
|
||||
});
|
||||
Reference in new issue
Block a user