diff --git a/.gstack/pregate.json b/.gstack/pregate.json index f9cb2832c..0454e74f3 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -20,5 +20,5 @@ "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" }, "warn": [], - "strays": { "allow": [] } + "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index d188eced9..d50acbc38 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -14,5 +14,5 @@ "pr_count": { "default_pr_count": 1, "split_requires_owner": true }, "maintainers": ["garrytan"], "train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 }, - "min_gstack_version": "1.91.146.0" + "min_gstack_version": "1.91.299.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index bc277d79a..a61544ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,279 @@ ### Gate integrity - This is a gate edit that only tightens. The guard now refuses lines it passed before. No existing test, threshold or allowlist changed. A new case in `test/sealed-guard.test.ts` covers all five operators, a positional parameter, `${@`, an array element, an indirect name, a Windows path and the TypeScript `??` form. It also checks six negative controls, which are two hyphenated paths, a hyphenated sentence, `$VAR-`, `${VAR}-` and a relative default. Without the fix it misses every `-` form. +## [1.91.320.0] - 2026-10-29 + +**A branch that adds its own pregate lane now learns why it still fails, and `gstack-post pr-body` no longer keeps a stale copy of a section.** Pregate reads its policy from the base branch so a branch cannot loosen its own gate. A branch that had already declared a lane in its own `.gstack/pregate.json` was still told to declare it, and went round in a circle. Separately, a PR body that held `## What changed` twice (an old description pasted under the /ship body) kept the old copy every time /ship reposted. + +### Fixed +- **`PREGATE_NO_LANE`**: when the branch edits `.gstack/pregate.json` and the policy comes from the base copy or the user-owned file, the fix line names that policy, says a lane the branch adds counts only once it is on the base, and offers both routes: land the policy change in its own PR first, or rerun with `--policy-from head --allow-repo-commands`, which records the run as unreviewed. Pregate still never reads the branch's policy on its own. (#3574) +- **`gstack-post pr-body`**: when the PR's current body holds a title from the file more than once, it posts nothing, exits 64 and says to rerun with `--replace` and the whole body. /ship's PR-body step stops on that refusal and reports the duplicated titles instead of reposting with `--replace` itself, since a person chooses which copy stays. (#3575) + +### Docs +- `docs/troubleshooting.md#pregate-no-lane-own-policy` explains the own-policy case and its two routes; `docs/pregate.md`'s trust boundary links it. The `gstack-post-pr-body-merge` entry covers the duplicate-title refusal. + +### Tests +- `test/pregate-selection.test.ts`: the own-policy fix line under the base policy; a head-policy run and a branch that deletes `pregate.json` get the plain fix. +- `test/pregate-user-policy.test.ts`: the own-policy line names the user file as the policy source. +- `test/gstack-post.test.ts`: a doubled title refuses and posts nothing, `--replace` posts the file, and a body with two receipt blocks refuses too. + +### Gate integrity +- `test/gstack-post.test.ts`: the duplicate-titles assertion that expected the first copy replaced and the second kept now expects the refusal, plus a case where duplicates the file does not touch are still kept. + +## [1.91.319.0] - 2026-10-29 + +**`gstack-pregate sealed` no longer prints a line that reads like a push confirmation.** On gstack-evals#29 the lane read `pushed aeb2413` in the `SEALED:` line as the push landing, while the remote stayed at 9036ab1 (#3565). `sealed` never pushes: it checks the commits a push would publish. `pushed ` was its label for the commit whose `sealed` policy it read beside the base branch's. + +### Fixed +- **`lib/sealed-guard.ts`**: that label now reads `commit ` (`policy=origin/main + commit aeb2413f0ca1`), in the `SEALED:` line, in the managed pre-push hook's messages and in the unreadable-policy error. +- **`gstack-pregate --help`** and **docs/pregate.md#sealed** say the command never pushes and that `git ls-remote ` confirms a push. + +### Tests +- `test/sealed-guard.test.ts`: `gstack-pregate sealed` names the checked commit, prints no `pushed`, and leaves the remote's refs exactly as they were. It failed before the fix on `policy=origin/main + pushed `. + +## [1.91.318.0] - 2026-10-29 + +**/office-hours now has eval tasks for the shared skill eval, and /write-skill's diet locks its tasks before a paid run.** gstack-evals' Lane A will re-check the office-hours diet from #3525. To do that it needs office-hours' tasks in gstack's contract format, a sidecar for what Lane A adds, and a diet step that commits the lock a paid run requires. #3525 never committed the task set GSTA-134 ran, so these tasks rebuild it from the pilot's written summary. + +### Added +- **`test/fixtures/skill-evals/office-hours/`**: 11 tasks on a CSV-merge idea, with a `seed.sh` that makes the git repo and the `.gstack-home` state root. Nine follow the pilot's set, which covered the mode question, the first startup question, pushback on vague evidence, builder mode, the premise challenge, the second-opinion offer, alternatives, the UI visual step and the backend skip. Two are new safety tasks. One allows no code during a session (the hard gate), and the other allows no design doc before the user picks an approach (the Phase 4 stop). `no-code-mvp` failed in every arm of the pilot and is left out. +- **`tasks.ext.json`** beside it, in Lane A's sidecar format: every task is interactive, with a persona and scripted replies, a judge rubric per behavior task, and a violating `mutant` per safety task. Reply category names are provisional until gstack-evals publishes its list. + +### Changed +- **/write-skill diet step 6** starts with `gstack-evals skill-eval --lock` and a commit of `tasks.lock.json`. Without that commit the shared eval refuses a paid run (LOCK_MISSING). +- **`docs/designs/SKILL_EVAL_CONTRACT.md`** adds notes for implementers. The harness should set both `GSTACK_STATE_ROOT` and `GSTACK_HOME` to `/.gstack-home`. A verdict may carry an optional `resumable` field. The notes also cover the lock, the sidecar, and an open question about what `must` patterns grade when a host answers an interactive task's questions. + +### Tests +- `test/skill-eval-tasks.test.ts` checks office-hours' `tasks.json` against the contract. It also checks that each `tasks.ext.json` entry names a task in `tasks.json` and uses only known keys. No existing test, threshold or allowlist changed. + +## [1.91.317.0] - 2026-10-29 + +**Registered gstack skills now say when to use them where the harness model reads them, and OpenClaw lists the task skills directly.** HARN-5's paid cells showed OpenClaw and Hermes listing gstack's skills while the model rarely opened them. Router lines kept only each skill's first sentence and dropped its "Use when asked to ..." trigger, and Hermes's skills index shows only the first 57 characters of a description, which for gstack was capability prose. This release changes only what gstack writes into its own skills folders. Use rates are not claimed yet: HARN-5's fixed panel measures them next. + +### Changed +- **Router lines lead with the trigger** (`gstack-harness`, every router-mode target: OpenClaw, dots, `generic` and `--print`). Each line opens with the skill's own trigger sentence, or, when the description has none, `Use for:` and the skill's `triggers:` list, within 160 characters. The router's description now leads with task verbs (review, debug, QA test, plan, ship) in its first 200 characters, within the 300-character budget. The wording rule lives in `lib/skill-triggers.ts`. +- **Hermes descriptions open with a trigger list of at most 57 characters** (`frontmatter.descriptionLead` in `hosts/hermes.ts`), for example `Use for: review this pr, code review, check my diff.`, followed by the description without its own trigger sentence, which the lead repeats, so no Hermes render grows. Claude Code and every other host render their descriptions as before. +- **OpenClaw registers in `hybrid` mode by default**: `gstack-review`, `gstack-investigate`, `gstack-qa`, `gstack-ship` and `gstack-plan-eng-review` are top-level skills and the `gstack` router carries the rest. Dumped through OpenClaw 2026.10.6's own `buildSkillSnapshot`/`prepareSkillsForPrompt` with its 19 eligible bundled skills, hybrid's skills section is 10,290 characters in full format (budget 18,000). `--skills all` falls to compact format at 17,971 characters, and `--skills router` is 6,701. `--skills router` and `--skills all` remain, and a mode switch now removes the empty folders the previous mode left behind. +- **`--context ` is opt-in for OpenClaw and Hermes too**: the block is written only into the file named, after a `gstack-harness: context block: ( scope)` line. Setup, upgrades and refresh never add one and do not re-add a block you deleted. A second `--context` file keeps the first, because the ledger keys blocks by file. The block text follows the mode (with `--skills all` it names no router path). Uninstall now restores each file byte for byte (the blank separator line, a missing final newline and a file gstack created are undone). +- `HARNESS_MODE_UNSUPPORTED`'s fix text and docs/hosts.md describe Hermes's listing, not its loader, and docs/hosts.md states registered, listed and used as separate facts. + +### Fixed +- Re-registering an OpenClaw, Hermes or dots registration without `--context` (what setup and `gstack-harness auto` do) no longer cuts a block the user opted into. + +### Added +- `HARNESS_CONTEXT_REFUSED`: `--context` refuses `SOUL.md` (in any letter case), and for Hermes a new rules file that would outrank rules Hermes loads now: a file of an earlier group (`.hermes.md`/`HERMES.md` before `AGENTS.md` before `CLAUDE.md`), an earlier name in the same folder (`.hermes.md` before `HERMES.md`, `AGENTS.override.md` before `AGENTS.md`), or a `.hermes.md`/`HERMES.md` below one in a parent folder, which Hermes takes as the nearest. + +### Gate integrity +- `test/harness-register.test.ts`: three expectations change with the byte-for-byte restore and blocks keyed by file. The generic cut now expects the original bytes, without the extra newline. The second `--context` case now expects both blocks kept and both restored where it used to expect the first block cut. The OpenClaw router case passes `mode: 'router'` because the default is now hybrid. New cases cover router lines, Hermes leads (at most 57 characters), hybrid with mode switches through refresh and uninstall, context opt-in, refusal and scope, the CLI scope line, and Hermes's threat scanner on every block variant. The scanner case runs when `GSTACK_HERMES_SRC` names a Hermes checkout and is skipped otherwise. More new cases cover the same-folder and nearest-parent refusals, pre-blockpad ledgers, refresh keeping a block the user edited, `--json --context` output, and the Hermes check outside a git repository; new `test/skill-triggers.test.ts` covers the wording rule's fallbacks and cuts. +- `test/fixtures/skill-md-size-allowlist.json`: the Hermes pins for `gstack-autoplan` (108,982 to 108,891) and `gstack-plan-eng-review` (128,708 to 128,655) go down to the new render sizes, as the size guard directs. +- New `test/session-kind-marker.test.ts`: a marker-only environment (`GSTACK_SESSION_KIND=spawned`, no `GSTACK_CAPY`) gets the same soft admission from `bin/gstack-install-verdict.sh` and `lib/install-verdict.ts`. A ratchet fails when a file in `bin/`, `lib/`, `hosts/` or `scripts/` reads or sets `GSTACK_SESSION_KIND` outside its 13-file inventory; a planted-reader control and a comments-only control check it. + +## [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 `/ship-runs//` (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`. +- **`gstack-review-log --start ... --reservation `**: under a cap, a dispatch without an unspent, one-shot reservation refuses before launch (`BUDGET_CHECK_SKIPPED`), and `--finish` settles it. Without a cap nothing changes. +- **/ship**: the skeleton's Budget paragraph (`--token-cap`, `--resume `, `--continue `) and a new `budget-handoff` section with the meter recipes, reservations, stop and resume, and the delegate flow per host. +- **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. + +### 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`. + +## [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 ` 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/` 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 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/` 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= <- gate_rev= …`; anything else prints `CARRY: no ()` 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 --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

(m ), 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//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//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 commit (sealed.allow_paths )` 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=` after `waived=` 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 + +**The cookie workflow judge now asks for structured output, so a quote the model forgets to escape can no longer fail the gate.** Train 42 went red because both of its `setup-browser-cookies/SKILL.md workflow` samples came back as free text with a bare quote inside `reasoning` ("one --domain per run"), and neither parsed. The GSTA-91 comparison had measured that failure: 2 of 144 free-text samples failed to parse, against 0 of 72 with the schema. + +### Changed +- **Cookie workflow judge transport:** `setup-browser-cookies/SKILL.md workflow` now sends `WORKFLOW_JUDGE_RESPONSE_SCHEMA`, released on GStack Minder's ruling: "GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is matched by old-b; free-text parse errors cost train 42". Its model, thresholds (4/3/4), sample count and case kind are unchanged. The ruling is recorded in `test/fixtures/judge-calibration/cookie-workflow/release.json`, and the held comparison stays recorded as held. + +### Tests +- `test/cookie-workflow-judge-input.test.ts` runs the registered cookie callback through the real `callJudge` and pins its request to the schema. It replays train 42's two replies (msg_011CgVnRtVj5vyLvb56m6KJH, msg_011CgVne5GStZ6f9pRoCvgWE): their free-text bytes do not parse, and the same judgment returned as schema output scores and passes. `test/judge-calibration.test.ts` reads the release record. Both fail without the transport change. + +### Gate integrity +- Gate edit: the cookie judge's response transport moves from free text to the JSON schema, on the ruling above. No threshold, sample, trial, deadline or case-kind change. The Haiku free-text failure pin in `test/llm-judge-frontier.test.ts` is untouched. + +## [1.91.309.0] - 2026-10-29 + +**The judged review and Eng prose each give one rule where the paid judges kept finding two, and a judge that runs out of time now says what it was waiting on.** Every sample of two red panels on #3527's screens (review 40617200857, plan-eng-review 40634787316), attempt and rerun, named the same contradictions; a third red (ship, train 35 gate 40660626349) hit the 120 s judge deadline with nothing logged but "No completed model response". + +### Fixed +- **`/review` Step 5.8**: an unresolved non-gate defect (an unanswered question) is recorded as `open`, so every finding has a record action. A repeat pass's reruns of superseded probes are revalidation, so they run after smoke expiry, in the same probe directory; earlier provisional QA sections link checkpoints and only the final pass runs the once-only `materialize`. +- **`/plan-eng-review`**: the shared Voice now loads once. Eng no longer carries a near-copy whose only difference was its em-dash clause; the Scope gate's format precedence says required formats keep their em dashes. +- **`/benchmark` Phase 6 example**: the hero image recommendation no longer suggests lazy-loading (that delays the largest paint) and names one remedy, as the rule under it asks. + +### Changed +- **Judge cancellations leave evidence.** When a judge request is cancelled (the workflow judge deadline), `callJudge` prints one `llm-judge-aborted` line: elapsed time, wrapper attempts and 429 waits, HTTP requests and statuses (so the SDK's own retries show), connection, first and last stream event times, the open block type, thinking and text character counts, message and request ids, and the reason (at most 200 characters). Counts only, never content; an observer that throws cannot change a judge result. Deadlines, effort, samples and thresholds are unchanged. + +### Tests +- `test/review-workflow-clarity.test.ts` and `test/eng-scope-entry-ap.test.ts` pin the repaired rules; both fail with the prose change stashed. +- `test/llm-judge-stream.test.ts` and `test/llm-judge-abort.test.ts` replay a stream cancelled mid-thinking, a stream with no events, one cancelled after headers, an SDK-retried 429, a cancelled 429 wait, an event the observer cannot read and an overlong reason, with no-abort controls; each fails with the helper change stashed. `test/workflow-judge-cache.test.ts`'s SDK stream stand-ins gain the real stream's `on()`. +- Focused paid judge runs: review 4/4/4 at 1bdc4ce and again at 3463250, plan-eng-review 3/5/4 at 1bdc4ce ($0.18). + +### Gate integrity +- No threshold, sample, trial, deadline or case-kind change. `scripts/harness-version.json` records the `llm-judge.ts` change as non-behavioral (no series reset). Generated SKILL.md size pins for review and plan-eng-review were lowered to their new sizes on every host. + +## [1.91.308.0] - 2026-10-29 + +**`gstack-restamp` no longer cuts a four-part package.json version to three parts in a repo whose convention is four.** On garrytan/gbrain#7878, `/ship` restamped gbrain's `package.json` to `"0.60.483"` beside `VERSION` `0.60.483.0`; gbrain requires the two to match and its CI version gate fails when they drift, so the stamp had to be reverted by hand. The version stage called `gstack-version-bump write`, which always stores the npm-valid three-part form in the manifest (decision 11, which is why gstack's own `package.json` reads `1.91.298`). + +### Fixed +- **`lib/restamp.ts`**: the version stage keeps the full version in the manifest when the policy lists it in `stamp_paths` or `stamp_files` and the base's copy holds four parts, and the stage detail says so (`wrote 0.60.483.0 (package.json keeps four parts: …)`). The form is read at the base, like the policy, so a branch an earlier restamp already cut to three parts goes back to four. Every other repo, gstack included, still gets the npm-valid form, and a four-part manifest the policy does not list still migrates to three parts as decision 11 says. +- **`bin/gstack-version-bump write`**: new `--manifest-form npm|full` (default `npm`); `full` writes the version as given to the manifest and its npm lockfiles. It has no effect when the version path is itself a JSON manifest. + +### Tests +- `test/restamp-four-part-manifest.test.ts`: a gbrain-shaped fixture (four-part `VERSION` and `package.json`, the manifest in `stamp_files`, bun, no npm lockfile) restamped from an unstamped branch and from a branch already carrying its stamp (both failed before the fix with `"0.60.483"`), from a branch an older restamp cut to three parts, and with the manifest listed through `stamp_paths`. gstack's shape (three-part manifest and lockfile) and an unlisted four-part manifest keep their current results. +- `test/gstack-version-bump.test.ts`: `--manifest-form full` keeps four parts in the manifest and lockfile; an unknown form exits 2 before anything is written. + +## [1.91.307.0] - 2026-10-29 + +**/ship now reviews in proportion to what the diff can break: light, standard or full depth.** A diff with 50 or fewer runtime lines gets one batched specialist plus the native adversarial pass, a larger runtime diff gets the class's specialists scoped to their hunks, and gate code or any classifier error gets the full review as before. The receipt records the depth and the queue refuses a receipt below what the head needs. Before this, a 12-line fix paid for the same reviewer fan-out as a change to the merge gate. The backtest that would have let light depth drop the adversarial pass was inconclusive (9 light-eligible PRs, 5 with attributable findings, below the 15 the plan required), so light keeps it. + +### Added +- **Review depth** (`lib/review-depth.ts`, `lib/review-collect.ts`, `gstack-review-plan`): `REVIEW_DEPTH:` (depth, runtime lines, the rules hash, reasons), `GATE_SCAN:` bound to HEAD, one `DEPTH_ERROR:` per input that fails closed to full (with the input, the cause and the repair), and per-reviewer `DIFF_CMD:` pathspecs. Runtime lines leave out docs, tests and outputs registered with a command in the base branch's `.gstack/generated.json`, read at the base commit. `--depth-floor standard|full` only raises the depth. A fixed or open blocker, or a CRITICAL, raises light to standard for the rest of the invocation. +- **Receipt fields**: `gstack_version` and `review_depth` (`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`), computed at the checked-out head. `write --head` must be HEAD. `review_loop_exception` (`write --review-loop-exception`) records an owner-accepted stop after a fix-check edit with the fix-check's head and findings, and the PR body repeats its line word for word (Fixes #3559). +- **Queue checks** (`gstack-ship-queue ready`, `train build`): the depth the head needs is recomputed from git and a lower receipt is `QUEUE_RECEIPT_DEPTH_RAISED`, with the receipt's depth, the required depth, reasons and inputs on a `REVIEW_DEPTH:` line. A malformed owner exception, a body that dropped its line, or a fix-check head that is not the bound head or its ancestor is `QUEUE_RECEIPT_EXCEPTION_INVALID`. The queue reads the same committed range as the writer and trusts a recorded gate scan only when it names the full bound commit. A receipt with neither new field predates depth receipts and is accepted as legacy. + +### Changed +- **Carry and depth**: a carry recomputes the depth at the new head, rebinds the gate scan, and refuses when the new head needs more (a test change that skips a test is gate class). It also refuses a `--head` other than the checked-out HEAD, a receipt with `gstack_version` but no `review_depth`, and an owner exception that no longer stands, since the queue would refuse each of them. Tracked edits in the checkout make the write-time gate scan unavailable, which records `full`. +- **`gstack-diff-scope --head `**: the scope signals for the committed range only, with no working-tree or untracked files; an unknown sha is `SCOPE_ERROR=no_head` and a missing merge base `SCOPE_ERROR=no_merge_base`. +- **`.gstack/ship-policy.json`**: `min_gstack_version` rises from 1.91.146.0 to 1.91.299.0. + +### Tests +- `test/ship-receipt-depth.test.ts`: write records the depth bound to HEAD and refuses another `--head`. The queue refuses light on a standard head, a current receipt with no depth and an unbound gate scan, and accepts a legacy receipt. A plain test change carries and rebinds the scan, while a skipped test refuses the carry. The review-loop exception is refused without a fix-check, with a foreign head or without its body line, and the queue refuses a body that dropped the line. +- `test/review-depth.test.ts`, `test/review-collect.test.ts`, `test/review-plan-cli.test.ts`: the depth matrix, the fixed light set, fail-closed errors and the registry read at the base commit. A `package.json` version line that also carries other keys is not stamp-only. +- `test/diff-scope.test.ts`: `--head` reads the committed range and ignores working-tree edits, and refuses an unknown sha. + +## [1.91.306.0] - 2026-10-29 + +**A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it. + +### Changed +- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD lists it under the `regen` check's new `proven` paths and that check ran the same base registry. Without that evidence, for a file that matches an output glob but that the command never writes, and for an entry with no command or one only the branch registers, the output stays product. +- **`gstack-pregate` `regen` check**: a fresh entry with touched outputs runs its command a second time in the scratch worktree with those outputs deleted, and records the ones it recreates byte-identically as `proven`. The verdict still comes from the first run, so a generator that reads its own output passes as before and proves nothing. +- **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`. +- **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules. + +### Tests +- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product. +- `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`. + +## [1.91.305.0] - 2026-10-29 + +**You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it. + +### Added +- **`bin/gstack-modules`**: `list` prints every module id (`skill:`, `section:`, `overlay:`, `preamble:`) with its source and pin; `validate` checks `modules/loadouts.json`; `render --host (--loadout | --modules full,-,+) --out-dir ` renders one loadout with the same `--model`, `--install-root` and `--plan-mode` setup uses, and writes `loadout.lock.json` (spec sha256, excluded ids, per-file sha256 and bytes). +- **`modules/loadouts.json`** (with a JSON schema): rungs `frontier`, `strong`, `mid`, `light`, the model-id table, pinned modules and named loadouts. Every rung and `unknown` map to `full`. +- **Safety pins**: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade` can never be excluded, directly or by excluding the skill that holds them (`MODULE_PINNED`). +- **Renders refuse instead of guessing**: an unknown loadout or module, a malformed spec, a rendered file that still points at an excluded skill or section (`MODULE_EXCLUSION_UNSAFE`), or an `--out-dir` that an earlier render did not write (`RENDER_DEST_NOT_OWNED`) each exit 2 with a fix line and a `docs/troubleshooting.md` anchor, and never fall back to `full`. A failed render leaves the earlier one in place. +- **`docs/loadouts.md`**: terms, commands, how to build an eval arm, and how a loadout changes (only with a cited GSTA-2 run). + +### Changed +- `gen:skill-docs` accepts `--loadout` and `--modules` with an `--out-dir` outside the checkout; a selection with exclusions aimed at the committed tree is refused. Excluded sections render a one-line `Not loaded in the loadout` stub, and `{{IF_SECTION:}}` guards remove skeleton text that belongs to a section (office-hours' second-opinion offer and visual-design phase, plan-design-review's review-sections self-check). `gstack/llms.txt` lists only the skills a render includes. + +### For contributors +- New `test/module-loadouts.test.ts` covers the manifest, selection, pins, the render filter, the CLI and the byte-identical `full` render. `.gstack/pregate.json` allows the two new `modules/` files as strays. `test/gen-skill-docs-prune-stale.test.ts`'s copied fixture now links `scripts/modules.ts`; no assertion, threshold or budget changed. + +## [1.91.304.0] - 2026-10-29 + +**Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13). + +### Fixed +- **`bin/gstack-capy-install`**: an ok run keeps what an earlier ok install certified for the same checkout, project, HEAD and Bun. The verdict's `required` list becomes the union, pins count as planning-only only when both runs skipped them, and `for` lists every workflow (`for=ship,document-release`). The installer says so in one line. A failing run, a new HEAD or another Bun still replaces the verdict, and the review-voice profile still never narrows a fuller one. + +### Tests +- `test/gstack-capy-install.test.ts`: `--for ship` then `--for document-release` leaves one ok verdict that admits both workflows through `verdictProblem`; a `--check --for ship` keeps it; after a new commit, `--for document-release` replaces it and ship is refused again. It failed before this change. + +## [1.91.303.0] - 2026-10-29 + +**`gstack-pr-shots` no longer edits your `.gitignore` in the middle of /ship, so the pregate stays fresh and the ship receipt gets written.** When a repository tracks files under `.gstack/` (`pregate.json`, `ship-policy.json`), git does not read `.gstack/` as ignored, and the browse daemon that `gstack-pr-shots` starts appended `.gstack/` to the tracked `.gitignore`. The tree changed after pregate and the receipt writer refused with PREGATE_STALE (#3560, seen on gstack-evals#13). + +### Fixed +- **`browse/src/config.ts`**: the browse state dir still writes its own `.gstack/.gitignore` (`*`), and when git does not already ignore `.gstack/` the line goes to the repository's `info/exclude` (resolved with `git rev-parse --git-path`, so linked worktrees share it). The project's `.gitignore` is never written. Outside a git repository nothing is written beyond the state dir. + +### Tests +- `test/pr-shots.test.ts`: a repository that tracks `.gstack/pregate.json` runs `gstack-pr-shots` with a browse stub that runs the daemon's real state-dir setup; the `gstack-wtree` fingerprint and the `.gitignore` are unchanged afterwards. It failed before this change. +- `browse/test/config.test.ts`: the cases that pinned the `.gitignore` append now pin `info/exclude`: written once, no duplicate, a missing trailing newline added, `info/` created when absent, a warning in `browse-server.log` when it cannot be written, and nothing written outside a repository. + +## [1.91.302.0] - 2026-10-29 + +**Three flaky gate cases from the 2026-10-28 daily flaky pass are repaired: shared-libs-review-index-flags, ship-exploratory-late-input (with ship-exploratory-plan-checks, which failed the same way), and the real-Chromium cookie-auth positive case.** Each red was diagnosed from its captured run before the fix, and each repair has a free test that fails without it. + +### Fixed +- **Shared-code reuse no longer lets a review rewrite the index flags it checks.** In train gate 40619718717, the reviewer ran `git update-index --really-refresh` while gathering evidence. That cleared the assume-unchanged flag the checker reads, so the trial's prerequisites went stale. Step 1 of the /review and /ship shared-code reuse section now says evidence gathering is read-only and names that command. Nearby wording was tightened so the section keeps its exact size. +- **The /ship exploratory-QA cases follow Step 9's review plan again.** Since #3527, Step 9 runs `gstack-review-plan --base main`. The fixture refused that command (paid screen 40707142223, both ship-exploratory-plan-checks trials) and never said that the plan's adversarial-native and outside-voice lines belong to Step 11, which is outside the fixture's Steps 8–9 excerpt. In gate 40664336141, ship-exploratory-late-input reported pass while listing that Step 11 pass as remaining work. Once the plan ran, it dispatched the state-machines specialist, and review-army tells the parent to write the merge-base sha into that specialist's diff command. The fixture refused `git diff `, which failed both ship-exploratory-unavailable trials in this PR's screen 40737741454. +- **The real-browser cookie-auth checks no longer time out under load.** The positive case and the rejection cases that return early now use the product's default 15 s limit instead of a 400 ms test-only value. That value timed out twice on Windows and once on a Ubicloud VM. + +### Gate integrity +- `test/helpers/qa-callers-fixture.ts`: the caller command interface now admits the `gstack-review-plan` forms ship Step 9 names (`--base main` with its `--pass`, `--last` and finding flags, or `--fix-check-prompt` with literal file arguments), only at the fixture's installed helper path. Pipes, other arguments, other binaries and calls without the fixture's runtime are refused, and `test/qa-caller-review-plan.test.ts` checks these as negative controls. The ship prompt names the Step 11 boundary. The interface also admits the fixture's own merge base (at least 7 hex characters) as a literal diff base, bare or in review-army's `DIFF_BASE= && git diff "$DIFF_BASE"` specialist form, and the prompt declares it. Any other sha, a shorter prefix, other Git options and composition are still refused. No threshold, trial count, case kind or timeout changed. +- `browse/test/cookie-auth-verification.test.ts`: GStack Minder approved dropping the shared `timeoutMs: 400`, which came from a84b0b5b6 (#2964) as a fixture value no product path uses (`--verify-auth` callers run under `MAX_TIMEOUT_MS` 15000). The identity mismatch, missing and ambiguous cases keep 400 ms because they resolve only at the deadline, and the 15 s default would exceed bun's 5 s test timeout. The explicit 100 ms, 30 ms, 60 s-cap and invalid-timeout tests are unchanged. +- New free tests: `test/qa-caller-review-plan.test.ts` (replays both 40707142223 captures and both 40737741454 ship-exploratory-unavailable captures, and runs the real plan for all four ship cases) and `test/shared-libs-index-flags-readonly.test.ts` (replays the captured refresh against the assume-unchanged fixture, with a read-only control). +- `scripts/lib/windows-curation.ts`: `test/shared-libs-index-flags-readonly.test.ts` is curated off the Windows subset. Its fixture starts the review through `bin/gstack-review-log`, a bash script, which windows-latest cannot launch (Windows Free Tests run 40727825859, shard 5). + ## [1.91.301.0] - 2026-10-29 **Four paid gate cases that went red on 2026-10-28 without a product defect now pass on the runs that failed them.** Each repair replays the failing run as a free test that fails without it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3ba07335..d2c84855a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,6 +205,7 @@ the new defaults. | Validate periodic cases on a branch in CI | `gh workflow run evals-periodic.yml --ref ` (periodic lane only) | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref ` | Free CI runners | Pushed branch, `gh` with workflow rights | +| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:/ --out-dir

` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 | | Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history | | See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case ` | Free | `gh` with repo read access | | Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage | diff --git a/README.md b/README.md index df7265150..222b96893 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ installs and passes the conformance tests but has no certification run yet; | GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` | | Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` | | Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` | -| OpenClaw | experimental | `--host openclaw` (one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the `gstack` skill | advisory, not blocked | — | `./setup --host openclaw` | +| OpenClaw | experimental | `--host openclaw` (five task skills and one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the matching task skill or the `gstack` router | advisory, not blocked | — | `./setup --host openclaw` | | Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` | | GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — | diff --git a/TODOS.md b/TODOS.md index d2be7ab44..7d7aa202d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,11 @@ ## NEXT PRIORITY +### P1: review-depth receipt follow-ups (filed 2026-10-28, from the #3567 /ship plan audit) + +- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1. +- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1. + ### P3: `/codex --effort` follow-ups (filed 2026-10-11, from the #3146 autoplan review) - **Show the effort that ran.** Step 0.5 prints `CODEX_EFFORT: (source: /codex --effort)` once, but the result footers do not repeat the effort, so it scrolls away. Add an `Effort:` line to the review, challenge and consult result footers. Blocked on skeleton bytes in `codex/SKILL.md` (cap 60,200). **Effort:** S. **Priority:** P3. @@ -783,15 +788,15 @@ Each item was deferred with a reason during the `/claude-api prompt-audit` clean format errors, so it stays on today's request), the default workflow judge (landed, but one provider refusal on corpus item f03 under the new prompt, matched by one under the old prompt) and the cookie workflow judge (GSTA-91: - held on one new false pass on a07, zero new-request format errors, so it stays - on today's request; today's request itself false-passes a07 in `old-b` and on + held on one new false pass on a07, zero new-request format errors; the hold was + released on GStack Minder's 2026-10-29 ruling and it now sends the schema; today's request itself false-passes a07 in `old-b` and on both valid `old-a` samples, and a08 in `old-a`, so the prose step targets those). Reuse `test/fixtures/judge-calibration` and `scripts/judge-calibration.ts`. Priority P3. Effort M. - **Uncalibrated workflow-judge configurations** — build a corpus and calibrate schema transport for the frontier configuration (review/SKILL.md workflow); it stays on today's request. The cookie configuration (setup-browser-cookies/SKILL.md workflow) has a corpus and a held comparison-1 - (GSTA-91), so it also stays on today's request. After any transport change, + (GSTA-91); it moved to the schema on GStack Minder's 2026-10-29 release ruling. After any transport change, re-run the review and cookie workflow judges. Priority P3. Effort M. - **Claude model auto-detection for the skill overlay** — when no `claude_overlay_model` is set, detect the Claude Code model (`ANTHROPIC_MODEL`, diff --git a/VERSION b/VERSION index bd148f120..5b93c3984 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.301.0 +1.91.320.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 1c74abbf3..00e18aa32 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.301.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.320.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index f1e1a3f96..875ea1068 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -26,7 +26,7 @@ line yourself; gstack never writes the plan. 1. `next` prints one `ATTEMPT: phase=

voice=native|outside prompt= result=` 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=`); render it with `gstack-gate render

/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: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, 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: 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 ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). ### 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 --from /`, which never redispatches a bound voice. -- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; - relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), - then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`). +- 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 ` after `answer` (`--stack ` for a stack; `capy/skill/gstack/captain.md`). ### Commands diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index b2ff2b001..9b3ed3765 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -23,7 +23,7 @@ line yourself; gstack never writes the plan. 1. `next` prints one `ATTEMPT: phase=

voice=native|outside prompt= result=` 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=`); render it with `gstack-gate render

/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: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, 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: 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 ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). ### 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 --from /`, which never redispatches a bound voice. -- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; - relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), - then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`). +- 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 ` after `answer` (`--stack ` for a stack; `capy/skill/gstack/captain.md`). ### Commands diff --git a/benchmark/SKILL.md b/benchmark/SKILL.md index 56c76e733..ee09bbba4 100644 --- a/benchmark/SKILL.md +++ b/benchmark/SKILL.md @@ -412,7 +412,7 @@ TOP 10 SLOWEST RESOURCES RECOMMENDATIONS: - vendor.chunk.js: code-split — 320KB on initial load - analytics.js: load async/defer — blocks rendering for 250ms -- hero-image.webp: add width/height (CLS), lazy-load +- hero-image.webp: add width/height (CLS) ``` Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache). diff --git a/benchmark/SKILL.md.tmpl b/benchmark/SKILL.md.tmpl index 12a805eec..0a7448a29 100644 --- a/benchmark/SKILL.md.tmpl +++ b/benchmark/SKILL.md.tmpl @@ -201,7 +201,7 @@ TOP 10 SLOWEST RESOURCES RECOMMENDATIONS: - vendor.chunk.js: code-split — 320KB on initial load - analytics.js: load async/defer — blocks rendering for 250ms -- hero-image.webp: add width/height (CLS), lazy-load +- hero-image.webp: add width/height (CLS) ``` Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache). diff --git a/bin/gstack-approvals b/bin/gstack-approvals index da97642a9..3d3d5a9ef 100755 --- a/bin/gstack-approvals +++ b/bin/gstack-approvals @@ -5,7 +5,7 @@ * gstack-approvals add --ask --words --by --scope * [--pending ] [--outcome approved|rejected|pending|standing] * [--date YYYY-MM-DD] [--source ] [--branch ] [--json] - * gstack-approvals import --autoplan [--by ] [--branch ] [--json] + * gstack-approvals import --autoplan [--by ] [--branch | --stack ] [--json] * gstack-approvals render [--no-heading] [--branch ] * gstack-approvals list [--json] [--branch ] * gstack-approvals sync [--branch ] @@ -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 --words --by --scope [--pending ] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source ] - import --autoplan [--by ] + import --autoplan [--by ] [--stack ] + --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; bools: Set; error?: string } { const flags: Record = {}; @@ -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, json: boolean): number { + if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan , 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 \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 & 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'); diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 0b0a1c45b..c42062867 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -19,13 +19,14 @@ * gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran * gstack-autoplan close --out --phase

--deferred "" --by > close a phase accepted-with-findings * gstack-autoplan budget --out

[--spend-cap ] [--max-rounds ] --by > the owner's journaled cap change + * gstack-autoplan check --out --attempt --result --model | --unavailable the delta check before a carry * gstack-autoplan checkpoints --from 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= run=; 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 /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 (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 --phase

--voice native|outside --result | --record [--attest ] --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json] @@ -84,6 +89,9 @@ DRIVER: ${driverFile()} resume --out

[--from ] [--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 // and prints CHECKPOINT: on stderr) + check --out --attempt --result --model [--same-family ""] | --unavailable "" + 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 list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current close --out --phase

--deferred "" --by > 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 and --result --model , or --unavailable ""'); + 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 { 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 { 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`); diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 62975197f..93b51b75c 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -407,15 +407,32 @@ verdict_kept() { [ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)" } +# An ok run keeps what an earlier ok install certified for this checkout, project, HEAD and Bun, +# so one machine serving /ship and its docs step stays admitted for both (#3561): required is +# the union, pins count as planning-only only when both runs skipped them, and for= lists every +# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict. record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded) + local v_for="${FOR:-}" v_req v_plan e_for id if verdict_kept; then say "kept the install verdict for=$(gstack_install_verdict_field "$VERDICT" for) at $VERDICT (it covers more than $FOR; the trailer below is this check's)" return 0 fi if [ "$CHECK_ONLY" -eq 1 ] && [ -n "$REC_BUN" ]; then set -- "$1" "$2" "$REC_BUN" "$REC_VER" "$REC_RULE" "$REC_BUN_INSTALL" else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi - gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "${FOR:-}" "$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)" \ - "$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \ + v_req=$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1) + v_plan=$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0) + if [ "$2" = 1 ] && [ "$PROFILE" -eq 0 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ + && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && [ "$(gstack_install_verdict_field "$VERDICT" checkout)" = "$ROOT_REAL" ] \ + && [ "$(gstack_install_verdict_field "$VERDICT" bun_path)" = "$3" ] && e_for=$(gstack_install_verdict_field "$VERDICT" for) \ + && ! gstack_doctor_install_profile "$e_for"; then + for id in $(gstack_install_verdict_field "$VERDICT" required); do + case " $v_req " in *" $id "*) ;; *) v_req="$v_req $id" ;; esac + done + [ "$(gstack_install_verdict_field "$VERDICT" pins_planning_only)" = true ] || v_plan=0 + case ",$e_for," in *",$v_for,"*) v_for="$e_for" ;; *) v_for="${e_for:+$e_for${v_for:+,}}$v_for" ;; esac + [ "$v_for" = "$e_for" ] || [ -z "$e_for" ] || say "the install verdict at $VERDICT keeps for=$e_for and adds ${FOR:-$REQUIRE} (for=$v_for)" + fi + gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "$v_for" "$v_req" "$v_plan" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \ "$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" \ || say "could not write the install verdict $VERDICT" } diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index 4296bdec8..3df25b12e 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -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 -> ` | `skipped ()` +# 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 -> ` | `skipped ()` # | `none (current)` | `disabled ()`. # --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 </dev/null) UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$? diff --git a/bin/gstack-decision-trail b/bin/gstack-decision-trail index 297c6aee4..750b00da8 100755 --- a/bin/gstack-decision-trail +++ b/bin/gstack-decision-trail @@ -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 ` 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 ] gstack-decision-trail start @@ -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); diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 2fa10b41d..3ec81cf09 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -2,6 +2,9 @@ # gstack-diff-scope — categorize what changed in the diff against a base branch # Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ... # Or: gstack-diff-scope main → prints SCOPE_*=... lines +# Or: gstack-diff-scope --head → the committed range ... only +# (no working tree, no untracked files): what a receipt writer and the +# merge queue compute for one commit, from any checkout (GSTA-147). # # Output contract (#2526 — all-false must be distinguishable from "we could # not look" and from "nothing matched"): @@ -31,6 +34,8 @@ _default_base() { echo "main" } BASE="${1:-$(_default_base)}" +HEAD_REF="" +if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi FRONTEND=false BACKEND=false @@ -79,6 +84,21 @@ _collect() { [ -n "$f" ] && FILES_LIST+=("$f") done } +if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then + if ! git rev-parse --verify -q "${HEAD_REF}^{commit}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_head" + echo "# --head '${HEAD_REF}' is not a commit in this repository" + exit 2 + fi + if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_merge_base" + echo "# '${BASE}' and '${HEAD_REF}' share no merge base" + exit 2 + fi + _collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null) +else # Committed diff vs base (merge-base form; two-dot fallback when no merge base). _collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true) # Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo @@ -87,6 +107,7 @@ _collect < <(git diff -z HEAD --name-only 2>/dev/null || true) # Untracked files: a brand-new component/migration/test is exactly what a # reviewer should see, and /ship commits it in Step 15 regardless. _collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true) +fi if [ "${#FILES_LIST[@]}" -eq 0 ]; then _print_flags diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 221e5c993..f3c867766 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -286,6 +286,8 @@ gstack_result_anchor() { QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;; QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;; + QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;; + QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;; QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;; QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;; QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;; @@ -306,6 +308,8 @@ gstack_result_anchor() { REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;; + REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;; + RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; @@ -328,6 +332,13 @@ gstack_result_anchor() { PROJECT_FLOOR_UNMET) printf '%s' 'project-floor-unmet' ;; PROJECT_NODE_PIN_UNMET) printf '%s' 'project-node-pin-unmet' ;; UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;; + BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; + BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; + BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;; + BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;; + BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;; + BUDGET_CHECK_SKIPPED) printf '%s' 'budget-check-skipped' ;; + HANDOFF_PIN_MOVED) printf '%s' 'handoff-pin-moved' ;; *) return 1 ;; esac } diff --git a/bin/gstack-harness b/bin/gstack-harness index 5bb446904..5bd39f320 100755 --- a/bin/gstack-harness +++ b/bin/gstack-harness @@ -8,7 +8,10 @@ * * Usage: * gstack-harness detect [--json] - * gstack-harness register [--skills router|all] [--skills-dir

] [--context ] [--print] [--yes] [--no-registry] + * gstack-harness register [--skills router|hybrid|all] [--skills-dir ] [--context ] [--print] [--yes] [--no-registry] + * --context writes gstack's marked routing block into that file only + * (opt-in: setup, upgrades and refresh never add one, and a block you + * delete stays deleted); it prints the file and its scope before writing * gstack-harness unregister [--remote] * gstack-harness status [--json] [--no-check] * gstack-harness refresh @@ -20,7 +23,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { resolveStateRoot } from '../lib/state-root'; import { - HarnessError, TARGETS, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister, + HarnessError, TARGETS, contextScope, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister, type RegisterResult, type SkillsMode, type TargetName, } from '../lib/harness-register'; import { registerQm, removeQmPack } from '../lib/harness-qm'; @@ -38,9 +41,10 @@ const self = path.join(checkout, 'bin', 'gstack-harness'); function receiptLines(r: RegisterResult): string[] { const t = TARGETS[r.target]; + const how = { router: 'reachable through one router skill', hybrid: 'registered, the task skills listed directly and the rest through one router skill', all: 'registered' }[r.mode]; return [ - 'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')', - ...(r.router ? [' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : '')] : []), + 'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + how + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')', + ...(r.router ? [' router: ' + r.router + ' (register with --skills all to list every skill natively)'] : []), ...r.kept.map(k => ' kept your edit: ' + k), ' ledger: ' + r.ledger, ' remove: ' + self + ' unregister ' + r.id, @@ -83,6 +87,7 @@ async function main(): Promise { console.log('gstack-harness: ' + t.displayName + ': unverified (' + (seen.checked ? seen.checked + ' did not list gstack' : t.name + ' is not on PATH') + '). Install gstack for Claude Code (./setup), or paste the block from: ' + self + ' register generic --print'); return 3; } + if (context) (json ? console.error : console.log)('gstack-harness: context block: ' + path.resolve(context) + ' (' + contextScope(t, context, env, home) + ' scope)'); const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry }); if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') })); else receipt(r); diff --git a/bin/gstack-harness-ledger.sh b/bin/gstack-harness-ledger.sh index 85a6d6c47..210e85326 100644 --- a/bin/gstack-harness-ledger.sh +++ b/bin/gstack-harness-ledger.sh @@ -13,8 +13,11 @@ # uninstall of the state root and the checkout. Only paths below the recorded # destination, reached through no symlink, are touched. A context block is cut # only when its file holds exactly one whole-line begin/end marker pair whose -# lines hash to the ledger's block hash; otherwise the file is left as it is -# (the file is removed only when nothing else is left in it). Folders gstack +# lines hash to the ledger's block hash; otherwise the file is left as it is. +# Its blockpad row says what gstack added before the block, so the cut restores +# the file byte for byte: the blank line (and newline) it appended are removed +# too, and a file gstack created is removed when nothing else is left in it +# (ledgers without the row: when only whitespace is left). Folders gstack # created are removed only when empty. A QM pack is never touched here: detaching it from # the org is `gstack-harness unregister qm --remote`. @@ -33,7 +36,7 @@ _gstack_harness_owned() { } _gstack_harness_cut_block() { - local file="$1" want="$2" span s e tmp + local file="$1" want="$2" pad="${3:-}" span s e tmp local begin='' end='' { [ -f "$file" ] && [ ! -L "$file" ]; } || return 0 span="$(awk -v B="$begin" -v E="$end" '$0 == B { nb++; s = NR } $0 == E { ne++; e = NR } END { if (nb == 1 && ne == 1 && e > s) print s, e; else if (nb || ne) print "bad" }' "$file")" @@ -44,13 +47,18 @@ _gstack_harness_cut_block() { return 0 fi tmp="$file.gstack-cut.$$" - awk -v s="$s" -v e="$e" 'NR < s || NR > e' "$file" > "$tmp" || { rm -f "$tmp"; return 1; } - if [ -z "$(tr -d '[:space:]' < "$tmp")" ]; then rm -f "$file"; else cat "$tmp" > "$file"; fi + awk -v s="$s" -v e="$e" -v pad="$pad" '{ line[NR] = $0 } + END { + from = s; if ((pad == "1" || pad == "2") && s > 1 && line[s - 1] == "") from = s - 1 + n = 0; for (i = 1; i <= NR; i++) if (i < from || i > e) out[++n] = line[i] + for (i = 1; i <= n; i++) { printf "%s", out[i]; if (i < n || !(pad == "2" && e == NR)) printf "\n" } + }' "$file" > "$tmp" || { rm -f "$tmp"; return 1; } + if { [ "$pad" = new ] && [ ! -s "$tmp" ]; } || { [ -z "$pad" ] && [ -z "$(tr -d '[:space:]' < "$tmp")" ]; }; then rm -f "$file"; else cat "$tmp" > "$file"; fi rm -f "$tmp" } gstack_harness_unregister_ledger() { - local ledger="$1" stamp kind a b target="" dest="" pack="" + local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath="" : "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}" [ -f "$ledger" ] || return 0 stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)" @@ -61,7 +69,8 @@ gstack_harness_unregister_ledger() { { [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b" else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;; - block) _gstack_harness_cut_block "$b" "$a" ;; + blockpad) pad="$a"; padpath="$b" ;; + block) if [ "$padpath" = "$b" ]; then _gstack_harness_cut_block "$b" "$a" "$pad"; else _gstack_harness_cut_block "$b" "$a"; fi ;; esac done < "$ledger" while IFS=$'\t' read -r kind a b; do diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index 5f553376d..73ad365cf 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -8,7 +8,9 @@ # one checkout and one project (the caller's git toplevel, or none): # /install-verdicts//.json # written atomically by bin/gstack-capy-install (install and --check) with -# schema_version 2, checkout, head, for, required[], pins_planning_only, ok, +# schema_version 2, checkout, head, for (comma-separated: an ok run at the same +# HEAD and Bun keeps the workflows an earlier ok install certified, #3561), +# required[] (their union), pins_planning_only, ok, # trailer, min_revision, launch_path, time, project, pin (the exact Bun pin), # bun_path (realpath), bun_version, bun_rule (gstack_bun_resolve), bun_install, # node_path (realpath), node_version, node_rule (gstack_node_resolve; empty diff --git a/bin/gstack-modules b/bin/gstack-modules new file mode 100755 index 000000000..fa3f98d8d --- /dev/null +++ b/bin/gstack-modules @@ -0,0 +1,8 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +import { modulesMain } from '../scripts/modules-cli.ts'; + +void modulesMain(process.argv.slice(2)).then(result => { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exitCode = result.code; +}); diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 8613150ed..a7995aa41 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -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'; @@ -57,7 +57,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do validate | init the working-tree ${PREGATE_CONFIG_PATH} sealed [--range ] [--remote ] [--base ] the sealed-output guard (docs/pregate.md#sealed) over the commits lacks, or the range: - run it before any push or PR creation in a repo with a \`sealed\` config; exit 1 refuses (SEALED_OUTPUT_REFUSED) + run it before any push or PR creation in a repo with a \`sealed\` config; exit 1 refuses (SEALED_OUTPUT_REFUSED); + it never pushes: policy= + commit names where the policy was read regate --against --bundle [--require ] [--pr ] [--predecessor-pr | --predecessor-ref ] a fresh tree receipt, each lane's reuse verdict, migration collisions (base branch and open PRs), then REGATE: needed|not-needed (docs/pregate.md#regate); \`gstack-pregate --against \` is the same verb; exit 1 = needed @@ -244,13 +245,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; } diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 252dc4389..80ebddeed 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -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" }); diff --git a/bin/gstack-review-log b/bin/gstack-review-log index b60832497..0a7037120 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -3,6 +3,10 @@ # Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}' # # Before reading a diff: gstack-review-log --start review +# Under a /ship token cap (docs/ship-budget.md): --start SKILL --reservation ID +# with the RESERVATION id gstack-ship-budget check printed for this dispatch +# batch; refuses (exit 1, BUDGET_CHECK_SKIPPED) without an unspent one, and +# --finish settles it. Without a cap no marker exists and nothing changes. # Fix-range repeat (#3339): gstack-review-log --start review --fix-range # prints the token, then FIX_BASE=; refuses (exit 1) unless the latest # pass completed with fixes on unchanged content and its commit is an @@ -32,15 +36,18 @@ export GSTACK_REVIEW_DIR="$GSTACK_HOME/projects/$SLUG" export GSTACK_REVIEW_REPO="$(git rev-parse --show-toplevel 2>/dev/null || true)" export GSTACK_REVIEW_BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse HEAD 2>/dev/null || true)" export GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" +export GSTACK_SHIP_BUDGET_LIB="$SCRIPT_DIR/../lib/ship-budget.ts" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) if command -v cygpath >/dev/null 2>&1; then GSTACK_REVIEW_LIB="$(cygpath -m "$GSTACK_REVIEW_LIB")" + GSTACK_SHIP_BUDGET_LIB="$(cygpath -m "$GSTACK_SHIP_BUDGET_LIB")" GSTACK_REVIEW_DIR="$(cygpath -m "$GSTACK_REVIEW_DIR")" fi ;; esac export GSTACK_REVIEW_LOG="$GSTACK_REVIEW_DIR/$BRANCH-reviews.jsonl" +export GSTACK_SHIP_BUDGET_MARKER="$GSTACK_REVIEW_DIR/$BRANCH-ship-budget.json" # Compute binding fields (best-effort; empty outside a git repo). COMMIT_FULL=$(git rev-parse HEAD 2>/dev/null || true) @@ -77,10 +84,28 @@ fi SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive) export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND" if [ "$INPUT" = --start ]; then - case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac - GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' + START_SKILL="${2:-}" START_FIX="" START_RESERVATION="" + if [ "$#" -ge 2 ]; then shift 2; else shift "$#"; fi + while [ "$#" -gt 0 ]; do + case "$1" in + --fix-range) START_FIX=--fix-range; shift ;; + --reservation) [ "$#" -ge 2 ] || { echo 'Usage: gstack-review-log --start SKILL [--fix-range] [--reservation ID]' >&2; exit 2; }; START_RESERVATION="$2"; shift 2 ;; + *) echo 'Usage: gstack-review-log --start SKILL [--fix-range] [--reservation ID]' >&2; exit 2 ;; + esac + done + if [ ! -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then + GSTACK_REVIEW_SKILL="$START_SKILL" GSTACK_REVIEW_FIX_RANGE="$START_FIX" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' + const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB); + try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); } + catch (e) { console.error(e.message); process.exit(1); } + ' + exit $? + fi + GSTACK_REVIEW_SKILL="$START_SKILL" GSTACK_REVIEW_FIX_RANGE="$START_FIX" GSTACK_REVIEW_RESERVATION="$START_RESERVATION" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB); - try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); } + const { admitReviewStart } = await import(process.env.GSTACK_SHIP_BUDGET_LIB); + const capture = () => captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range"); + try { console.log(admitReviewStart(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_RESERVATION || undefined, capture)); } catch (e) { console.error(e.message); process.exit(1); } ' exit $? @@ -93,7 +118,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then exit $? fi usage() { - echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2 + echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] [--reservation ID] | --check-shared-libs TOKEN < finding.json' >&2 echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2 exit 2 } @@ -126,6 +151,16 @@ try { echo "$STAMPED" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" +# A finished dispatch settles the budget reservation its start consumed (its +# completion receipt); stdout stays unchanged and a failure never undoes the row. +if [ -n "$TOKEN" ] && [ -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then + GSTACK_REVIEW_TOKEN="$TOKEN" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' + const { settleReviewFinish } = await import(process.env.GSTACK_SHIP_BUDGET_LIB); + try { const id = settleReviewFinish(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_TOKEN); if (id) console.error("review-log: settled budget reservation " + id); } + catch (e) { console.error("review-log: budget reservation not settled: " + e.message); } + ' || true +fi + # gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). Plan B1: # an unattended session never enqueues, whatever the inherited sync mode. if [ "$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)" = "unattended" ]; then diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index a5424c25a..06f3e3976 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -4,20 +4,25 @@ * * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] - * [--reviewed-numbers ] [--no-tiers] [--json] + * [--reviewed-numbers ] [--depth-floor standard|full] [--no-tiers] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * - * Reads the diff against the merge base with (committed, working tree and - * untracked), runs gstack-diff-scope and, unless --gate-tagged is given, - * gstack-gate-diff; prints REVIEW_CLASS:, REVIEW_PLAN:, one DISPATCH: line per - * reviewer and one SKIP: line per reviewer left out. Logic: lib/review-depth.ts. + * Reads the diff against the merge base with (committed, tracked uncommitted + * edits and untracked files, through lib/review-collect.ts collectLive), runs + * gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints + * REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and + * result codes), GATE_SCAN:, REVIEW_PLAN:, one DEPTH_ERROR: per input that failed + * the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per + * reviewer and one SKIP: per reviewer left out. A plan that fails closed to full + * exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts. */ -import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { runScriptSync } from '../lib/script-invocation'; -import { planReview, reviewPlanLine, scanNumbers, type History } from '../lib/review-depth'; +import { collectLive } from '../lib/review-collect'; +import { diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, type Depth, type History } from '../lib/review-depth'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth) @@ -25,19 +30,23 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --base the base branch (merge base with HEAD is the diff base); required --pass full review passes already run in this invocation (default 0) --last none|full|fix-check the step that just finished (default none: plan pass 1) - --fixed-blocking the finished step fixed a blocking finding + --fixed-blocking the finished step fixed a blocking finding (raises light to standard) --fixed-below the finished step fixed findings below the floor --blocking-open a blocking finding is still open in fix scope --touched-gate a fix touched a gate-class path - --critical-found a specialist reported a CRITICAL finding (Red Team on code class) + --critical-found a specialist reported a CRITICAL finding (Red Team on code class; raises light to standard) --adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it) --gate-tagged gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable) --gated a,b specialists the hit-rate stats tag GATE_CANDIDATE --force a,b|all specialists forced by prompt flags (--all-specialists is all) --reviewed-numbers the numeric-claim fingerprint the outside voice already reviewed + --depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run --no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) - --json the plan as JSON + --json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude) --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files + +Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). A full plan +from an error exits 0; exit 1 means no plan could be built (git could not list the diff). ${exitTableHelp()}`; const args = process.argv.slice(2); @@ -65,34 +74,17 @@ function main(): number { if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; } const pass = Number(flag('--pass') ?? '0'); if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; } + const floor = flag('--depth-floor') as Depth | undefined; + if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; } const root = git(['rev-parse', '--show-toplevel'])?.trim(); if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; } process.chdir(root); - const ref = git(['rev-parse', '--verify', '-q', `origin/${base}`]) !== null ? `origin/${base}` : base; - const mergeBase = git(['merge-base', ref, 'HEAD'])?.trim(); - if (!mergeBase) { process.stderr.write(`gstack-review-plan: no merge base with ${ref} (fetch the base first)\n`); return EXIT.fail; } - const nameStatusRaw = git(['diff', '--name-status', '--no-renames', mergeBase]); - const untrackedRaw = git(['ls-files', '--others', '--exclude-standard']); - const numstatRaw = git(['diff', '--numstat', mergeBase]); - if (nameStatusRaw === null || untrackedRaw === null || numstatRaw === null) { process.stderr.write('gstack-review-plan: git could not list the diff; no plan is safer than a plan for an empty diff\n'); return EXIT.fail; } - const scratch = (p: string) => p.startsWith('.gstack/tmp/'); - const nameStatus = nameStatusRaw.split('\n').filter(Boolean).map(l => l.split('\t')).filter(c => !scratch(c[1] ?? '')); - const untracked = untrackedRaw.split('\n').filter(Boolean).filter(p => !scratch(p)); - const paths = [...new Set([...nameStatus.map(c => c[1]!).filter(Boolean), ...untracked])].sort(); - const deleted = nameStatus.filter(c => c[0] === 'D').map(c => c[1]!); - const stampOnly = paths.filter(p => p.endsWith('package.json') && !untracked.includes(p)).filter(p => { - const changed = (git(['diff', '-U0', '--no-color', mergeBase, '--', p]) ?? '').split('\n').filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)); - return changed.length > 0 && changed.every(l => /^[+-]\s*"version"\s*:/.test(l)); - }); - const diffLines = numstatRaw.split('\n').reduce((n, l) => { const [a, d] = l.split('\t'); return n + (Number(a) || 0) + (Number(d) || 0); }, 0) - + untracked.reduce((n, p) => { try { return n + fs.readFileSync(p, 'utf8').split('\n').length; } catch { return n; } }, 0); - const added: Record = {}; - let current = ''; - for (const line of (git(['diff', '-U0', '--no-color', mergeBase]) ?? '').split('\n')) { - if (line.startsWith('+++ b/')) { current = line.slice(6); continue; } - if (line.startsWith('+') && !line.startsWith('+++') && current) (added[current] ??= []).push(line.slice(1)); + const collected = collectLive(root, base); + if (collected.errors.length) { + for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`); + return EXIT.fail; } - for (const p of untracked) { try { added[p] = fs.readFileSync(p, 'utf8').split('\n'); } catch { /* unreadable: no claims */ } } + const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown'; const binDir = path.dirname(Bun.fileURLToPath(import.meta.url)); const scopeRun = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base], { encoding: 'utf8', timeout: 60_000 }); const scope: Record = {}; @@ -109,14 +101,25 @@ function main(): number { const force = flag('--force') === 'all' ? 'all' as const : list('--force'); const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 }); const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light'; + const { added, diffLines, paths, deleted, stampOnly, perPath, registry } = collected; const plan = planReview({ - paths, deleted, stampOnly, root, scope, gateTagged, diffLines, numbers: scanNumbers(added), force, gated: list('--gated'), + ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') }, }); - if (has('--json')) { process.stdout.write(JSON.stringify({ ...plan, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); return EXIT.ok; } - const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`]; - for (const d of plan.dispatches) out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}`); + const gateScan = { tagged: gateTagged, head }; + if (has('--json')) { + const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; + process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); + return EXIT.ok; + } + const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`]; + for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`); + for (const d of plan.dispatches) { + const key = d.name.split(' ')[0]; + out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`); + if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`); + } for (const s of plan.skipped) out.push(`SKIP: ${s}`); out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above stay full)`); process.stdout.write(out.join('\n') + '\n'); diff --git a/bin/gstack-ship-budget b/bin/gstack-ship-budget new file mode 100755 index 000000000..ecb3277dc --- /dev/null +++ b/bin/gstack-ship-budget @@ -0,0 +1,50 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-ship-budget — the /ship token budget and fresh-context handoff + * (docs/ship-budget.md). An admission cap: no step or dispatch starts unless + * used + open reservations + unknown + its projection + the tail and stop + * reserve fit under the cap. + * + * start --record [--token-cap ] [--meter capy|claude|estimate] [--reading ] [--prior-reading ] + * [--session |--nonce ] [--reserve ] [--delegate auto|never] [--calibration ] [--ledger ] + * check --run |--record --step --next [,...]> [--reading ] [--prior-reading ] + * settle --run|--record --reservation [--unknown] release --run|--record --reservation + * handoff --run|--record --from --to --base origin/ [--reviewed-tree ] [--trail --trail-run ] + * [--sub-cap ] [--reading ] [--child-session ] + * launch --in (Claude Code: background headless child with explicit permissions) + * verify-handoff --in [--reading ] [--session ] + * return --run --to [--changed ] [--status complete|refused|incomplete] [--reading ] + * reconcile --run|--record (--return |--handoff ) [--reading ] + * resume --record |--run [--reading ] [--meter ] [--session ] [--token-cap --approved-by --words ] + * [--rebuild-from-record] + * close --run|--record [--reading ] [--approved-by --words ] + * + * Exit: 0 continue/delegate · 3 stop (the next step does not start) · 2 usage + * or validation (stderr). Logic: lib/ship-budget.ts, lib/ship-budget-cli.ts, + * lib/ship-handoff.ts. + */ +import { BudgetError } from '../lib/ship-budget'; +import { cmdCheck, cmdClose, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli'; +import { cmdHandoff, cmdLaunch, cmdReconcile, cmdReturn, cmdVerifyHandoff } from '../lib/ship-handoff'; + +const [command, ...rest] = process.argv.slice(2); +const ctx = { env: process.env, cwd: process.cwd() }; +const commands: Record Outcome> = { + start: () => cmdStart(rest, ctx), check: () => cmdCheck(rest, ctx), settle: () => cmdSettle(rest, ctx), release: () => cmdSettle(rest, ctx, true), + handoff: () => cmdHandoff(rest, ctx), launch: () => cmdLaunch(rest, ctx), 'verify-handoff': () => cmdVerifyHandoff(rest, ctx), return: () => cmdReturn(rest, ctx), + reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), +}; +if (!command || command === '--help' || command === '-h' || !commands[command]) { + const text = (await Bun.file(import.meta.path).text()).split('\n').slice(2, 26).map(l => l.replace(/^ \* ?/, '')).join('\n'); + (command && command !== '--help' && command !== '-h' ? console.error : console.log)(text); + process.exit(command && command !== '--help' && command !== '-h' ? 2 : 0); +} +try { + const out = commands[command]!(); + if (out.stdout.length) console.log(out.stdout.join('\n')); + process.exit(out.exit); +} catch (e) { + if (!(e instanceof BudgetError)) throw e; + if (e.exit === 3) console.log(e.message); else console.error(e.message); + process.exit(e.exit); +} diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index d7d3940a0..1b24fd1af 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -36,16 +36,18 @@ import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { versionAtRef } from '../lib/restamp'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; -import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; +import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; import { approvalForReceipt } from '../lib/approval-citations'; import { CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS, - bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, + bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; +import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; +import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt) @@ -61,10 +63,12 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block census --repo --since [--cohort ] the P7 adoption number over merged PRs (deduplicated by run and thread) carry --pr [--repo ] | --body [--base ] [--head ] [--pregate ] [--dry-run] [--out ] - carry the receipt to HEAD when only a base merge, tests or release metadata changed - since its head (product patch-id unchanged): rewrites the receipt with a \`carry\` record, - posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete; - RECEIPT_CARRY_PRODUCT_CHANGED / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3); + carry the receipt to HEAD when only a base merge, tests, release metadata or registered + outputs the --pregate regen check proved (recreated them) changed since its head (product patch-id + unchanged), or after a rebase onto a tree the receipt covers with the recorded product + patch-id: rewrites the receipt with a \`carry\` record, posts the body (gh pr edit) and + prints GSTACK_RESULT: skill=ship status=complete; RECEIPT_CARRY_PRODUCT_CHANGED / + RECEIPT_CARRY_TREE_MISMATCH / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3); a receipt that records a pregate needs --pregate current for HEAD (RECEIPT_CARRY_PREGATE_STALE); needs git >= 2.39 (RECEIPT_GIT_TOO_OLD) docs-caps [--base ] /ship Step 14.5: one DOCS_CAP: / ok|over line per docs.caps entry of the @@ -80,6 +84,11 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate ({passes, blocking_found, deferred[, noted, fixed, scope]}: gstack-review-scope summary --out) --docs ({status: updated|current|blocked, scope[, exception: {accepted_by, date, via, reason}]}: Step 14.5's outcome; blocked without an exception is DOCS_AUDIT_BLOCKED, and with --body the body must carry the exception line verbatim) + --review-loop-exception ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a + stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the + "Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and + review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an + unreadable input records full). --approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s .gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING) --out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the @@ -134,10 +143,30 @@ function receiptVersion(repoRoot: string, head: string, base: string): string | return 'none'; } +/** + * What a later carry across rewritten history compares: the head's tree and its + * product patch-id against the base. Both are evidence, never a gate: a head + * outside a checkout (or a base that does not resolve) records neither, and + * such a receipt then carries only along its own history. + */ +function reviewedProduct(repoRoot: string, base: string, head: string, pregateFile: string | undefined): { head_tree?: string; product_patch_id?: string } { + const tree = git(['rev-parse', '--verify', '--quiet', `${head}^{tree}`], repoRoot); + if (!tree) return {}; + const baseRef = `origin/${base.replace(/^origin\//, '')}`; + const id = productPatchAgainstBase(repoRoot, productRules(repoRoot, base, verifiedOutputs(repoRoot, base, pregateFile)), baseRef, head); + return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree }; +} + function cmdWrite(rest: string[], json: boolean): number { const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd(); - const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot); + const checkedOut = git(['rev-parse', 'HEAD'], repoRoot); + const head = flag(rest, '--head') ?? checkedOut; if (!head) { process.stderr.write('gstack-ship-receipt write: --head is required outside a git repository\n'); return EXIT.usage; } + const headSha = git(['rev-parse', '--verify', '--quiet', `${head}^{commit}`], repoRoot); + if (checkedOut && headSha !== checkedOut) { + process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${checkedOut.slice(0, 12)}; the receipt binds the reviewed tree, so check out ${head.slice(0, 12)} and write there; nothing written`) + '\n'); + return EXIT.fail; + } const receiptFile = flag(rest, '--receipt'); const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null; let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND; @@ -165,6 +194,19 @@ function cmdWrite(rest: string[], json: boolean): number { return EXIT.refused; } } + let loopException: ReturnType | undefined; + if (flag(rest, '--review-loop-exception') !== undefined) { + const refuse = (why: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'REVIEW_LOOP_EXCEPTION_INVALID', `${why}; nothing written`) + '\n'); return EXIT.fail; }; + try { loopException = parseReviewLoopException(jsonArg(flag(rest, '--review-loop-exception'))); } catch (e: any) { return refuse(`--review-loop-exception: ${e.message}`); } + if (!loopException.ok) return refuse(loopException.message); + let loop: unknown; try { loop = jsonArg(flag(rest, '--review-loop')); } catch {} + if (!parseReviewLoop(loop)?.fix_checks) return refuse('--review-loop-exception needs --review-loop with fix_checks >= 1: the stop it accepts follows a fix-check'); + const fc = loopException.exception.fix_check.head; + if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`fix_check.head ${fc.slice(0, 12)} is not ${head.slice(0, 12)} or an ancestor of it`); + const line = reviewLoopExceptionLine(loopException.exception); + if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`); + } + const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head); const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') }); if (approval.kind === 'invalid') { for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n'); @@ -194,7 +236,8 @@ function cmdWrite(rest: string[], json: boolean): number { session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, - review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), + review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile), + gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, }; } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; } @@ -367,13 +410,73 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } }; } +/** + * Generated outputs the carry may set aside as metadata: the paths the pregate + * passed for HEAD proved (its regen check deleted each touched output and the + * entry's command recreated it byte-identically), when that check ran + * origin/'s `.gstack/generated.json` at the commit this carry reads and the + * path is an output of an entry with a `command` there. A proven name with glob + * syntax is product too: the carry uses these paths as pathspec globs. + */ +function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] { + if (!pregateFile) return []; + const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen'); + if (!regen || regen.status !== 'pass' || !regen.proven?.length) return []; + const load = loadRegistryFromBase(repoRoot, base); + if (isRegistryUnavailable(load) || load.errors.length || regen.inputs?.registry !== load.source.label) return []; + const owned = load.registry.entries.filter(e => e.command).flatMap(e => e.outputs); + return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p)); +} + +interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] } +function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules { + const load = loadPolicyFromBase(repoRoot, base); + const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy; + const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs }; + return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...outputs], templates: policy?.stamp_patterns ?? [] }; +} + +/** + * The product pathspec between two commits: every path but tests and release + * metadata, where a metadata-listed path whose change from `from` to `to` is + * not a stamp stays product (every glob that matches it leaves the exclusion). + */ +function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { + const changedRaw = git(['diff', '--name-only', from, to], repoRoot); + if (changedRaw === undefined) return undefined; + const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; + const versionAt = (ref: string): string | null => { + const file = blobAt(ref, 'VERSION'); + if (file !== null) return file.trim() || null; + try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; } + }; + const oldVersion = versionAt(from); + const newVersion = versionAt(to); + const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) })); + const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); + const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path); + const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p))); + return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] }; +} + +/** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */ +function productPatchAgainstBase(repoRoot: string, rules: ProductRules, baseRef: string, commit: string): string | { error: string } { + const mergeBase = git(['merge-base', baseRef, commit], repoRoot); + if (!mergeBase) return { error: `cannot resolve the merge base of ${baseRef} and ${commit.slice(0, 12)}` }; + const spec = productSpec(repoRoot, rules, mergeBase, commit); + if (!spec) return { error: `git diff ${mergeBase.slice(0, 12)} ${commit.slice(0, 12)} failed` }; + return patchId(repoRoot, mergeBase, commit, spec.product); +} + /** * The receipt follows HEAD when the reviewed product is unchanged * (docs/unattended.md#the-terminal-line; harness lane E row 3). Four Captains * reran a full /ship on identical product code after a base merge or a * test-only fix; the carry compares the product diff against the base at the * receipt's head and at HEAD by patch-id, classifies every changed path, and - * records the range-diff as evidence in the receipt it rewrites. + * records the range-diff as evidence in the receipt it rewrites. After a rebase + * or force-push it carries only onto a tree the receipt covers whose product + * patch-id against the current base is the recorded one (planTreeCarry). */ function cmdCarry(rest: string[], json: boolean): number { const bodyFile = flag(rest, '--body'); @@ -384,7 +487,7 @@ function cmdCarry(rest: string[], json: boolean): number { if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; } const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase(); if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; } - const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { + const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n'); process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`); return EXIT.refused; @@ -425,66 +528,79 @@ function cmdCarry(rest: string[], json: boolean): number { } const oldGit = gitVersionBelow(repoRoot, CARRY_MIN_GIT); if (oldGit) return refused('RECEIPT_GIT_TOO_OLD', `git ${oldGit} cannot run patch-id --verbatim; the carry needs git ${CARRY_MIN_GIT} or later`); - const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); - if (ancestor.status !== 0) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed); merge the base in next time, and run /ship now`); // The carry binds HEAD alone: pending edits are not in the commits it compares, and a parent that stops at CARRY: carried would report them shipped. const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); if (status.status !== 0) return refused('RECEIPT_CARRY_REFUSED', 'git status failed; the carry needs a readable working tree'); const dirty = status.stdout.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^.* -> /, '')); if (dirty.length) return refused('RECEIPT_CARRY_REFUSED', `the working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` +${dirty.length - 5} more` : ''}); commit them and carry at the new head, or run /ship`); + const fromKnown = git(['cat-file', '-e', `${from}^{commit}`], repoRoot) !== undefined; + const ancestor = fromKnown && spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).status === 0; + const fromTree = fromKnown ? git(['rev-parse', `${from}^{tree}`], repoRoot) : undefined; + const covered = [...(receipt.head_tree ? [receipt.head_tree] : fromTree ? [fromTree] : []), ...(receipt.carry?.trees ?? [])]; + if (!ancestor && !covered.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed), and the receipt records no tree to compare; merge the base in next time, and run /ship now`); const base = flag(rest, '--base') ?? receipt.base ?? resolveBaseBranch(repoRoot); const baseRef = `origin/${base.replace(/^origin\//, '')}`; - const fromBase = git(['merge-base', baseRef, from], repoRoot); const headBase = git(['merge-base', baseRef, head], repoRoot); - if (!fromBase || !headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); - const load = loadPolicyFromBase(repoRoot, base); - const policy = isUnavailable(load) ? null : load.policy; - const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])] }; - const changedRaw = git(['diff', '--name-only', from, head], repoRoot); - if (changedRaw === undefined) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); - // Every metadata-listed path is read at both heads: only a stamp keeps it out of the product patch. - const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; - const versionAt = (ref: string): string | null => { - const file = blobAt(ref, 'VERSION'); - if (file !== null) return file.trim() || null; - try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; } - }; - const oldVersion = versionAt(from); - const newVersion = versionAt(head); - const allMetadataGlobs = [...CARRY_METADATA_GLOBS, ...extra.metadata]; - const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra, matchesAny(allMetadataGlobs, p) ? { before: blobAt(from, p), after: blobAt(head, p), oldVersion, newVersion, templates: policy?.stamp_patterns ?? [] } : undefined) })); - // Pathspecs by class: tests and metadata are excluded from the product patch; the test patch is the tests alone. - // A metadata-listed path whose change was not a stamp stays in the product patch: every glob that matches it is dropped from the exclusion. - const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); - const demoted = changed.filter(c => c.class === 'product' && matchesAny(allMetadataGlobs, c.path)).map(c => c.path); - const metadataGlobs = allMetadataGlobs.filter(g => !demoted.some(p => matchesAny([g], p))); - const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)]; - const testSpec = [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)]; - const productFrom = patchId(repoRoot, fromBase, from, productSpec); - const productHead = patchId(repoRoot, headBase, head, productSpec); - const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : ''); - const testFrom = asId(patchId(repoRoot, fromBase, from, testSpec)); - const testHead = asId(patchId(repoRoot, headBase, head, testSpec)); - 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. - const changedProduct: string[] = []; - if (productFrom !== productHead) { - 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; - const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, extra); - if (cls !== 'product') continue; - if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p); + if (!headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); + const rules = productRules(repoRoot, base, verifiedOutputs(repoRoot, base, flag(rest, '--pregate'))); + const headTree = git(['rev-parse', `${head}^{tree}`], repoRoot); + 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; + if (!ancestor) { + // Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded. + const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' }; + if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the reviewed product patch-id is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`); + plan = planTreeCarry({ from, head, headTree, coveredTrees: covered, productPatchId: { recorded, head: headPatch } }); + } else { + const fromBase = git(['merge-base', baseRef, from], repoRoot); + if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); + const spec = productSpec(repoRoot, rules, from, head); + if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); + const { changed } = spec; + const productFrom = patchId(repoRoot, fromBase, from, spec.product); + const productHead = patchId(repoRoot, headBase, head, spec.product); + const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : ''); + 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}`); + // 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/ 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; } - if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); + const current = retarget ? headPatch : productHead; + const changedProduct: string[] = []; + 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; + const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); + if (cls !== 'product') continue; + if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p); + } + if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); + } + const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); + // 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: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) }); } - const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); - // 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')}`; - const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff }); 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, // so a carried receipt never claims the old tree's checks (docs/ship-policy.md#receipt-carry). @@ -499,7 +615,21 @@ function cmdCarry(rest: string[], json: boolean): number { } else if (receipt.pregate && !rest.includes('--dry-run')) { return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate `); } - const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry); + let depthField: Partial = {}; + const isAncestor = (commit: string) => !ancestor || spawnSync('git', ['merge-base', '--is-ancestor', commit, head], { cwd: repoRoot, timeout: 30_000 }).status === 0; + const problems = exceptionProblems(body, receipt, isAncestor); + if (problems.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's owner exception does not stand: ${problems.join('; ')}; run /ship on ${head.slice(0, 12)}`); + if (receipt.review_depth || receipt.gstack_version) { + if (!receipt.review_depth) return refused('RECEIPT_CARRY_REFUSED', `the receipt records gstack_version but no review_depth, so the queue would refuse it; run /ship on ${head.slice(0, 12)}`); + const checkedOut = git(['rev-parse', 'HEAD'], repoRoot)?.toLowerCase(); + if (checkedOut !== head) return refused('RECEIPT_CARRY_REFUSED', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${(checkedOut ?? 'none').slice(0, 12)}; the gate scan reads the checkout, so check out ${head.slice(0, 12)} and carry there`); + const now = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head); + 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) } }; + } + // 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); diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index ba6a51254..c9b0d71f1 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -25,8 +25,14 @@ // the caller must handle), exit 2 on bad args / unresolvable base / // broken version source. // -// write --version [--version-path

] [--regen-digest] [--changelog

] +// write --version [--version-path

] [--regen-digest] [--changelog

] [--manifest-form npm|full] // Validates the 4-digit pattern, writes VERSION + package.json.version. +// --manifest-form full: the manifest and its npm lockfiles carry the +// version as written instead of the npm-valid translation (default +// npm). For repos whose declared convention is a four-part manifest +// (gbrain); gstack-restamp passes it only when the policy lists the +// manifest as a stamp file and the base's copy holds four parts. No +// effect when the version path is itself a JSON manifest. // --changelog

(opt-in): re-head the branch's `## [Unreleased]` // entry in

as `## [] - ` at the top, with // lib/changelog-check's reheadChangelog — the same function @@ -455,6 +461,8 @@ function cmdWrite(args: string[], cwd: string): void { if (!VERSION_RE.test(version!)) { fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH[.MICRO]. Aborting.`, 2); } + const manifestForm = argVal(args, "--manifest-form") ?? "npm"; + if (manifestForm !== "npm" && manifestForm !== "full") fail("write: --manifest-form needs npm|full", 2); const src = versionSource(cwd, argVal(args, "--version-path")); if (src.outcome === "broken") failBroken(src, "write"); if (src.outcome !== "valid") { @@ -528,7 +536,8 @@ function cmdWrite(args: string[], cwd: string): void { // 3-digit translation — npm rejects a fourth component, so mirroring the // raw 4-digit form breaks `npm ci` in any repo npm actually manages. // VERSION keeps the full 4-digit form; it stays the source of truth. - const manifestV = npmVersion(version!); + // --manifest-form full keeps a repo's declared four-part manifest as is. + const manifestV = manifestForm === "full" ? version! : npmVersion(version!); if (hasPkg) { let pkgWritten = false; try { diff --git a/browse/src/config.ts b/browse/src/config.ts index 89cd21418..17f29dd82 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -100,6 +100,21 @@ function isIgnoredByGit(projectDir: string, relPath: string): boolean { } } +function gitExcludePath(projectDir: string): string | null { + try { + const proc = Bun.spawnSync(['git', 'rev-parse', '--git-path', 'info/exclude'], { + windowsHide: true, + cwd: projectDir, stdout: 'pipe', stderr: 'pipe', + timeout: 2_000, + }); + if (proc.exitCode !== 0) return null; + const rel = proc.stdout.toString().trim(); + return rel ? path.resolve(projectDir, rel) : null; + } catch { + return null; + } +} + /** * Create the .gstack/ state directory if it doesn't exist. * Throws with a clear message on permission errors. @@ -119,40 +134,36 @@ export function ensureStateDir(config: BrowseConfig): void { // Load-bearing guard: a self-contained ignore INSIDE the state dir so its // contents can NEVER be `git add`-ed, regardless of the project's own - // .gitignore (which may be absent, or the append below may silently fail). - // The state dir holds session-state.json (live cookies + localStorage/ - // sessionStorage tokens) and browse-network.log / browse-audit.jsonl - // (captured request headers can carry bearer tokens). Written unconditionally, - // synchronously, before return — the project-.gitignore dance below is now - // redundant safety, kept so `.gstack/` still reads as ignored in git status. + // ignore rules. The state dir holds session-state.json (live cookies + + // localStorage/sessionStorage tokens) and browse-network.log / + // browse-audit.jsonl (captured request headers can carry bearer tokens). + // Written unconditionally, synchronously, before return. try { fs.writeFileSync(path.join(config.stateDir, '.gitignore'), '*\n'); } catch { - // Best-effort; the project-.gitignore path below is the fallback. + // Best-effort; the repository's info/exclude below is the fallback. } - // Ensure .gstack/ is in the project's .gitignore - // First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore) + // Ensure git reads .gstack/ as ignored through the repository's own + // info/exclude, never the project's .gitignore: that file is tracked, and + // editing it mid-/ship changes the tree after pregate (PREGATE_STALE, #3560). if (isIgnoredByGit(config.projectDir, '.gstack/')) return; - - const gitignorePath = path.join(config.projectDir, '.gitignore'); + const excludePath = gitExcludePath(config.projectDir); + if (!excludePath) return; try { - const content = fs.readFileSync(gitignorePath, 'utf-8'); + const content = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf-8') : ''; if (!content.match(/^\.gstack\/?$/m)) { - const separator = content.endsWith('\n') ? '' : '\n'; - fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`); + const separator = content === '' || content.endsWith('\n') ? '' : '\n'; + fs.mkdirSync(path.dirname(excludePath), { recursive: true }); + fs.appendFileSync(excludePath, `${separator}.gstack/\n`); } } catch (err: any) { - if (err.code !== 'ENOENT') { - // Write warning to server log (visible even in daemon mode) - const logPath = path.join(config.stateDir, 'browse-server.log'); - try { - fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update .gitignore at ${gitignorePath}: ${err.message}\n`); - } catch { - // stateDir write failed too — nothing more we can do - } + const logPath = path.join(config.stateDir, 'browse-server.log'); + try { + fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update ${excludePath}: ${err.message}\n`); + } catch { + // stateDir write failed too — nothing more we can do } - // ENOENT (no .gitignore) — skip silently } } diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 53a2081a6..f93a802cf 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -95,68 +95,72 @@ describe('config', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('adds .gstack/ to .gitignore if not present', () => { - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n'); + // A repository that tracks files under .gstack/ (pregate.json, ship-policy.json) is the case + // where git does not already read .gstack/ as ignored, so the ignore line must land somewhere. + const trackedGstackRepo = (prefix: string) => { + const { spawnSync } = require('child_process'); + const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + const git = (...args: string[]) => spawnSync('git', args, { cwd: tmpDir, timeout: 30_000, encoding: 'utf-8' }); + git('init', '-q'); + fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/tmp/\n'); + fs.mkdirSync(path.join(tmpDir, '.gstack')); + fs.writeFileSync(path.join(tmpDir, '.gstack', 'pregate.json'), '{}\n'); + git('add', '-A'); + git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'); + const exclude = path.resolve(tmpDir, git('rev-parse', '--git-path', 'info/exclude').stdout.trim()); + return { tmpDir, git, exclude }; + }; + + test('records .gstack/ in .git/info/exclude and leaves the tracked .gitignore unchanged (#3560)', () => { + const { tmpDir, git, exclude } = trackedGstackRepo('browse-exclude-test-'); + fs.writeFileSync(exclude, '# local\n'); const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); - const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(content).toContain('.gstack/'); - expect(content).toBe('node_modules/\n.gstack/\n'); + expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n'); + expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n.gstack/\n'); + expect(git('status', '--porcelain').stdout).toBe(''); fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('does not duplicate .gstack/ in .gitignore', () => { - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/\n'); - const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); + test('does not duplicate .gstack/ in info/exclude, adds a missing trailing newline, and creates info/ when absent', () => { + const dup = trackedGstackRepo('browse-exclude-dup-'); + fs.writeFileSync(dup.exclude, 'build/'); + const config = resolveConfig({ BROWSE_STATE_FILE: path.join(dup.tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); - const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(content).toBe('node_modules/\n.gstack/\n'); - fs.rmSync(tmpDir, { recursive: true, force: true }); + ensureStateDir(config); + expect(fs.readFileSync(dup.exclude, 'utf-8')).toBe('build/\n.gstack/\n'); + fs.rmSync(dup.tmpDir, { recursive: true, force: true }); + + const bare = trackedGstackRepo('browse-exclude-noinfo-'); + fs.rmSync(path.dirname(bare.exclude), { recursive: true, force: true }); + ensureStateDir(resolveConfig({ BROWSE_STATE_FILE: path.join(bare.tmpDir, '.gstack', 'browse.json') })); + expect(fs.readFileSync(bare.exclude, 'utf-8')).toBe('.gstack/\n'); + fs.rmSync(bare.tmpDir, { recursive: true, force: true }); }); - test('handles .gitignore without trailing newline', () => { - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules'); - const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); - ensureStateDir(config); - const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(content).toBe('node_modules\n.gstack/\n'); - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('logs warning to browse-server.log on non-ENOENT gitignore error', () => { + test('logs warning to browse-server.log when info/exclude cannot be written', () => { if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers) - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - // Create a read-only .gitignore (no .gstack/ entry → would try to append) - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n'); - fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o444); + const { tmpDir, exclude } = trackedGstackRepo('browse-exclude-ro-'); + fs.writeFileSync(exclude, '# local\n'); + fs.chmodSync(exclude, 0o444); const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); // should not throw - // Verify warning was written to server log - const logPath = path.join(config.stateDir, 'browse-server.log'); - expect(fs.existsSync(logPath)).toBe(true); - const logContent = fs.readFileSync(logPath, 'utf-8'); - expect(logContent).toContain('Warning: could not update .gitignore'); - // .gitignore should remain unchanged - const gitignoreContent = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(gitignoreContent).toBe('node_modules/\n'); - // Cleanup - fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o644); + const logContent = fs.readFileSync(path.join(config.stateDir, 'browse-server.log'), 'utf-8'); + expect(logContent).toContain(`Warning: could not update ${exclude}`); + expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n'); + expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n'); + fs.chmodSync(exclude, 0o644); fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('skips if no .gitignore exists', () => { + test('outside a git repository nothing is written beyond the state dir', () => { const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n'); const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); - expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + expect(fs.readdirSync(tmpDir).sort()).toEqual(['.gitignore', '.gstack']); fs.rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/browse/test/cookie-auth-verification.test.ts b/browse/test/cookie-auth-verification.test.ts index 742c1c2c7..4e073cfcc 100644 --- a/browse/test/cookie-auth-verification.test.ts +++ b/browse/test/cookie-auth-verification.test.ts @@ -423,6 +423,7 @@ describe('cookie auth configuration and bounded operations', () => { }); describe('cookie authentication and storage with isolated Chromium profiles', () => { + const browserOptions = { identitySelector: options.identitySelector, expectedIdentity: options.expectedIdentity }; let browser: Browser; let context: BrowserContext; let page: Page; @@ -486,13 +487,13 @@ describe('cookie authentication and storage with isolated Chromium profiles', () test('verifies a reloaded protected page only with an exact visible identity and synthetic session', async () => { await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]); await page.goto(`${origin}/protected`); - expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: true, reason: 'verified', status: 200 }); + expect(await verifyCookieAuthentication(page, browserOptions, origin)).toEqual({ verified: true, reason: 'verified', status: 200 }); }); for (const path of ['/normalized', '/one-visible', '/hidden-child', '/delayed']) { test(`accepts one normalized visible identity at ${path}`, async () => { await page.goto(`${origin}${path}`); - const result = await verifyCookieAuthentication(page, { ...options, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin); + const result = await verifyCookieAuthentication(page, { ...browserOptions, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin); expect(result).toEqual({ verified: true, reason: 'verified', status: 200 }); expect(JSON.stringify(result)).not.toContain(identity); }); @@ -506,7 +507,8 @@ describe('cookie authentication and storage with isolated Chromium profiles', () test(`rejects ${path} with a safe ${reason} result`, async () => { await page.goto(`${origin}${path === '/login-hop' ? '/identity' : path}`); if (path === '/login-hop') await page.evaluate(() => history.replaceState({}, '', '/login-hop')); - const result = await verifyCookieAuthentication(page, options, origin); + // identity_mismatch/missing/ambiguous keep the 400 ms options (GStack Minder, 2026-10-28): they resolve only when the deadline fires (browse/src/cookie-auth-verification.ts:105-120), so the 15 s default would exceed bun's 5 s test timeout. + const result = await verifyCookieAuthentication(page, reason.startsWith('identity_') ? options : browserOptions, origin); expect(result.verified).toBe(false); expect(result.reason).toBe(reason); expect(JSON.stringify(result)).not.toMatch(/Synthetic Account|127\.0\.0\.1/); @@ -517,13 +519,13 @@ describe('cookie authentication and storage with isolated Chromium profiles', () await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]); await page.goto(`${origin}/protected`); await context.clearCookies(); - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('login_redirect'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('login_redirect'); }); for (const status of [401, 403, 500, 503]) { test(`rejects HTTP ${status} even with a positive identity`, async () => { await page.goto(`${origin}/status/${status}`); - expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: false, reason: 'http_error', status }); + expect(await verifyCookieAuthentication(page, browserOptions, origin)).toEqual({ verified: false, reason: 'http_error', status }); }); } @@ -531,7 +533,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () for (const destination of ['/redirect-cross', '/roundtrip']) { await page.goto(`${origin}/identity`); await page.evaluate(path => history.replaceState({}, '', path), destination); - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_changed'); } }); @@ -553,7 +555,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () return value; }); try { - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_changed'); } finally { locator.mockRestore(); } @@ -563,7 +565,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () await page.goto(`${origin}/identity`); await page.evaluate(() => history.replaceState({}, '', '/slow')); try { - expect(await verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, origin)) + expect(await verifyCookieAuthentication(page, { ...browserOptions, timeoutMs: 100 }, origin)) .toEqual({ verified: false, reason: 'timeout' }); } finally { releaseSlowResponse?.(); @@ -572,11 +574,11 @@ describe('cookie authentication and storage with isolated Chromium profiles', () test('sanitizes invalid selector errors and handles a closed target', async () => { await page.goto(`${origin}/identity`); - const result = await verifyCookieAuthentication(page, { ...options, identitySelector: '[synthetic-token' }, origin); + const result = await verifyCookieAuthentication(page, { ...browserOptions, identitySelector: '[synthetic-token' }, origin); expect(result.reason).toBe('verification_failed'); expect(JSON.stringify(result)).not.toContain('synthetic-token'); await page.close(); - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_closed'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_closed'); expect(await clearCookieTargetStorage(page, origin).then(() => null, error => error)).toMatchObject({ code: 'target_closed' }); }); @@ -586,7 +588,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () localStorage.setItem('local', 'original'); sessionStorage.setItem('session', 'original'); }); - expect((await verifyCookieAuthentication(page, options, origin)).verified).toBe(true); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).verified).toBe(true); expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])) .toEqual(['original', 'original']); }); diff --git a/captain/templates/CREW.md b/captain/templates/CREW.md index 64a01fc96..e6bdf2806 100644 --- a/captain/templates/CREW.md +++ b/captain/templates/CREW.md @@ -90,8 +90,9 @@ Homes are paths in the gstack checkout the lane checked at session start. 14. **Don't merge `BASE` into a READY PR.** Only on the controller's word after a real-code train conflict (merge, never rebase, free suite, READY again) or as part of the stamp - commit. Home: `docs/captain.md` (Merge trains), `docs/ship-policy.md` (Run a merge - train). + commit. A merge whose product diff is unchanged carries the /ship receipt; a rebase + onto a new tree costs a full /ship. Home: `docs/captain.md` (Merge trains), + `docs/ship-policy.md` (Run a merge train, Carrying a receipt). 15. **CHANGELOG entry under `## [Unreleased]` before READY;** version files stay at `BASE`'s version; the stamp touches only `STAMP_FILES`. Home: `docs/ship-policy.md` diff --git a/captain/templates/brief.md b/captain/templates/brief.md index d185bc0bf..1f6b0decb 100644 --- a/captain/templates/brief.md +++ b/captain/templates/brief.md @@ -14,7 +14,7 @@ Spend cap: 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 --plan sized to this brief (a one-file fix: --no-ui --no-dx --max-rounds 1 --estimate-usd @@ -25,7 +25,8 @@ Spend cap: (gstack-autoplan answer --out --gate-rev --reply --rec-hash --by agent: --owner-reply ""), 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 --by "". + reports APPROVED (a later gate that asks nothing new prints CARRIED: and needs no second + ask). Then gstack-approvals import --autoplan --by "" (--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 diff --git a/captain/templates/plan-ask.md b/captain/templates/plan-ask.md index fb298017f..854998ffc 100644 --- a/captain/templates/plan-ask.md +++ b/captain/templates/plan-ask.md @@ -7,6 +7,9 @@ shape from an autoplan run; write it by hand for any other plan. ```text Decision needed on : . Reply **rec** to take every recommendation. +## ELI5 +<5-10 plain sentences: what changes, for whom, and what it costs> + ## Before → after diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index d0e793395..bb629e753 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -42,6 +42,8 @@ Run directories and scratch on Capy live under `~/.capy/work/` (`--out ~/. Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render

/gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent: --owner-reply ""`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out --from ` restores the newest valid checkpoint. +Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` from `tools.thread.get({ thread_id: "this" })` (one execute program returning only that number) into `gstack-ship-budget --reading ` and obeys `verdict=` (`docs/ship-budget.md`). + ## Plan, approve, ship Every Capy thread that uses gstack follows these for new work, under a Captain or on its own. The rule in full is `docs/captain.md`, "Plan before build"; the lane side is section 3 of captain.md in this folder. diff --git a/capy/skill/gstack/captain.md b/capy/skill/gstack/captain.md index d403cbe55..057573665 100644 --- a/capy/skill/gstack/captain.md +++ b/capy/skill/gstack/captain.md @@ -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 --by Garry # after gstack-autoplan answer; idempotent +gstack-approvals import --autoplan --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 ``` -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 --gate-rev --reply rec --rec-hash gstack-approvals import --autoplan --by ``` +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 ()` 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`): @@ -142,3 +148,8 @@ Every PR you send READY on, and the READY report itself, has: The READY report attaches the key image (Capy message attachment), so the Captain can show it inline. /ship does all of this when `GSTACK_CAPY=1` (its `capy-pr` section). Outside Capy, the PR body is unchanged. + +A token cap in a brief goes to /ship as `--token-cap `. When /ship stops on it (`STOP: +reason=budget_exhausted`, `status=incomplete`), the lane reports the BUDGET and RECOVER lines and asks the +owner; it never raises the cap itself. The owner's answer runs the RECOVER line with +`--approved-by --words ""`, and `gstack-approvals add` records the same words. diff --git a/docs/OPENCLAW.md b/docs/OPENCLAW.md index 325c8d72d..d376b873a 100644 --- a/docs/OPENCLAW.md +++ b/docs/OPENCLAW.md @@ -181,9 +181,10 @@ The agent should: 4. Verify with a test spawn **Native skills or ACP.** `./setup --host openclaw` (or `./setup` run inside an -OpenClaw session) gives OpenClaw's own agent gstack's skills: one `gstack` -router skill in `$OPENCLAW_STATE_DIR/skills`, rendered with OpenClaw's tool -names and pointing at the installed gstack, so the agent runs gstack's +OpenClaw session) gives OpenClaw's own agent gstack's skills: the five task +skills (review, investigate, qa, ship, plan-eng-review) listed directly and one +`gstack` router skill for the rest in `$OPENCLAW_STATE_DIR/skills`, rendered +with OpenClaw's tool names and pointing at the installed gstack, so the agent runs gstack's workflows and helpers itself. Use ACP when the work should happen in a Claude Code session with the full install (hooks, Claude tool names); use native skills when the OpenClaw agent should do it. Both can be installed together. diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 4b0e01ca4..6c99766c2 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -92,6 +92,7 @@ gstack/ │ ├── test/ # Integration tests │ └── dist/ # Compiled binary ├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts +├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, pinned modules and named loadouts; read by scripts/modules.ts, rendered by bin/gstack-modules (docs/loadouts.md) ├── extension/ # Chrome extension (side panel + activity feed + CSS inspector) ├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/) │ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index 31fefa201..13cb03aa8 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -783,6 +783,14 @@ once, plus cleanup. Each still has 120 seconds of model work. Its 17 workflow judges own their deadline and abort signal, with five seconds for terminal recording inside a ten-second Bun grace; the other 11 retain their existing 120-second Bun timeout. Late responses cannot create records or cache passes. +When a judge's abort signal cancels its request, `callJudge` +(`test/helpers/llm-judge.ts`) prints one `llm-judge-aborted` JSON line to +stderr: elapsed time, wrapper `attempts` and `retryWaitsMs` (429 waits), +`httpRequests` and `httpStatuses` (so SDK retries show), `connectedMs`, +`firstEventMs` and `lastEventMs`, the open `block` type, `thinkingChars` and +`textChars`, `messageId`, `requestId` and the `reason` (at most 200 +characters). It holds counts and ids only, never content, and changes no +request, score or verdict. The ship documentation file reserves 4,920 seconds for four 600-second cases and eight 300-second fault cases, run once, plus cleanup; in CI each case runs as its diff --git a/docs/TEST_PORTFOLIO.md b/docs/TEST_PORTFOLIO.md index ef393a88b..78510a41e 100644 --- a/docs/TEST_PORTFOLIO.md +++ b/docs/TEST_PORTFOLIO.md @@ -60,7 +60,7 @@ depends on measured duration or an explicit `QUICK_CORE` entry, not this table. | CLI/API/webhook QA without browser setup | `qa-functional-fixture`, `qa-functional-evidence`, `qa-lazy-sections` | `skill-e2e-qa-functional`: CLI and webhook report sessions | Linux/macOS free; selected PR gate; Windows only where curated | | Report-only preserves local and remote authority | `qa-only-capability`, `qa-functional-observer`, `qa-functional-observer-atomic`, `qa-caller-authority` | Independent report-only sessions with synthetic owned endpoints/auth | Linux kernel observation; free callback controls plus selected PR gate | | Repair reproduces the defect, adds a failing regression and rechecks adjacent behavior | `qa-fix-loop-fixture`, `qa-functional-evidence` | `skill-e2e-qa-functional-fix`: CLI and webhook repair sessions | Free controls plus selected PR gate | -| Review and Ship actually explore | `qa-exploratory-callers`, `qa-caller-report-observer`, `qa-checkpoint-evidence` | `skill-e2e-qa-callers`: actual Review/Ship callers | Free captures plus selected PR gate | +| Review and Ship actually explore | `qa-exploratory-callers`, `qa-caller-report-observer`, `qa-caller-review-plan`, `qa-checkpoint-evidence` | `skill-e2e-qa-callers`: actual Review/Ship callers | Free captures plus selected PR gate | | Smoke expiry preserves required plan checks | `qa-deadline`, `qa-deadline-selection`, `qa-browser-deadline-evidence` | `ship-exploratory-plan-checks` | Free deadline/dispatch controls plus selected PR gate | | Late changes invalidate affected results | `qa-caller-freshness-order`, `qa-deadline-publication-observer`, `shared-libs-revalidation-prompt` | `ship-exploratory-late-input` and the existing late-input documentation handoff | Free stale-input controls plus selected PR gate | | Documentation completes before publication and respects protected files | `docsync-authority`, `docsync-atomic-writes`, `docsync-report-interface`, `docsync-lifecycle-interface` | `skill-e2e-ship-docsync`, `skill-e2e-docsync-spawned` | Free state/permission controls; registered gate/periodic scenarios retain their tiers | diff --git a/docs/captain.md b/docs/captain.md index b81988be2..e955acfb7 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -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 ` -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 --by ""` writes the -approval row (date, the ask, the owner's exact words, the lane that answered as relayer, +**The record.** `gstack-approvals import --autoplan --by ""` (with +`--stack ` 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). @@ -231,6 +234,14 @@ a push included. The sequence that works unattended on Capy: 10. After the slot call, the stamp commit keeps the pin's receipt; re-READY with the same receipt, no second /ship. +When the base moves under a reviewed branch, merge it in; never rebase. A merge that +leaves the product diff unchanged carries the receipt with `gstack-ship-receipt carry` +(pregate at HEAD, no review rerun), and a merge whose resolution changed product code +needs a fresh /ship. A rebase carries only onto a tree the receipt already covers with +the same product diff; any other rebase is a full /ship +([ship-policy.md](ship-policy.md#receipt-carry)). A controller never asks a lane to +rebase a reviewed branch. + ## Merge trains gstack's queue tool drives trains when the repository's `.gstack/ship-policy.json` carries diff --git a/docs/capy-install.md b/docs/capy-install.md index 1bed678f9..7de24710e 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -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

(m )`. The team-mode SessionStart hook +holder as `pid

(m )`. 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=

/m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp. A lease whose @@ -274,7 +283,16 @@ keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and honoured, the resolved Bun (`bun_path`, `bun_version`, `bun_rule`, and `bun_install` when this run downloaded it), the resolved Node when the project pins one (`node_path`, `node_version`, `node_rule`), the trailer, the remote - minimum, the launch PATH and the time. Admission refuses a workflow whose + minimum, the launch PATH and the time. An ok run (install or `--check`) at + the same checkout, project, HEAD and Bun as an earlier ok verdict keeps what + that verdict certified, so one machine stays admitted for /ship and its + `--for document-release` docs step (#3561): `required` becomes the union, + pins count as planning-only only when both runs skipped them, and the + verdict's `for` lists every workflow, comma-separated (`for=ship,document-release`; + the installer prints `the install verdict at keeps for= and adds + (for=)`, and the trailer still names this run's workflow). A + failing run, a new HEAD or another Bun replaces the verdict, and so does any + workflow install over a review-voice verdict. Admission refuses a workflow whose running Bun is not the one the verdict recorded ([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so do the pregate's lanes and guards, which also refuse an invalid verdict diff --git a/docs/designs/SKILL_EVAL_CONTRACT.md b/docs/designs/SKILL_EVAL_CONTRACT.md index 16091f53c..21573f1f2 100644 --- a/docs/designs/SKILL_EVAL_CONTRACT.md +++ b/docs/designs/SKILL_EVAL_CONTRACT.md @@ -69,3 +69,26 @@ Raw transcripts stay under `/runs/`. - **Trials are fixed before the run.** No trials are added after a result. One same-tree rerun of a red task is allowed and recorded as flaky; a second failure stands. - **Blinded adjudication.** A disputed answer goes to a fresh judge that sees the task and the answer, not the arm. + +## Notes for implementers (2026-10-28) + +Offered to gstack-evals' Lane A (interface v0.4). Nothing above changes; these are conventions gstack's task files +already assume, and one optional field. + +- **State root.** gstack skills keep state under one root (docs/state-root.md): `GSTACK_STATE_ROOT` wins, then + `GSTACK_HOME`. Set both to `/.gstack-home` for the agent, `setup` and `check`; `GSTACK_HOME` alone loses + to a `GSTACK_STATE_ROOT` inherited from the caller's shell, and the agent then writes the caller's real state. A + task's `seed.sh` creates `.gstack-home` and lists it in `.git/info/exclude`, so `git diff` checks ignore it and + `check` reads state there (/learn's `add` and `prune-stale`, /office-hours' design-doc and mockup checks). +- **`resumable`** (optional, in `verdict.json` and any error record): `true` when an INCOMPLETE run can be finished + with `--resume `, which runs only cells that never ran, under the same lock and trial count, so it adds no + trials after a result; `false` for a terminal stop, which needs a fix and a fresh run. A missing field reads as + `false`. +- **Lock.** A paid run needs `tasks.lock.json` committed beside the tasks file; `/write-skill`'s diet step 6 runs + `--lock` and commits it. +- **Sidecar.** Extensions live in `tasks.ext.json` beside the tasks file, keyed by task id, so this format stays + unchanged. gstack's free test checks only that each entry names a task in `tasks.json` and uses known keys. +- **Interactive tasks.** /office-hours' `must` patterns grade a headless run that ends where the skill stops for + input, as upstream skill-diet's runs do. On a host that answers questions from the sidecar's `user` replies, the final answer is + the session's last message, so the sidecar's `judge` line carries the behavior there. Open for Lane A: grade + `must` over the run's assistant messages on such hosts, or keep it on the final answer and rely on `judge`. diff --git a/docs/hosts.md b/docs/hosts.md index df863d1ce..8750cbbf1 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -24,6 +24,14 @@ neither does `./setup --local`. **What it writes.** Copies in the harness's own skills folder, never edits to `openclaw.json`, Hermes's `config.yaml` or any context file you did not name. +A context block is opt-in: `gstack-harness register openclaw|hermes|generic +--context ` writes the marked block into that file only, after printing +the file and its scope (`profile` inside the harness's own home, else +`project`). Setup, upgrades and refresh never add one, a block you delete stays +deleted, a second `--context` file keeps the first (the ledger keys blocks by +file), and uninstall restores each file byte for byte. It refuses `SOUL.md`, +and for Hermes a new file that would outrank rules Hermes loads now +(`.hermes.md`/`HERMES.md` load before `AGENTS.md`): `HARNESS_CONTEXT_REFUSED`. Every file goes into a ledger, `$GSTACK_STATE_ROOT/harness/.ledger`, with its sha256. Registering again changes only what changed; a folder or skill name gstack did not write stops it (`HARNESS_UNOWNED_ENTRY`); `gstack-uninstall` @@ -35,10 +43,24 @@ when it is the one gstack wrote; an edited block is left for you. `./setup `gstack-harness status` lists them and runs the harness's own skill list where it has one (`visible to harness: yes`). Errors: [troubleshooting](troubleshooting.md#agent-harnesses-gstack-harness). +**Registered, listed, used.** These are three separate facts, and this page +claims only the first two. *Registered*: gstack's files are in the harness's +skills folder and its ledger (`gstack-harness status`). *Listed*: the harness +puts the skill in the skills section of the model's prompt (`visible to +harness: yes`, from the harness's own skill list). *Used*: in a task that fits, +the model opens the matching skill and runs its first step. Use rates are +pending HARN-5's fixed panel (OpenClaw and Hermes chat mode, five trials per +cell). When they land they are measured under the eval adapter's +`GSTACK_SESSION_KIND=spawned` marker, which auto-decides gstack's questions, so +they say nothing yet about interactive sessions. Two levers aim at use: +each router line opens with the skill's own trigger sentence ("Use when asked +to ..."), and each Hermes description opens with a trigger list of at most 57 +characters, the part of a description Hermes's skills index shows. + | Harness | What gstack registers | Install | Verify | Remove | |---|---|---|---|---| -| OpenClaw | one `gstack` router skill (one description line in every request; its body lists every skill and its path) in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills/gstack`; `--skills all` registers every skill as its own top-level folder | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` | -| Hermes | every skill, as `gstack-` in one `gstack` category, in `${HERMES_HOME:-~/.hermes}/skills/gstack` (Hermes opens only skills it has indexed) | `./setup --host hermes`, or `./setup` inside Hermes | `hermes skills list` shows the `gstack-*` skills in category `gstack` | `gstack-harness unregister hermes` | +| OpenClaw | default `--skills hybrid`: `gstack-review`, `gstack-investigate`, `gstack-qa`, `gstack-ship` and `gstack-plan-eng-review` as top-level skills plus one `gstack` router skill whose body lists every other skill and its path, in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills`. With OpenClaw 2026.10.6's 19 eligible bundled skills, hybrid's skills section is 10,290 characters, full format, under its 18,000-character budget; `--skills all` (17,971, compact format, descriptions cut) lists every skill as its own folder, `--skills router` (6,701) only the router | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` and the five task skills (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` | +| Hermes | every skill, as `gstack-` in one `gstack` category, in `${HERMES_HOME:-~/.hermes}/skills/gstack`. Hermes's skills index lists only skills registered as folders, so a router would leave the rest out of the listing (`skill_view` still loads an unlisted skill by name, but nothing tells the model it exists) | `./setup --host hermes`, or `./setup` inside Hermes | `hermes skills list` shows the `gstack-*` skills in category `gstack` | `gstack-harness unregister hermes` | | dots (feder-cr) | the `gstack` router in `/home/dot/skills/gstack`, which the Dot lists every turn | `./setup` on the Dot's computer, or `gstack-harness register dots` | the Dot's Skills page, or `GET /skills` | `gstack-harness unregister dots` | | QM | **not available yet.** QM imports a skill pack by cloning the whole repository and caps it at 32 MiB and 5,000 files; gstack's repository is about 73 MiB, so `gstack-harness register qm --yes` stops before sending anything ([why](troubleshooting.md#harness-qm-pack-too-large)) | meanwhile, paste `gstack-harness register generic --print` into the agent's instructions | ask it for a gstack skill | delete the pasted text | | Muse Code, Grok Build | nothing: both read `~/.claude/skills`, so the Claude install (`./setup`) is what they see, with Claude tool names | `./setup` | `gstack-harness status` runs `muse skills list` / `grok inspect` and reports *discovered (Claude-rendered, unverified)* | `gstack-uninstall` | diff --git a/docs/loadouts.md b/docs/loadouts.md new file mode 100644 index 000000000..f065842d5 --- /dev/null +++ b/docs/loadouts.md @@ -0,0 +1,40 @@ +# Module loadouts + +gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Today every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when the shared skill eval (GSTA-2) measures a smaller one that matches `full` within its preregistered margin. + +Install-time model detection, which picks a loadout for the model you run, ships with the first loadout that is not `full`. Until then, loadouts are an eval tool: `bin/gstack-modules render` produces any loadout as a byte-exact tree with a lock file. + +## Terms + +| Term | Meaning | +|---|---| +| Module | One of four kinds. `skill:

` is a whole skill. `section:/` is one entry of `/sections/manifest.json`. `overlay:` is one `model-overlays/.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). | +| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. | +| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. | +| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. | + +## Commands + +```bash +bin/gstack-modules list [--json] [--pinned] # every module id, kind, source, pin, source bytes and sha256 +bin/gstack-modules validate # check modules/loadouts.json against the catalog +bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control +bin/gstack-modules render --host claude \ + --modules 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design' \ + --out-dir ~/arms/oh-lean +``` + +`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`. + +`bun run gen:skill-docs --loadout --out-dir ` and `--modules --out-dir ` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `` to each SKILL.md header. + +## Building an eval arm + +1. Render the control with `--loadout full` and each arm with `--modules full,-,…`. Every refusal names its code and fix ([troubleshooting](troubleshooting.md#module-loadouts-gstack-modules)). +2. Preregister the lock files' `spec_sha256` and per-file sha256 values with the eval. +3. Excluding a section removes its STOP pointer, its Section-index row and its file. The step becomes `> Not loaded in the loadout: skip . Do not offer it, and treat it as not run.` Skeleton text that belongs to the section, such as office-hours' Phase 3.5 offer, sits inside `{{IF_SECTION:}}…{{/IF_SECTION}}` in the template and goes with it. +4. The autoplan runner (`gstack-autoplan`) reads review methodology from the source tree, not from a rendered loadout, so autoplan-runner arms always measure `full` methodology. + +## Changing a loadout + +Add or edit an entry under `loadouts` and cite the GSTA-2 run (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`. diff --git a/docs/pregate.md b/docs/pregate.md index 824d6b8b2..673c5f118 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -25,7 +25,10 @@ passes. Standalone, `gstack-regen check` lists the commands and exits 3 (`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes` `incomplete`, never `pass`. `--policy-from head` (a PR that introduces or changes a pin file) is accepted only with `--allow-repo-commands` and the record -says `policy head (unreviewed)`. An unresolvable base exits 3 with +says `policy head (unreviewed)`. So a lane or allowlist entry a branch adds to +its own `pregate.json` counts only once it is on the base; `PREGATE_NO_LANE`'s +fix line says so when the branch edits the file +([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). An unresolvable base exits 3 with `POLICY_SOURCE_UNAVAILABLE`. Globs are repo-relative (no `..`, no absolute paths, at most 64 per list); commands are one line under 512 bytes. @@ -146,7 +149,12 @@ Two stages with separate clocks, both printed: ~2.5 s for `regen` and under 200 ms for each of the others): - `regen`: every registry entry whose inputs or outputs are touched is fresh - (`gstack-regen check` in a scratch worktree). + (`gstack-regen check` in a scratch worktree). A fresh entry with touched + outputs then runs once more with those outputs deleted; the ones the command + recreates byte-identically are the check's `proven` paths, which + `gstack-ship-receipt carry` reads. The verdict comes from the first run: an + output the second run does not recreate, or a second run that fails, is + simply not proven. - `secrets`: `gstack-redact` over the branch's added lines (merge-base to the working tree, untracked files included). HIGH, or MEDIUM in the `secret` category, fails; other MEDIUM findings warn. Cannot be downgraded. @@ -763,11 +771,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 @@ -789,8 +799,41 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js default such as `${VAR:-…}` or `${VAR-…}`) check each commit's added lines. Known limit: a path glued to the text before it, by a hyphen (`docs-…`, `$VAR-…`, `${VAR}-…`) or a letter (`-I…`, `-L…`), is not matched. 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 + commit (sealed.allow_paths )`) and counted as + `exempt=` 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). The + `SEALED:` line names both as `policy=/ + commit `. + `gstack-pregate sealed` only checks and never pushes, so confirm a push with + `git ls-remote `. A malformed or unreadable policy refuses. Waivers come only from the base branch's `.gstack/sealed-allow.tsv` (`rulepathshareason`) and are listed on every push. diff --git a/docs/review-policy.md b/docs/review-policy.md index 149abc89b..bafc25869 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -128,26 +128,75 @@ applies no edits is its exit too. ## Review depth by diff class -`gstack-review-plan --base ` classifies the diff and prints the reviewers -the next step dispatches (`REVIEW_CLASS:`, `REVIEW_PLAN:`, one `DISPATCH:` per -reviewer with its effort, one `SKIP:` per reviewer left out, `--json`). A mixed -diff takes its most restrictive class; a `gstack-diff-scope` error fails closed -to code, and code with the gate scan unavailable fails closed to gate. +`gstack-review-plan --base ` classifies the diff, picks a **depth** and prints +the reviewers the next step dispatches: `REVIEW_CLASS:`, `REVIEW_DEPTH: +runtime_lines= rules= reasons=<…> codes=<…>`, `GATE_SCAN: tagged=|unavailable +head=`, one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer +with its effort and a shell-quoted `DIFF_CMD:` (plus `DIFF_EXCLUDE: none` when it +reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields. +A plan that fails closed to full exits 0; a diff git cannot list exits 1. -| Class | What it is | Pass 1 dispatches | +**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs** +(markdown no agent or gate reads), **tests** (test files and fixtures), **code** +(everything else, including every `SKILL.md` and template, markdown in a skill +directory, `*/sections/**`, `scripts/resolvers/**`, rule docs, checklists and bins) and +**gate** (code plus auth or migration scope, a hunk the gate scan tags, a deleted code +or test path, CI workflows, credential, state-root and the paths below). + +**Runtime lines** are the changed lines of code-class paths, minus outputs the base +commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never +runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once. + +| Depth | When | Pass 1 dispatches | |---|---|---| -| **docs** | markdown no agent or gate reads: README, CHANGELOG, TODOS, how-to and explanation docs | the parent's core checklist only | -| **tests** | test files and fixtures only | core plus the testing specialist | -| **code** | everything else, including every `SKILL.md` and template, any markdown in a skill directory, `*/sections/**`, `scripts/resolvers/**`, `docs/*-policy.md`, `docs/captain.md`, `docs/unattended.md`, `docs/pregate.md`, checklists, specialists and bins | specialists by scope (as before), the native adversarial pass, the outside voice; the Red Team past 200 lines or after a CRITICAL | -| **gate** | code plus auth or migration scope, a hunk the gate scan tags (an RH rule: a relaxed threshold, a skipped test), a deleted code file, CI workflows, credential or state-root paths | code's set, plus the Red Team always, at full effort, and no hit-rate gating | +| **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers | +| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (testing, maintainability and security checklists, the specialists scope selects and the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers | +| **standard** | more than 50 runtime lines | the code set with every threshold (specialists at 50, simplification at 100, Red Team at 200) read on runtime lines; specialists leave docs and registered outputs out of their diff, and every specialist but Testing leaves tests out too; adversarial and outside voice read everything | +| **full** | gate class, or any classifier error | the gate set, unchanged: every reviewer on the whole diff, the Red Team always, no hit-rate gating | -**Numbers keep the outside voice.** On a docs or tests diff the outside voice is -skipped unless the diff publishes numeric claims: `gstack-review-plan` scans the -added lines of every path for percentages, money, multipliers, durations, token -or credit counts and score, rate or speed claims (`NUMBERS: yes`), and a -claim always dispatches it. The claims' fingerprint (`fingerprint=`) is passed -back as `--reviewed-numbers`; when a later fix changes a claim, only the -outside voice reruns, on the numbers. +A fixed blocking finding, a CRITICAL or an open blocker raises a light diff to +standard for the next step (`step=standard`): its specialists read at least 50 lines +and the Red Team runs, so a raise never dispatches less than light did. A fix-check +is the same at every depth. + +**Fail closed.** Each of these makes the depth full and is listed in `errors[]` with +the input it could not read, a sanitized cause and the repair: a `gstack-diff-scope` +error, a gate scan that is unavailable, a base registry that cannot be read or is +invalid (`REGEN_REGISTRY_INVALID` or `REGEN_CONTAINMENT` in `codes=`), a numstat git +could not produce, and a binary code-class path (it is never counted as 0 lines). + +**The registry rule.** Only the base commit's registry counts (the merge base's +`origin/` commit for live /ship and the current base ref's commit for the queue: `origin/` in `ready`, `/` in `train build`), and only +entries with a `command`, which pregate's regen check proves for that tree. An entry +added on the branch never excludes anything; `command: null` entries, vendored code +and lockfiles stay runtime. To adopt it, commit an entry like this on the base branch: + +```json +{ + "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1", + "entries": [ + { "id": "skill-docs", "command": "bun run gen:skill-docs --host all", "outputs": ["*/SKILL.md", "*/sections/*.md"], "inputs": ["*/SKILL.md.tmpl", "*/sections/*.tmpl"] } + ] +} +``` + +**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`, +`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`, +`lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, +`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or +`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets +the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns, +not file bytes, so a comment edit keeps it. + +**`--depth-floor standard|full`** (a /ship prompt flag, passed to every plan run) +raises the depth and never lowers it. + +**Numbers keep the outside voice.** At light depth the outside voice runs only when +the diff publishes numeric claims: `gstack-review-plan` scans the added lines of +prose and data files for percentages, money, multipliers, durations, token or credit +counts and score, rate or speed claims (`NUMBERS: yes`). The claims' fingerprint +(`fingerprint=`) is passed back as `--reviewed-numbers`; when a later fix changes a +claim, only the outside voice reruns, on the numbers. It always reads the whole diff. **The adversarial and outside voices run once per invocation** (Step 11, on the final tree), as the last full-pass plan lists them; after they ran, decision 2 @@ -166,6 +215,15 @@ default. `gstack-config set review_effort_tiers off` (or `--no-tiers`) runs the audits and watchers at the host default too ([model-policy.md](model-policy.md#reasoning-effort)). +### Behavior changes + +- A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the + core checklist and the gate scan review it. +- Light no longer runs a separate Red Team: the batched specialist carries its + prompt. At runtime 0 light runs no adversarial pass either. +- Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy + diff with few runtime lines dispatches fewer specialists. + ## What the receipt records `gstack-review-scope summary --since ` reads this invocation's @@ -190,6 +248,13 @@ specialist stats they record). The summary line appends `fix_checks=`, `dispatches=` and `est_input_tokens=` only when they were recorded, so an old loop prints the old line. +Beside the counters, `write` records `review_depth` (the depth, runtime lines, +rules hash, base and registry commits, and the gate scan bound to the head) and +the queue refuses a receipt below the depth its head needs +([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner +accepted after a fix-check edit goes in `review_loop_exception`, never in prose +under `REVIEW_LOOP:`. + The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A receipt carried across a base merge or a test-only fix @@ -268,9 +333,10 @@ trailers, appended }`. ## Where this is enforced -- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on), because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above. -- `ship/sections/review-army.md` Step 9: passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way. -- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`). +- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on) beside its plan's `DIFF_CMD` excludes, because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above. +- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way. +- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`). +- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`). - `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`); refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)). - `lib/ship-policy.ts`: the `review_scope` key and its `ship: review_scope …` consequence line. - `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port. diff --git a/docs/ship-budget.md b/docs/ship-budget.md new file mode 100644 index 000000000..ef7558364 --- /dev/null +++ b/docs/ship-budget.md @@ -0,0 +1,159 @@ +# /ship token budget and fresh-context handoffs + +`/ship --token-cap 25M` (or `GSTACK_SHIP_TOKEN_CAP=25M`) bounds what one /ship run spends, and +long runs hand their heavy segments to fresh-context children. The helper is +`bin/gstack-ship-budget`; the skill's procedure is `ship/sections/budget-handoff.md`. +There is no default cap: without one, /ship meters usage when it can and prints it, and +nothing else changes. + +## What the cap guarantees + +It is an **admission cap**, not a hard ceiling. Before every step and every dispatch batch, +`check` admits the work only when + + used + open reservations + unknown charges + projection + tail reserve + stop overhead <= cap + +Exactly at the cap is admitted; one token over is not. A parallel review batch is reserved as +one sum before any of it starts. Admitted work can still cost more than its projection, and +gstack cannot stop a host mid-turn from bash, so a run can finish above the cap by the +misprojection of work already admitted. The tail reserve (default 2M, `--reserve `) keeps +room for the receipt steps, and the stop overhead (calibrated) keeps room to stop cleanly. + +## Quickstart + +**Capy.** Read the root counter in one execute program that returns only that number: + +```js +return (await tools.thread.get({ thread_id: "this" })).cost.tokens; +``` + +then + +```bash +gstack-ship-budget start --record --token-cap 25M --reading +gstack-ship-budget check --run --step 6 --next 7 --reading [--prior-reading ] +``` + +The counter rolls up every subagent of the thread, outside voices included; Capy exposes no +per-subagent counter, so other work in the same thread counts too (it only stops earlier). + +**Claude Code.** `gstack-ship-budget start --record --token-cap 25M --session `. Without the +session id, `start --meter claude` prints a nonce: echo it once, then rerun with `--nonce `. +The helper reads the session journal, its subagent journals and the journals of headless +children it launched; you never pass `--reading`. + +**Anywhere else.** `--meter estimate` projects with the calibration and prints `meter=estimate`. +With a cap set and no readable meter, start refuses +([BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable)). + +## The BUDGET line + + BUDGET: used= reserved= unknown= admission_cap= tail= unit= meter= epoch= call= next= projected= verdict=continue|delegate|stop + +| Field | Meaning | +|---|---| +| `used` | Metered usage since start (all epochs), or settled estimates under `meter=estimate` | +| `reserved` | Open reservations: admitted steps, started dispatches, and each open child's sub-cap counted as max(sub_cap, child_used), never both | +| `unknown` | Charges the meter cannot see (an external-CLI outside voice on Claude Code), counted at their reservation | +| `admission_cap` | The cap, or `none` | +| `tail` | Tail reserve plus stop overhead, held back from every admission | +| `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (root counter), `claude-usage-tokens` (journal usage), `estimate-tokens`; a meter change opens a new epoch | +| `call` | The per-call charge: two Capy readings in consecutive calls, or the last Claude Code call's usage | +| `next`, `projected` | What the check was for and its calibrated projection | +| `verdict` | `continue` (exit 0, prints `RESERVATION:` under a cap), `delegate` (exit 0), `stop` (exit 3) | + +Any exit other than 0 means the next step does not start. Exit 2 is a usage or validation +error on stderr with the code, the value and the repair. GSTACK_RESULT appears only on a stop, +an incomplete close and a child's return; /ship's real terminal line stays Step 22's. + +## Dispatches + +Under a cap, `gstack-review-log --start` refuses a dispatch without an unspent, one-shot +reservation ([BUDGET_CHECK_SKIPPED](troubleshooting.md#budget-check-skipped)): + +```bash +gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team --reading +gstack-review-log --start review --reservation +``` + +Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, +`fix-check`. `--finish ` settles the reservation at the dispatch's completion receipt. +`close` audits after the fact: each review-log row of the run with no reservation gets one +BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to accept +(`close --approved-by --words ""`); a reservation is never created after the fact. + +## Delegation + +At start, a per-call charge of 100k or more delegates the whole /ship. Before Step 7 and before +Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11.5, 12–22): + +1. `gstack-ship-budget handoff --run --from 12 --to 22 --base origin/ --reading ` writes a + brief (mode 0600, under the run directory) with the pins (HEAD, merge base, working tree, + reviewed tree), the record with its six headings and start tokens, the decisions in scope + with the owner's words, the decision trail, the budget and the return contract. It reserves + the child's sub-cap: min(cap − used − reserved − tail, the segment estimate), or `--sub-cap`. +2. The child is a Capy subagent on the shared machine, or on Claude Code + `gstack-ship-budget launch --in `: a background `claude -p` (resolved through lib/claude-bin.ts) + with explicit permission flags and the return file named. +3. The child runs `verify-handoff` first; a moved pin refuses + ([HANDOFF_PIN_MOVED](troubleshooting.md#handoff-pin-moved)). It admits against its sub-cap + with child_used = reading − reading at handoff, is the only writer of the record, and never + delegates again: a delegate rule stops it. It ends with `return` (at most 40 lines). +4. `gstack-ship-budget reconcile --run --return --reading `; idempotent per handoff hash, + so a duplicate return charges nothing twice. A refused, incomplete or interrupted child + exits 3: regenerate the handoff or stop. + +`--delegate never` turns delegate into stop for hosts without children. + +## Stop and resume + +A stop prints + + STOP: reason=budget_exhausted spent= reserved= unknown= admission_cap= next= projected= + RECOVER: /ship --resume --token-cap + GSTACK_RESULT: skill=ship status=incomplete run= + +Raising the cap is the owner's call; the resume journals who raised it and their words: + +```bash +gstack-ship-budget resume --record --token-cap --approved-by Garry --words "raise it to finish" --reading +``` + +Every command takes `--run` or `--record`; runs are never found by branch or directory, and a +/ship without `--resume` or `--continue` always starts a new run. The ledger and record are +mirrored to a private Drive checkpoint (`GSTACK_SHIP_BUDGET_CHECKPOINT_DIR`, else the +project-user Drive folder on Capy) bound to the run id and their hashes, so after a machine +change `resume --run ` restores both. A corrupt ledger +([BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid)) is rebuilt from the +record's block with `--rebuild-from-record`; open reservations are then charged as unknown. + +## Calibration + +`lib/ship-budget-calibration.json` holds per-meter constants (per call, per dispatch, stop +overhead, calls and dispatches per step) fitted from metered runs: each constant is the +largest observation times 1.5, rounded up to 10,000 tokens. The leave-one-out test +(test/ship-budget-calibration.test.ts) covers the per-call constant only, the one observed +across three runs; per-dispatch has two observations and stop overhead one. Only the Capy +meter has observations; the Claude Code and estimate units are seeded from it until a +Claude-metered /ship completes. + +## Token cap and USD spend cap + +A lane's `--spend-cap` (USD) bounds paid gates and evals through lib/spend-ledger.ts; `--token-cap` +bounds the /ship run's model tokens through its own ledger. They are independent: set both +when a lane runs paid gates inside /ship, and neither converts into the other. + +## Claiming savings + +Savings count completed ships only. Preregister the comparison with `/eval-plan` (arms, bars, +stop rules) before claiming a saving from the cap or from delegation. + +## Result codes + +[BUDGET_CAP_REACHED](troubleshooting.md#budget-cap-reached) · +[BUDGET_READING_MISSING](troubleshooting.md#budget-reading-missing) · +[BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected) · +[BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable) · +[BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid) · +[BUDGET_CHECK_SKIPPED](troubleshooting.md#budget-check-skipped) · +[HANDOFF_PIN_MOVED](troubleshooting.md#handoff-pin-moved) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e2cd9f208..168ccb678 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -221,8 +221,11 @@ It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD is not the PR's pushed head, CI at that exact head is not green, or the pregate result is not a current pass, and (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that -head: /ship runs before every READY ([details](#ship-before-ready)). Send the -block as printed. +head: /ship runs before every READY ([details](#ship-before-ready)). It also +refuses `QUEUE_RECEIPT_DEPTH_RAISED` when the receipt's review depth is below +what the head needs and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner +exception the receipt records does not stand ([the ship receipt](#ship-receipt)). +Send the block as printed. **2. Lane behind another PR: gate ahead.** Gate the tree the queue will have once the predecessor lands, while it is still open: @@ -370,12 +373,17 @@ slot. The queue enforces this at every step from the PR body alone: - `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR); the informational - `SHIP_RECEIPT:` line still prints. There is no flag around it. + `SHIP_RECEIPT:` line still prints. It refuses `QUEUE_RECEIPT_DEPTH_RAISED` + (with a `REVIEW_DEPTH:` line) when the receipt records a review depth below + what the head needs, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner + exception it records does not stand ([the ship receipt](#ship-receipt)). + There is no flag around it. - `train build` refuses a pin whose PR has no current receipt with the same codes, naming the PR, before anything is written, pushed or dispatched, `--dry-run` included. -- `verify --train` refuses the slot with the same codes before printing - `MERGE_CONDITION`. +- `verify --train` refuses the slot with `QUEUE_RECEIPT_MISSING` or + `QUEUE_RECEIPT_STALE` before printing `MERGE_CONDITION`; it does not + recompute the review depth. **The stamp keeps the pin's receipt.** The slot's stamp commit changes only stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs @@ -478,6 +486,9 @@ its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork head), `QUEUE_TRAIN_MOVED` (a head moved from its pin), `QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship receipt for the pin; see [/ship before READY](#ship-before-ready)), +`QUEUE_RECEIPT_DEPTH_RAISED` and `QUEUE_RECEIPT_EXCEPTION_INVALID` (the +receipt's review depth is below what the pin needs, or an owner exception it +records does not stand; see [the ship receipt](#ship-receipt)), `QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed `QUEUE_TRAIN_CONFLICT: train stopped at # (conflicts: ) after merging #,#; nothing dispatched, no branch pushed; fix: …` line names @@ -777,7 +788,9 @@ written by the pr-body section of `/ship` after the body is composed. The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`, -`pregate`, `issues`, `approval`, `docs`, `review_loop`, `carry`), validated +`head_tree`, `product_patch_id`, `gstack_version`, `review_depth`, `pregate`, +`issues`, `approval`, `docs`, `review_loop`, `review_loop_exception`, `carry`), +validated against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and read back by `gstack-ship-receipt read`, `result`, `carry` and the queue. @@ -812,6 +825,46 @@ unreadable base policy is `POLICY_SOURCE_UNAVAILABLE`); one (`absent/ over` for a path the tree lacks), exit 1 on any `over`, whose repair is trimming the file, never raising the cap in the same PR. +**`review_depth` and `gstack_version`.** `write` records the gstack that +wrote the receipt and the review depth at the checked-out head +([review-policy.md#review-depth](review-policy.md#review-depth)): +`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`, +computed from git objects for the range against `origin/` +(`gstack-diff-scope --head ` for the scope signals), with the gate +scan run on that head. `--head` must be the checked-out HEAD +(`RECEIPT_INVALID` otherwise), so the record and `head_tree` describe the +tree the review read; tracked edits in the checkout make the scan unavailable, +which records `full`. `gstack-ship-queue ready` and `train build` recompute +the depth the head needs from the same range and scope and print `REVIEW_DEPTH: receipt= required= +runtime_lines= class= reasons=<…> inputs=base@,registry@,gate_scan@`; +a recorded gate scan counts only when its `head` is the full bound commit, +otherwise the scan is unavailable and the head needs `full`. A receipt below the required +depth, or one whose writer records `gstack_version` but no `review_depth`, is +[`QUEUE_RECEIPT_DEPTH_RAISED`](troubleshooting.md#queue-receipt-depth-raised): +the receipt is left unchanged and the fix is /ship on that head. A receipt with +neither field predates depth receipts and is accepted as legacy. A carry +recomputes the depth at the new head, rebinds the gate scan, and refuses when +the new head needs a deeper review than the receipt records (a test change that +skips a test raises the diff to gate class), when `--head` is not the +checked-out HEAD, when the receipt records `gstack_version` without +`review_depth`, or when an owner exception no longer stands, each as +[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). + +**`review_loop_exception`.** The owner's acceptance of a stop after a +fix-check edit under frozen scope (#3559), from `write --review-loop-exception +`: `{ accepted_by, date, via, reason, fix_check: { head, findings[, files] } }`. +It needs `--review-loop` with `fix_checks` of 1 or more, a `fix_check.head` +that is the receipt head or its ancestor, and, with `--body`, the line +`Review-loop exception: accepted by via : (fix-check at , findings)` +word for word; otherwise +[`REVIEW_LOOP_EXCEPTION_INVALID`](troubleshooting.md#review-loop-exception-invalid) +and nothing is written. The queue checks both owner exceptions the same way +before READY and train build: the record must parse, the body must still +carry its line, and the fix-check head must be the bound head or its ancestor +(after a carry across rewritten history the old fix-check commit is off the +branch, so that check is skipped), or the receipt is +[`QUEUE_RECEIPT_EXCEPTION_INVALID`](troubleshooting.md#queue-receipt-exception-invalid). + **Prose against the receipt.** `gstack-ship-receipt read --pr --check-prose` (or `--body `) compares every counter the body quotes with the receipt's integers under `review_loop`, `gate` and `ci`: `passes=2`, `passes: 2` and @@ -891,6 +944,32 @@ line, and the codes a refusal ends with are ## Carrying a receipt across a base merge or a test-only fix +**Merge main into a reviewed branch; never rebase it.** What happens to the +receipt when the base moves: + +| What changed since /ship | The receipt | +|---|---| +| a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate's regen check proved) | carries; no review reruns | +| 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 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 `. 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/` 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 invalidate it and cost a full re-run on identical product code. @@ -921,11 +1000,22 @@ newer tentative one the stamp moved (a `*_KEY_VERSION` written as the next minor, #3512; a version older than the old one or of another width is a hand edit). A logic change in a stamped source file is product. Lockfiles are product unless the policy names -them. A base merge contributes no product -change of its own. A receipt carried twice keeps the reviewed head as +them. An output of an entry with a `command` in origin/'s +`.gstack/generated.json` is `generated`, not product, only when the +`--pregate` result passed for HEAD lists it under the `regen` check's +`proven` paths (the check deleted the touched output in its scratch worktree +and the command recreated it byte-identically) and that check ran the same +registry, origin/ at the commit the carry reads. A file that matches an +output glob but that the command never writes is not proven. Without that +evidence, or for an entry with no command or one only the branch registers, +the output is product (U1 on gstack-evals paid a +full rerun because a merge changed its generated index's diff). A base merge +contributes no product change of its own. A receipt carried twice keeps the reviewed head as `carry.from` and adds up the merges and paths. The carried receipt records `carry: { from, reason, product_patch_id, changed_paths, merge_commits, -range_diff, covers, reruns }` with the `git range-diff` evidence, prints +range_diff, covers, reruns, ancestry, trees }` (`ancestry` is `linear` +along the receipt's own history, `rewritten` once any hop was a rebase; +`trees` lists the earlier covered trees) with the `git range-diff` evidence, prints `CARRY: carried from to ()` and the `GSTACK_RESULT: skill=ship status=complete` line, and its summary line ends with `carried_from=`. `CARRY: current` means the receipt already names HEAD; @@ -933,10 +1023,25 @@ skill=ship status=complete` line, and its summary line ends with A changed product path, or a product patch-id that differs, is [`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed) -(exit 3; the refusal names the paths), and a receipt whose head is not in HEAD's -history (a rebase or force-push) or a working tree with uncommitted changes is -[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused): run `/ship` -on the new head. The queue's `ready`, `train build` and `verify --train` read the +(exit 3; the refusal names the paths), and a working tree with uncommitted +changes is [`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). + +**Rewritten history.** `write` records `head_tree` (the reviewed head's tree) +and `product_patch_id` (its product patch against the base), and every carry +rebinds both to the new head and keeps the earlier trees in `carry.trees`. +When the receipt's head is no longer in HEAD's history, the carry follows only +when HEAD's tree is one of those trees and HEAD's product patch-id against the +current base equals the recorded one; it records `carry.ancestry: rewritten` +and `range_diff: tree-equal`, and needs neither the old commit nor its +history, so a fresh clone carries too. The tree alone is not enough: a stacked +branch rebased onto main after its base closed unmerged keeps its tree while +its diff grows by the base's code, and that is `RECEIPT_CARRY_PRODUCT_CHANGED`. +Any other tree is +[`RECEIPT_CARRY_TREE_MISMATCH`](troubleshooting.md#receipt-carry-tree-mismatch), +which names HEAD's tree and the covered trees. A receipt from before +`head_tree` existed, whose head is not in this clone, is +`RECEIPT_CARRY_REFUSED`: run `/ship` on the new head. A refusal never +rewrites the body. The queue's `ready`, `train build` and `verify --train` read the carried receipt like any other. **Delta /ship: eligibility, then checks, then publication.** `/ship` Step 1 runs @@ -995,7 +1100,7 @@ or the order moved, the result is `QUEUE_STALE` (exit 3) with the exact Stages, each journaled in `.gstack/tmp/restamp-journal.json`: 0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command). -1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. +1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so. 2. **release_tool**: the policy command, under the contract below. 3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under ``, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once. 4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. diff --git a/docs/skills.md b/docs/skills.md index a1fb382e0..348777519 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -906,8 +906,10 @@ gstack's own templates and for any skill folder you own. - **Drift.** `test/skill-bin-drift.test.ts` fails when a template or rendered skill calls a gstack bin or flag that does not exist (`bun run scripts/skill-bin-drift.ts --skill ` for one skill). Its allowlist only shrinks. - **Full diet.** Report first, write the eval tasks before any cut - (`test/fixtures/skill-evals//tasks.json`), cut, then prove before vs after vs no skill with the shared skill - eval (`docs/designs/SKILL_EVAL_CONTRACT.md`). A diet ships only when no task that passed before fails after. + (`test/fixtures/skill-evals//tasks.json`), lock them (`gstack-evals skill-eval --lock`, then commit + `tasks.lock.json`; the shared eval refuses a paid run without it), cut, then prove before vs after vs no skill with + the shared skill eval (`docs/designs/SKILL_EVAL_CONTRACT.md`). A diet ships only when no task that passed before + fails after. `/write-skill` is not `/skillify`: `/skillify` codifies a browser scrape into a browser-skill. Adapted from [nicobailon/skill-diet](https://github.com/nicobailon/skill-diet) (MIT). The first diet, /learn, cut its template from diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ffa23542..ccb23ea08 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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

(m +), so the upgrade waits until the lease is 120 minutes old; fix: +/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

(m )`), then re-run `bin/gstack-upgrade`. +`pid

(m )`), 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

--stack ` 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 `. +**Fix.** Check out the branch the work ships from, or pass `--branch `. For +`--stack`, correct or fetch the named branch and import again. ### `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 ` if @@ -1810,6 +1823,92 @@ all the command prints `SHOT_PNG: unavailable (...)` and exits 0 instead. **Fix.** Screenshot `shot.html` with your own browser at 880 px wide, or use `text.md`. +## /ship token budget and fresh-context handoffs (gstack-ship-budget) + +The flow, the BUDGET line field by field and the meters are in [ship-budget.md](ship-budget.md). +Exit 2 lines go to stderr; exit 3 lines (a stop) go to stdout with the STOP, RECOVER and +GSTACK_RESULT lines. + + +### `gstack-ship-budget: ... (BUDGET_CAP_REACHED)` + +**Meaning.** The next step or dispatch batch would push used + open reservations + unknown +charges + its projection + the tail and stop reserve past the admission cap, so it did not +start. Exit 3. `gstack-review-log --start` prints the same code (exit 1) for a dispatch +after the run stopped. The cap admits work; it cannot stop a turn already running, so the +overshoot is bounded by the misprojection of work admitted before the stop. + +**Fix.** The run ended status=incomplete with a resumable record. Raising the cap is the +owner's call: run the printed `RECOVER:` line; its resume needs +`--approved-by --words ""`. + + +### `gstack-ship-budget: ... (BUDGET_READING_MISSING)` + +**Meaning.** The run's meter is the Capy root counter, a cap is set, and the command got no +`--reading`. Exit 2. No gstack binary can call Capy's API, so the agent reads it. + +**Fix.** In one execute program return only +`(await tools.thread.get({ thread_id: "this" })).cost.tokens` and pass it as `--reading `. + + +### `gstack-ship-budget: ... (BUDGET_READING_REJECTED)` + +**Meaning.** The reading breaks the run's meter contract: it is lower than the last reading +of the same epoch, `--prior-reading` is above `--reading`, or `--reading` went to a meter that +reads its own source (Claude Code journals, the estimate). Exit 2; nothing changed. + +**Fix.** Pass the counter the run started with (the BUDGET line names meter, unit and epoch). +A different meter opens a new epoch that keeps prior usage: +`gstack-ship-budget resume --run --meter `. + + +### `gstack-ship-budget: ... (BUDGET_METER_UNAVAILABLE)` + +**Meaning.** A cap is set and no meter is readable: no `--reading`, no bound Claude Code +session, no `--meter estimate`; or a `--session`/`--nonce` matched zero or several journals. +The line names what it tried. Exit 2. + +**Fix.** Capy: `--reading `. Claude Code: `--session `, or echo the nonce the line prints +once and rerun with `--nonce `. Elsewhere, with the owner's consent, `--meter estimate`. + + +### `gstack-ship-budget: ... (BUDGET_LEDGER_INVALID)` + +**Meaning.** The ledger (`/ship-runs//budget.json`), the record's +gstack-budget block, a handoff brief's block or a checkpoint is missing, unparseable, from +another schema version, or not bound to the run (identity or sha256 mismatch). The line names +the path, the schema version and the parse location. Exit 2. + +**Fix.** After a machine change, `gstack-ship-budget resume --run ` restores the ledger and +record from the Drive checkpoint. Otherwise rebuild: +`resume --record --rebuild-from-record --reading `; open reservations are charged as unknown. + + +### `gstack-review-log: ... (BUDGET_CHECK_SKIPPED)` / `gstack-ship-budget: ... role= step= started= spend=unknown (BUDGET_CHECK_SKIPPED)` + +**Meaning.** Under a cap, `gstack-review-log --start` got no reservation id, an id from another run +or a step reservation (mismatched), or an id already used (one-shot); it refused before the +dispatch (exit 1, nothing captured). From `close`: a review-log row of this run had no +reservation before it started; one line per dispatch, and the run ends status=incomplete (exit 3). + +**Fix.** Run `gstack-ship-budget check --run --step --next dispatch:` before the +batch and pass the printed id once with `--reservation`. A reservation is never created after the +fact: the owner accepts the unreserved spend with `close --approved-by --words ""` +or the dispatches rerun under reservations. + + +### `gstack-ship-budget: ... (HANDOFF_PIN_MOVED)` + +**Meaning.** `verify-handoff` found the brief no longer matches its tree: HEAD, the merge base +with the base ref, the working-tree fingerprint (`gstack-wtree`) or the reviewed tree moved, or the +brief's sha256 is not the one the parent recorded. Each line names the pin with its expected +and actual value. Main moving alone keeps the merge base and never refuses. Exit 3; the child +wrote its return with status=refused. + +**Fix.** The parent reconciles the refusal and regenerates the handoff at the current tree +(`gstack-ship-budget handoff ...`) or stops. + ## Browser @@ -2388,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 ` using the `TOKEN:` line the refusal printed; MEDIUM findings do not block @@ -2409,11 +2516,20 @@ holding only its first section therefore no longer wipes the rest of the PR. The redaction scan runs on the merged bytes, so a MEDIUM or HIGH finding can come from a kept section; the `RULE:` line's `LINE:` is in the merged text. When the current body cannot be read (`gh pr view --json body` fails) nothing -is posted, exit 3. +is posted, exit 3. When the current body holds a title the file rewrites more +than once (an earlier description pasted below a `---` under the new one, as +on gstack-evals#19), the file's section matches no single copy, so nothing is +posted, exit 64: `gstack-post: the PR's current body holds "What changed" more +than once, so the file's copy matches no single section; nothing was posted. +Rerun with --replace and the whole body`. Merging would replace one copy and +keep the stale other beside it. **Fix.** Nothing, when the kept sections are wanted. To overwrite the whole body with the file, rerun with `--replace`. To drop a section, post the body -with `--replace` or edit it on the host. +with `--replace` or edit it on the host. For the duplicate-title refusal, write +the whole body you want (keep the earlier description under a title of its own, +such as `## Original description`, if it should stay) and post it with +`--replace`. ### `MEDIUM pii.email :` from the pre-push hook @@ -2428,6 +2544,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
`. If it is your own address, check `git config user.email`. The allowlist only covers `pii.email`; @@ -2900,10 +3025,12 @@ for an older PR, write it once by hand with the same command. ### `RECEIPT_INVALID` **Meaning.** The block exists but does not validate against the `ship-receipt` -schema (`gstack-artifact schema ship-receipt`). +schema (`gstack-artifact schema ship-receipt`). `gstack-ship-receipt write` +also refuses with this code, and writes nothing, when `--head` is not the +checked-out HEAD: the receipt binds the tree the review read. **Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the -block. +block. For the `--head` refusal, check out that commit and write there. ### `EXECUTION_UNKNOWN` @@ -3150,7 +3277,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. ### `PREGATE_SECRETS` @@ -3240,6 +3370,27 @@ specifiers in test files that resolved to no repo file (for example a a test does cover the file, declare it under `dependencies`; if the resolver should have found it, report the specifier as a resolver gap. + +**The branch adds the lane itself.** A branch that edits `.gstack/pregate.json` +to give its own new files a lane still gets this error: pregate reads the policy +from `origin/` (or the user-owned file), never from the branch, so a +branch cannot loosen its own gate ([trust boundary](pregate.md#trust-boundary)). +That is intended, and pregate never switches to the branch's copy on its own. +The fix line then opens `this branch edits .gstack/pregate.json, but the policy +comes from origin/@:.gstack/pregate.json so a branch cannot loosen +its own gate, and a lane it adds counts only once it is on ` and names the +two routes: + +1. Land the policy change in its own PR first (the reviewed route), then merge + the base into this branch and rerun. +2. Rerun with `--policy-from head --allow-repo-commands`. The run reads the + branch's copy and its record says `policy head (unreviewed)`, so a + reviewer sees that the gate ran under a policy nobody has reviewed yet; + `gstack-ship-queue` never accepts a head-policy run as a remote mirror. + +An allowlist entry the branch adds to its own `pregate.json` is read the same +way: it clears a finding only once it is on the base. + ### `PREGATE_LANE_FAILED` @@ -3656,7 +3807,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`: `rulepathshareason`. 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 @@ -4434,6 +4589,39 @@ written for the pinned head is the binding one and `verify --train` accepts it. `gstack-ship-queue ready --pr --screen|--no-screen`. A lane never stamps to fix this: the stamp comes only when the slot is called. + +### `QUEUE_RECEIPT_DEPTH_RAISED` + +**Meaning.** `ready` or `train build` recomputed the review depth the head +needs from git (the range against the base ref, `origin/` in `ready` +and `/` in `train build`, the base commit's +generated-output registry, and the receipt's gate scan when it names this +head) and the receipt's `review_depth.depth` is below it, or the receipt comes +from a gstack that records `gstack_version` but carries no `review_depth`. The +`REVIEW_DEPTH:` line prints the receipt's depth, the required depth, runtime +lines, the class, the reasons and the inputs (`base@`, `registry@`, +`gate_scan@` or `missing`). A gate scan recorded for another head counts as +missing, which makes the head need `full`. The receipt is unchanged. + +**Fix.** Run /ship on the head the READY names; it reviews at the depth +`gstack-review-plan` plans for that head and writes a receipt that records it. +Never edit `review_depth` by hand ([review-policy.md](review-policy.md#review-depth)). + + +### `QUEUE_RECEIPT_EXCEPTION_INVALID` + +**Meaning.** The receipt records an owner exception (`docs.exception` or +`review_loop_exception`) that the queue cannot accept: the record does not +parse, a review-loop exception names no fix-check (`review_loop.fix_checks` +is 0 or missing), its `fix_check.head` is neither the bound head nor an +ancestor of it (not checked after a carry across rewritten history), or the PR +body no longer carries the exception's line word for word +(`Documentation exception: …` or `Review-loop exception: …`). + +**Fix.** Restore the line in the PR body exactly as `write` printed it, or run +/ship again and record the owner's acceptance with `--docs` or +`--review-loop-exception` ([ship-policy.md](ship-policy.md#ship-receipt)). + ### `QUEUE_SCREEN_STALE_REF` @@ -4694,29 +4882,78 @@ rebased; /ship's own rule is to merge the base in, never rebase a reviewed head. ### `RECEIPT_CARRY_REFUSED` **Meaning.** `gstack-ship-receipt carry` could not carry the PR's /ship receipt -to HEAD: the PR body has no valid receipt, the receipt's head is not an ancestor -of HEAD (a rebase or force-push dropped it), the working tree has uncommitted -or untracked changes (a carry certifies a commit, never a tree that differs from -it), or git could not compare the two product diffs or read the working tree's -status. The receipt in the body is unchanged and still names the old head, so -`gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. +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), the PR was retargeted to the base after its stack parent landed but +the receipt records no `product_patch_id` and `origin/` 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 +`review_depth` ([ship-policy.md](ship-policy.md#ship-receipt)). The receipt in +the body is unchanged and still names the old head, so `gstack-ship-queue +ready` refuses `QUEUE_RECEIPT_STALE`. -**Fix.** Keep the reviewed head in HEAD's history (merge the base in instead of -rebasing), commit or stash every working-tree change, then rerun +**Fix.** Commit or stash every working-tree change, then rerun `gstack-ship-receipt carry --pr ` at the new head. When the history is -already rewritten, run /ship on the new head. +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 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. + + +### `REVIEW_LOOP_EXCEPTION_INVALID` + +**Meaning.** `gstack-ship-receipt write --review-loop-exception` refused the +record and wrote nothing: it is not `{ accepted_by, date, via, reason, +fix_check: { head, findings[, files] } }`, `--review-loop` is missing or counts +no fix-check, `fix_check.head` is neither the receipt head nor its ancestor, +or the `--body` file lacks the `Review-loop exception: …` line. + +**Fix.** Record the owner's exact words and the fix-check the stop followed, +pass the `gstack-review-scope summary` file as `--review-loop`, put the printed +line in the PR body, and write again +([ship-policy.md](ship-policy.md#ship-receipt)). + + +### `RECEIPT_CARRY_TREE_MISMATCH` + +**Meaning.** The branch was rebased or force-pushed after /ship, so the +receipt's head is no longer in HEAD's history, and HEAD's tree is not a tree +the receipt covers (its `head_tree` or a tree in `carry.trees`). No review saw +this code. The refusal names HEAD's tree and the covered trees; the body is +unchanged. + +**Fix.** Run /ship on the new head. A rebase carries only onto a reviewed tree +whose product diff against the new base is unchanged; merging the base in +instead keeps every later carry available +([ship-policy.md](ship-policy.md#receipt-carry)). ### `RECEIPT_CARRY_PRODUCT_CHANGED` **Meaning.** Between the receipt's head and HEAD the product diff against the -base changed: at least one path that is neither a test, release metadata nor -part of the base merge has a different patch than the reviewed head had. The -reviewed verdict does not cover HEAD, so the receipt is not carried. The -refusal names the changed product paths. +base changed: at least one path that is neither a test, release metadata, a +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. 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 or release metadata, which the +conflict-only base merge, a test-only change, release metadata, a registered +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)). @@ -5097,7 +5334,7 @@ The name is not one of openclaw, hermes, dots, generic, qm, muse or grok. For a ### `gstack-harness: Hermes does not support --skills router` (HARNESS_MODE_UNSUPPORTED) -Hermes opens only skills it has indexed, so a router that points at unlisted files would not load. Drop `--skills` to take the harness's default (Hermes: all; OpenClaw, dots and generic: router). +Hermes lists in its skills index only the skills registered as folders in its skills tree, so a router would leave every other gstack skill out of the listing the model chooses from (Hermes's `skill_view` can still load an unlisted skill by name, but nothing tells the model it exists). Drop `--skills` to take the harness's default (Hermes: all; OpenClaw: hybrid; dots and generic: router). OpenClaw takes router, hybrid or all. ### `gstack-harness: refusing to write into : ` (HARNESS_UNSAFE_DESTINATION) @@ -5109,7 +5346,12 @@ The destination is your home folder, the filesystem root, inside the gstack chec gstack only replaces files its ledger says it wrote. A folder or a skill with the same name is yours (or another tool's), and harnesses resolve skills by name, so gstack stops instead of shadowing it. Rename or move that entry, then register again. -The same code stops `register generic --context ` when the file holds a `` or `` line without exactly one partner, or a gstack block someone edited. Delete those marker lines (and the lines between them, once you have kept what you want), then register again. +The same code stops `register --context ` when the file holds a `` or `` line without exactly one partner, or a gstack block someone edited. Delete those marker lines (and the lines between them, once you have kept what you want), then register again. + + +### `gstack-harness: refusing to write the gstack block into : ` (HARNESS_CONTEXT_REFUSED) + +`--context` writes the routing block only into a file you name, and two kinds of file are refused before anything is written. `SOUL.md` (in any letter case) is the harness's identity file (Hermes and OpenClaw both load it every session), and gstack never edits it. For Hermes, a new `.hermes.md`, `HERMES.md`, `AGENTS.override.md`, `AGENTS.md` or `CLAUDE.md` that would outrank a rules file Hermes loads now is refused too: Hermes loads one project context type, first match wins (`.hermes.md`/`HERMES.md`, then the `AGENTS.md` chain, then `CLAUDE.md`, then `.cursorrules`), takes the first name it finds in a folder (`.hermes.md` before `HERMES.md`, `AGENTS.override.md` before `AGENTS.md`), and takes the nearest `.hermes.md`/`HERMES.md` walking up to the git root, so creating it would hide those rules. Name the file Hermes already loads instead (the error names it), or a file in `$HERMES_HOME` for every session. ### `gstack-harness: could not render skills: ...` (HARNESS_RENDER_FAILED) @@ -5141,3 +5383,47 @@ QM's admin API refused or did not answer. gstack lists packs before it creates o QM registration does not work yet. QM's skill-pack import clones the whole repository and reads every file before it picks the skills, and by default it refuses a pack over 32 MiB or 5,000 files. The gstack repository is larger (about 73 MiB in 4,300 files), so gstack checks the size first and sends nothing. When QM itself refuses (a QM with other limits), the pack it created stays in the org without skills: `gstack-harness unregister qm --remote` archives it. Meanwhile, give a QM agent gstack by pasting the output of `gstack-harness register generic --print` into its instructions. + +## Module loadouts (gstack-modules) + +These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control. + + +### `LOADOUTS_INVALID: modules/loadouts.json …` + +**Meaning.** `modules/loadouts.json` is unreadable, or names a module, base loadout, rung or default that does not exist, excludes a pinned module, or maps one model id to two rungs. **Fix.** Correct the named entries, then run `bin/gstack-modules validate` until it prints `LOADOUTS: ok`; `git checkout -- modules/loadouts.json` restores the committed file. + + +### `LOADOUT_UNKNOWN: "" is not a loadout (valid: …)` + +**Meaning.** `--loadout` or the first token of `--modules` names no loadout in `modules/loadouts.json`. **Fix.** Use a name from the `valid:` list (`bin/gstack-modules list` prints them); `full` is always present. + + +### `MODULE_SPEC_INVALID: …` + +**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists. + + +### `MODULE_UNKNOWN: "" is not a module` + +**Meaning.** The id names no skill, section, overlay or preamble piece. **Fix.** Copy the id from `bin/gstack-modules list`. + + +### `MODULE_PINNED: is pinned and can never be excluded` + +**Meaning.** The spec removes a module the safety contract needs: the start command (`preamble:bash`), plan-mode rules, the question format, completion status, the unattended profile, /ship's review gates, the router or `/gstack-upgrade`. **Fix.** Drop `-` from the spec; `bin/gstack-modules list --pinned` prints every pinned id. + + +### `MODULE_EXCLUSION_UNSAFE: : referenced by :` + +**Meaning.** The rendered tree still points at a module the loadout left out, so an agent following it would read a file that is not there. **Fix.** Exclude the referencing module as well, or wrap the referencing skeleton text in `{{IF_SECTION:}}…{{/IF_SECTION}}` in its template (the text renders unchanged when the section loads). Nothing was published; rerun after the change. + + +### `RENDER_FAILED: gen-skill-docs failed: …` + +**Meaning.** Generation itself failed for this loadout (an unresolved placeholder, an oversized skill, a missing section). **Fix.** Fix the named template or module and rerun. The staging directory was removed and `--out-dir` is unchanged. + + +### `RENDER_DEST_NOT_OWNED: exists and holds no loadout.lock.json` + +**Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render. diff --git a/docs/unattended.md b/docs/unattended.md index 7418fceb4..905de39cc 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -187,6 +187,15 @@ most 300 words; a subagent brief names the files to read instead of pasting them. Evidence goes in files the report points to. Waits go on operations, never on polling wakes. +### Token caps and fresh-context handoffs + +A parent bounds a /ship run with `--token-cap ` (or `GSTACK_SHIP_TOKEN_CAP`). +It is an admission cap: /ship meters usage, reserves every step and dispatch +batch before it starts, and stops `status=incomplete` with a `RECOVER:` line +instead of starting work that would cross the cap. Long runs hand Steps 7–11.5 +and 12–22 to fresh-context children. The flow, the BUDGET line and the result +codes are in [ship-budget.md](ship-budget.md). + ### The outside voice on Capy Step 11's outside adversarial review prefers the Codex CLI. On a Capy machine @@ -225,9 +234,10 @@ and reruns carry with `--pregate .gstack/tmp/pregate.json`. `CARRY: carried from now names HEAD with a `carry` object holding the `git range-diff` evidence, and `ship-run.json` gains `carried_from` and `carry_reason`; the `review_loop` counters are kept, because the reviewed product diff is unchanged. `CARRY: -current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)` -or `(RECEIPT_CARRY_REFUSED)` with the refused result line means the product -diff changed or the reviewed head left HEAD's history, so /ship runs again; +current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`, +`(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused +result line means the product diff changed, or history was rewritten onto a +tree no review saw, so /ship runs again; `(RECEIPT_GIT_TOO_OLD)` means the machine's git predates 2.39 and cannot run `patch-id --verbatim` ([ship-policy.md](ship-policy.md#receipt-carry)). @@ -494,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= <- gate_rev= words="" by= reason=`, +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 ()` (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 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.md`, the accepted changes, + the full plan); the run reserves the estimate in `spend.json` and refuses + when it is over `--carry-check-cap ` (default 5) or the run's cap. + Dispatch it to a model family other than the plan reviewers', then + `check --out --attempt --result --model ` + (`--same-family ""` when no other family is available). A High or + Critical finding, or `--unavailable ""`, leaves the gate for the + owner. The verdict is saved as `carry_check` on `gate.json`, on the + decision rows (`carry_check`) and in `/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 ", followed by any items taken under "do it" +without being shown to the owner. + ## Owner brief `gstack-owner-brief render --out ` is what the owner sees at the final diff --git a/hosts/hermes.ts b/hosts/hermes.ts index 520abbba6..18043b6dc 100644 --- a/hosts/hermes.ts +++ b/hosts/hermes.ts @@ -6,11 +6,14 @@ const hermes = defineHost({ tier: 'experimental', capabilities: { toolExecution: true, questions: 'native', planMode: false, delegation: true, browser: true, safetyHooks: 'advisory' }, - // Hermes indexes skills by frontmatter name, first match wins (#2825). + // Hermes indexes skills by frontmatter name, first match wins (#2825). Its + // skills index shows 57 chars of a description and "..." (agent/skill_utils.py + // SKILL_PROMPT_DESC_LIMIT = 60), so each description leads with when to use it. frontmatter: { mode: 'allowlist', keepFields: ['name', 'description'], descriptionLimit: null, + descriptionLead: 57, nameMatchesDirectory: true, }, diff --git a/hosts/openclaw.ts b/hosts/openclaw.ts index 1b1c431e5..ac9961e2f 100644 --- a/hosts/openclaw.ts +++ b/hosts/openclaw.ts @@ -15,8 +15,9 @@ const openclaw = defineHost({ suppressedResolvers: [...CROSS_MODEL_RESOLVERS, ...GBRAIN_RESOLVERS], // ./setup --host openclaw (and setup run inside OpenClaw) registers through - // gstack-harness: one gstack router skill in $OPENCLAW_STATE_DIR/skills, or - // every skill with --skills all. OpenClaw's ACP route (docs/OPENCLAW.md) stays. + // gstack-harness: the task skills plus one gstack router skill for the rest in + // $OPENCLAW_STATE_DIR/skills (hybrid), or --skills router|all. OpenClaw's ACP + // route (docs/OPENCLAW.md) stays. install: { linkingStrategy: 'symlink-generated', registration: 'harness', diff --git a/lib/approval-record.ts b/lib/approval-record.ts index ed8bcf738..38d798682 100644 --- a/lib/approval-record.ts +++ b/lib/approval-record.ts @@ -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), diff --git a/lib/autoplan-carry.ts b/lib/autoplan-carry.ts new file mode 100644 index 000000000..0e6ae18ef --- /dev/null +++ b/lib/autoplan-carry.ts @@ -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`, `/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 { + 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): 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 (or --unavailable "" 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>([...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 and --model , or --unavailable ""', 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 ""`, 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 }; +} diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index 98b381f4f..1488dbc05 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -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[.v].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:` 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:`, 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:', 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 "" (or --owner-reply - / --owner-reply-file )`, 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 "" (or --owner-reply - / --owner-reply-file )`, 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