From 1f246e3505eac788abf570d1e3f552ef939bee7f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 27 Oct 2026 08:11:13 -0700 Subject: [PATCH 1/2] v1.91.278.0 fix(ship-queue): readiness takes dispatched Free Tests, stamp paths accept tentative key versions, ready and train build read the base overlap, pr-wait --exclude, restamp prints the corrected order (#3514) * fix(ship-queue): readiness takes dispatched Free Tests, prose check reads key=value pairs, stamp paths accept tentative key versions, ready and train build read the base overlap, pr-wait --exclude, restamp prints the corrected order Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Pass 1 of /ship's review on #3514: the prose check skips a key: value pair's value whatever the whitespace, the restamp recompute keeps a merged-not-in-base predecessor and says to fetch, stampOnlyLine applies the version-width bound, pr-wait --exclude excludes nothing while the required checks are unreadable, baseOverlap reports a failed git read as unknown and gives no verdict at train build, readiness lets a pull_request run outrank a dispatched one, and the READY conflict refusal follows the crew template's rule 14 instead of a crew-local rule. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: stamp rule for tentative key versions, key=value prose claims, QUEUE_STALE merged-not-in-base fix Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 20 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-pr-wait | 2 +- bin/gstack-ship-receipt | 2 +- docs/TESTING_INTERNALS.md | 7 ++- docs/pregate.md | 4 +- docs/ship-policy.md | 37 ++++++++++-- docs/standalone-binaries.md | 2 +- docs/troubleshooting.md | 51 ++++++++++++----- lib/pr-wait-github.ts | 14 +++-- lib/pr-wait.ts | 64 +++++++++++++++++---- lib/restamp.ts | 69 ++++++++++++++++++++--- lib/ship-queue-train-ready.ts | 14 ++++- lib/ship-queue-train.ts | 50 ++++++++++++++-- lib/ship-receipt-prose.ts | 18 ++++-- lib/ship-receipt.ts | 25 +++++--- lib/tree-receipt.ts | 11 ++-- package.json | 2 +- scripts/eval-readiness.ts | 26 +++++++-- scripts/train-gate.ts | 28 +++++++-- test/eval-readiness.test.ts | 33 +++++++++++ test/gstack-restamp-queue.test.ts | 33 ++++++++++- test/gstack-ship-receipt-docs.test.ts | 17 ++++++ test/pr-wait.test.ts | 44 ++++++++++++++- test/ship-queue-train-ops.test.ts | 11 ++++ test/ship-queue-train.test.ts | 41 ++++++++++++++ test/ship-receipt-prose.test.ts | 22 ++++++++ test/train-gate.test.ts | 34 +++++++++++ test/tree-receipt-stamp-sensitive.test.ts | 52 ++++++++++++++++- 30 files changed, 653 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d367fdd..7a08871d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.91.278.0] - 2026-10-27 + +**The merge train's tooling stops failing the lanes for things the tooling itself could see.** A head whose only conflict with main was the CHANGELOG got its Free Tests through the dispatch route and the paid screen still called it NOT-RUN; the receipt-versus-prose check read the canonical `REVIEW_LOOP` line as a false mismatch; a PR that writes a new `*_KEY_VERSION` constant as a tentative next-minor version (the convention #3511 introduces) could not clear the train's stamp-only receipt; a pooled pin learned that main had moved onto its files only when the train was built; a watcher had no way to leave a stuck non-required check out of its verdict; and a lane that passed the slot call's remaining order to `gstack-restamp` without its own PR got the same stale list back as the fix. Backlog items from GSTA-119, #3485, #3495 and the 2026-10-27 slot calls. + +### Fixed +- **`scripts/eval-readiness.ts`** (#3508): readiness accepts a `workflow_dispatch` Free Tests run at the exact head beside GitHub's `pull_request` run, so a head the dispatch route of docs/ship-policy.md tested starts its paid screen. GitHub's `pull_request` run decides whenever one exists (it built the merge ref; a dispatch builds the head alone, so a newer green dispatch never overrides a red `pull_request` run and an extra red or running dispatch never holds a head its `pull_request` CI cleared); the newest dispatched run decides only for a head with none, a red one blocks, and a `push` run still counts for nothing. +- **`lib/ship-receipt-prose.ts`** (#3513): a number that is the value of a `key=` or `key:` pair is that key's claim alone, whatever the whitespace around the separator, so `REVIEW_LOOP: passes=2 blocking_found=1 deferred=0 noted=4 fixed=8 scope=frozen` and `passes: 2 blocking_found: 1` parse as five claims that all match the receipt instead of a `2 blocking_found` claim. +- **Stamp paths under the next-minor key convention** (#3512): the tree receipt (`lib/tree-receipt.ts`), the carry (`lib/ship-receipt.ts` `stampOnlyText`) and the train's tree-modulo-stamps digest (`scripts/train-gate.ts` `stampFreeTree`) accept a stamp_paths line whose stamp-form version moved to the released version whatever it was before, not only when it was the base's version. The receipt keeps the stamp_paths stage's own bounds (`lib/restamp.ts` `stampOnlyLine`): a moved version older than the gated head's is a released key moved by hand, and one of another width (a 3-digit pin in a 4-digit repository) is never a stamp candidate, so both stay `changed`. The digest collapses every stamp-form mention, so a tentative key and a base-version mention on a base-side line digest the same before and after the slot stamp and `train-backstop` finds the record that covers main. +- **`gstack-restamp --expect-order`**: the `QUEUE_STALE` refusal prints the corrected command instead of the stale list: this PR first when the list left it out (with the message saying every entry read as a predecessor), PRs merged into the local base dropped, an unmerged predecessor kept ahead with a note that it must merge first, and a predecessor GitHub calls merged that the local base does not carry yet kept ahead too, with `fix: git fetch origin ` (dropping it would let the recompute, pinned to that local base, allocate the version it already took). The base-moved refusal carries `--pr` too. + +### Added +- **`gstack-ship-queue ready` and `train build` read gstack-pr-wait's base-overlap event from git** (handoff GSTA-119; the crew template's sibling trial merge, done by the tool). `ready` prints `BASE_OVERLAP: moved to and touches files in this PR: ; the train's merge onto is clean` (or the conflicting files) when the base moved onto the PR's files since it forked, and under `--no-screen` a conflict is `QUEUE_TRAIN_CONFLICT` at READY rather than at the train, with the template's fix (report it to the controller; merge the base on its word). A git read that fails prints `conflicts unknown` and refuses nothing. `train build` runs the scratch merges before the fill rule. A seated pin whose files the base moved onto gets `# BASE_OVERLAP: moved to and touches files in this PR: ` (the chain's merge onto the pin's own prefix is the verdict), a pin that does not merge is `QUEUE_TRAIN_CONFLICT` while the train is still filling, and a clean pool prints `MERGE_CHECK: T1..T merge clean onto @` ahead of `QUEUE_TRAIN_WAITING`, so the waiting verdict is also a dry-run merge (#3485). +- **`gstack-pr-wait --exclude `** (#3495 follow-up): a named non-required check is left out of the verdict as `gstack-ci-gate --exclude` does, so it neither holds `--until green` while pending nor blocks when it fails; a required check cannot be excluded and the first poll logs the refusal, and while a required-check source cannot be read nothing is excluded (the set may be short), as `gstack-ci-gate` refuses its overrides. + +### Tests +- `test/eval-readiness.test.ts`: a dispatched-only head decides green, red and running, a `pull_request` run outranks a dispatched one either way; the runs listing is no longer narrowed to `pull_request`. `test/ship-receipt-prose.test.ts`: the exact `REVIEW_LOOP` line with `passes=2 blocking_found=1` and with `passes=1 blocking_found=0`, the `key: value` spelling with whitespace, plus a real disagreement on the same line. `test/tree-receipt-stamp-sensitive.test.ts`, `test/train-gate.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: a tentative key moved by the restamp is a stamp in the receipt, the digest and the carry; a renamed key, a released key moved by hand or a mention of another width is not. `test/gstack-restamp-queue.test.ts`: the corrected `--expect-order` in each refusal, the own-PR-first lesson and a merged predecessor the local base does not carry. `test/pr-wait.test.ts`: `--exclude` parsing, pending and failed excluded checks, a refused required name, an unreadable required set, and the flag through `prWaitMain`. `test/ship-queue-train-ops.test.ts` and `test/ship-queue-train.test.ts`: the `BASE_OVERLAP:` lines at READY and at build, the `--no-screen` conflict refusal, and `MERGE_CHECK:` beside `QUEUE_TRAIN_WAITING`. + +### Gate integrity +- No threshold, timeout, trial count or case kind changed. No skill prose changed. + ## [1.91.277.0] - 2026-10-27 **office-hours stops carrying its own copy of the shared AskUserQuestion subsections.** After the shared-preamble hoist, office-hours still inlined whole copies of the shared `### When AskUserQuestion is unavailable or a call fails` and `### Format` subsections, because each held one office-hours addition: the `Q` open-question form. A session's first office-hours invocation read those subsections twice, once from the shared file and once from SKILL.md. The `Q` form and its routing sentence now render as office-hours' own `### Open-ended questions` subsection at the end of the AskUserQuestion section, so the shared subsections apply as they are and office-hours' SKILL.md drops 6,328 bytes (its parity union goes from 160,952 to 154,624, under the pre-hoist 1.18 cap). diff --git a/VERSION b/VERSION index fce37150c..ec63d14a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.277.0 +1.91.278.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index fbf62f29c..109a4f394 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.277.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.278.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/bin/gstack-pr-wait b/bin/gstack-pr-wait index a26655a41..285046d13 100755 --- a/bin/gstack-pr-wait +++ b/bin/gstack-pr-wait @@ -2,7 +2,7 @@ /** * gstack-pr-wait — watch one pull request and print one line per real event. * - * gstack-pr-wait [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES] + * gstack-pr-wait [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES] [--exclude CHECK,...] * * Exit 0 when the --until condition is met or the PR merged, 1 when it needs * action (PR_WAIT_BLOCKED), 2 on bad input, a duplicate watcher or a local diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 533262e14..3614720be 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -446,7 +446,7 @@ function cmdCarry(rest: string[], json: boolean): number { 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 } : undefined) })); + 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}`); diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index cc35d83fb..d85b1656d 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -1236,7 +1236,12 @@ GitHub-hosted restore never sees (until GSTA-48 every restore missed). **Readiness (R1).** Paid slices for a PR head start only when the PR has been quiet for the window (repository variable `EVALS_QUIET_MINUTES`, default 10, measured from the run's creation) and Free Tests has concluded success on the -same head SHA. The `readiness` job (`scripts/eval-readiness.ts`) sleeps out the +same head SHA, as GitHub's `pull_request` run or, for a head with none, as a +`workflow_dispatch` run on the branch (the dispatch route of docs/ship-policy.md +for a head whose only conflict with the base is the CHANGELOG, which gets no +`pull_request` CI; the `pull_request` run built the merge ref, so it decides +whenever it exists, a dispatched run decides only without one, and a red +dispatched run blocks like any other, #3508). The `readiness` job (`scripts/eval-readiness.ts`) sleeps out the window with no API calls, since a newer push cancels the run anyway, then polls Free Tests every minute for up to 80 minutes. Free Tests red on the head fails the run without paid work; re-run Free Tests, then re-run this workflow (a diff --git a/docs/pregate.md b/docs/pregate.md index 80f72bd3d..a1bf4619e 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -456,7 +456,9 @@ receipt for H when the two trees differ only as a stamp does: that does not account for every path `git diff-tree` lists between the two trees. The version rule is the tree receipt's: a `stamp_files`, `stamp_paths` or `release_outputs` hunk is a stamp - only as the rewrite from the gated tree's version to the head's (so two trees + only as the rewrite to the head's version from the gated tree's version, or + from a newer tentative version of the repo's width that the stamp moved (a + `*_KEY_VERSION` written as the next minor, #3512) (so two trees at the same version refuse such a hunk as `other`), while the built-in version-file, manifest, lockfile and CHANGELOG rules compare content and do not check the direction of the version move. The obligation stays uncleared; rerun diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 72306e5a0..552730afa 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -425,7 +425,18 @@ conflicts with the base, CHANGELOG-only conflicts included) is replaced by a scratch merge of the head onto the base, pushed as `screen/-` (the `SCREEN_MERGE:` line says which); a screen dispatched on a stale ref would plan no cases and report success. Only a real conflict with the base (or a -remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`. A finished screen that planned no cases (no +remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`. Under either +choice `ready` first reads what `gstack-pr-wait`'s base-overlap event reports, +from git: when the base moved since the head forked and its new commits touch +files the PR changes, `BASE_OVERLAP: moved to and touches files +in this PR: ; the train's merge onto is clean` (or `conflicts +beyond the CHANGELOG and the stamp files ()`), the train's own merge +rules run as a dry run in object storage; a git read that fails prints +`conflicts unknown` and refuses nothing. A conflict under `--no-screen` is +`QUEUE_TRAIN_CONFLICT` at READY, since the train would stop at this PR: the +lane reports it to the controller and, on its word (crew template rule 14), +merges the base into the pin, resolves, pushes and runs /ship before READY, +instead of learning it when the train is built. A finished screen that planned no cases (no `paid-plan` artifact, which a superseded run never uploads, or a plan manifest with nothing planned) is never followed as a screen: `ready --screen` dispatches a fresh one and `train build` prints `SCREEN: # run planned @@ -453,7 +464,14 @@ and waits (`--free-timeout `, default 2700) for a green through eval-bg's nonce. A train fills at `max_size` READY PRs or when its oldest READY has waited `fill_hours`, whichever comes first; until then `QUEUE_TRAIN_WAITING` names the time, and `--urgent` dispatches a one-PR -train at once. gstack's own block fills at 3 PRs or one hour (it was 5 PRs or +train at once. The scratch merges run before the fill rule: every `train +build` prints `# BASE_OVERLAP: moved to and touches files in +this PR: ` for a seated pin whose files the base moved onto since it +forked (the chain's merge onto the pin's own prefix is the verdict), refuses +`QUEUE_TRAIN_CONFLICT` for a pin that does not merge, and otherwise prints +`MERGE_CHECK: T1..T merge clean onto @`, so a +`QUEUE_TRAIN_WAITING` result is also a clean dry-run merge of the pool (the +crew template's sibling trial merge, done by the tool). gstack's own block fills at 3 PRs or one hour (it was 5 PRs or three hours until 2026-10-26, which left READY PRs waiting most of a day). The same pins on the same base return the live train instead of a second one; `train build --train ` resumes a train from its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork @@ -708,7 +726,9 @@ integers under `review_loop`, `gate` and `ci`: `passes=2`, `passes: 2` and several counters, `passed` under `gate` and `ci` or `pass` under every gate lane, matches when the number equals any of them; an underscore in a key matches `_`, a space or `-`; a name inside a longer word, `3 pass-through`, is -not a claim). A body that quotes a different number is +not a claim; a number that is the value of a `key=` or `key:` pair is that +key's claim alone, so `passes=2 blocking_found=1` is two claims, never a +`2 blocking_found` one, #3513). A body that quotes a different number is `RECEIPT_PROSE_MISMATCH` (exit 1) and the `PROSE:` line names each field with both values; prose that quotes no counter is not a claim. Data PRs (evals, measurements, sweeps) run it before READY so the body's numbers are the run's. @@ -798,7 +818,11 @@ dependency, a script or a new key makes them product). A path the policy's `stamp_files`, `stamp_paths` or `release_outputs` name is metadata only while its change is a stamp, the rule the tree receipt applies to the same paths: a manifest by its version key, any other file when its new text reads as the old -once the new release version is replaced by the old one. A logic change in a +once the new release version is replaced by the old one, or when every changed +line is a stamp-form rewrite to the new version of the old version or of a +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 @@ -940,7 +964,10 @@ only, where `gstack-version-bump` writes; a dependency entry is `changed`), the CHANGELOG (a heading move or re-heading, compared as an order-independent multiset of entries), `stamp_paths` and `release_outputs` (every removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite (a quote around X in the first two forms included) with -X the gated version and the replacement the current one, so a dependency pin +X the gated version, or a newer version of the repo's width that the +stamp_paths stage moves (a tentative `*_KEY_VERSION` written as the next +minor, #3512; a version older than the gated one, or of another width, is a +hand edit), and the replacement the current one, so a dependency pin that happens to equal the old version is `changed`; `stamp_patterns` templates count as stamp forms too). With `stamp_files` set, a file outside it is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever diff --git a/docs/standalone-binaries.md b/docs/standalone-binaries.md index 9307428c4..2aab76988 100644 --- a/docs/standalone-binaries.md +++ b/docs/standalone-binaries.md @@ -18,7 +18,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-review-log` | **Review-pass receipts** — `--start ` captures the working-tree fingerprint before a diff review; `'' --finish ` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs ` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. | | `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). | | `gstack-evidence` | **Verification-evidence ledger** — `run --label -- ` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint; `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. | -| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete. Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). | +| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60] [--exclude CHECK,...]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete; `--exclude` leaves a named non-required check out of the verdict as `gstack-ci-gate --exclude` does (a required name is refused and gates). Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). | | `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue `, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. | | `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](howto-ios-testing-with-gstack.md). | | `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e4199286f..04255e7bb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2708,11 +2708,19 @@ code (you already do when you run its tests), or drop the release tool. **Meaning.** `gstack-restamp` was told the base SHA (`--expect-base`) or the merge order (`--expect-order`) the allocation assumed, and one of them moved: the base advanced, a predecessor is not merged, or a successor merged first. -The JSON result carries `expected`, `actual` and `recompute`. +`--expect-order` lists the lane's own PR (`--pr`) first, then the remaining +order; a list that leaves it out reads every entry as an unmerged predecessor, +and the message says so. The JSON result carries `expected`, `actual` and +`recompute`. -**Fix.** Run the printed `recompute` command, which recomputes the version -from the current base, then merge with the server-side head condition -(`MERGE_CONDITION: sha=`). +**Fix.** Run the printed `recompute` command: it carries the corrected order +(this PR first, merged PRs dropped, an unmerged predecessor kept ahead because +it must merge first) and recomputes the version from the current base; then +merge with the server-side head condition (`MERGE_CONDITION: sha=`). A +predecessor GitHub calls merged that the local base does not carry yet +(`#=merged-not-in-base`) is kept ahead too, with `fix: git fetch origin +`: fetch, then rerun, so the recompute, pinned to that local base, never +allocates the version the predecessor already took. ### `RESTAMP_VERSION_SOURCE` @@ -4174,13 +4182,19 @@ with the new pin (`train build --prs @,…`). ### `QUEUE_TRAIN_CONFLICT` **Meaning.** Merging a PR onto the prefix before it conflicts outside the -version files and the CHANGELOG. The `QUEUE_TRAIN_CONFLICT:` line names the -PR, the conflicting files and the PRs merged before it. Nothing was -dispatched and no branch was pushed. +version files and the CHANGELOG. From `train build`, the +`QUEUE_TRAIN_CONFLICT:` line names the PR, the conflicting files and the PRs +merged before it, and the check runs before the fill rule, so a filling train +finds it too. From `ready --no-screen`, the prefix is the base itself: the +`BASE_OVERLAP:` line names the files the base moved onto since the head +forked, and the train would stop at this PR (under `--screen` the same +conflict is `QUEUE_SCREEN_STALE_REF`). Nothing was dispatched and no branch +was pushed. -**Fix.** Run the printed `train build --prs `. The PR's -lane merges main (with the earlier PRs once they land) and reports READY -again. +**Fix.** From `train build`, run the printed `train build --prs `. The PR's lane reports the conflict to the controller and, on its +word (crew template rule 14), merges main into the pin (with the earlier PRs +once they land), resolves, pushes, runs /ship and reports READY again. ### `QUEUE_TRAIN_FREE_RED` @@ -4209,7 +4223,11 @@ run carrying the nonce, or marks the dispatch absent; only then does **Meaning.** The train has fewer PRs than `train.max_size` and its oldest READY PR has waited less than `train.fill_hours`, so it does not dispatch -yet. The message prints when it will. +yet. The message prints when it will. The scratch merges already ran: the +`MERGE_CHECK: T1..T merge clean onto @` line above it says every +seated pin merges in slot order (a pin that does not is `QUEUE_TRAIN_CONFLICT` +instead), and a `# BASE_OVERLAP:` line names a pin whose files the base +moved onto since it forked. **Fix.** Add READY PRs, rerun after the printed time, or pass `--urgent` for a one-PR train. @@ -4688,14 +4706,21 @@ reason }` and carry the `Documentation exception: accepted by via ## gstack-pr-wait `gstack-pr-wait [--repo owner/name] [--until green|ready|any-change] -[--interval SECONDS] [--timeout MINUTES]` watches one pull request and prints +[--interval SECONDS] [--timeout MINUTES] [--exclude CHECK,...]` watches one pull request and prints one stdout line per real event, prefixed `#`; progress and recoverable errors go to stderr. `/land-and-deploy` Step 3 runs it with `--until green`. Exit 0 means the `--until` condition is met or the PR merged; exit 1 and 2 end with one of the two codes below. `green` gates on every check on the head, as `gstack-ci-gate` does, so the two never disagree; a check the base branch requires (rulesets and classic branch protection) counts as pending -until it appears. A 403 on the classic +until it appears. `--exclude ` leaves a named non-required check +out of the verdict, as `gstack-ci-gate --exclude` does: it neither holds +`green` while pending nor blocks when it fails, and the first poll logs +`excluded from the verdict: `; a required check cannot be excluded +(`exclude refused for required check `) and gates, and while a +required-check source cannot be read nothing is excluded (`exclude refused for +: the required checks could not be read`), as `gstack-ci-gate` refuses +its overrides. A 403 on the classic protection read (a token without admin scope) is logged and skipped, and the rulesets read still names the required checks. The review-bot lists are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope` diff --git a/lib/pr-wait-github.ts b/lib/pr-wait-github.ts index fa8eda600..e999e9cf5 100644 --- a/lib/pr-wait-github.ts +++ b/lib/pr-wait-github.ts @@ -193,24 +193,28 @@ export function fetchChecks(env: GhEnv, repo: string, pr: number): Map { +export function requiredChecks(env: GhEnv, repo: string, base: string): { names: Set; known: boolean } { const sources: Array<[string, string]> = [ [`repos/${repo}/rules/branches/${base}`, '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context'], [`repos/${repo}/branches/${base}/protection/required_status_checks`, '.contexts[]?, .checks[]?.context'], ]; const names = new Set(); + let known = true; for (const [path, jq] of sources) { try { for (const name of ghLines(env, ['api', path, '--jq', jq])) names.add(name); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (!/HTTP 404/.test(message)) env.log(`could not read required checks from ${path}: ${message}`); + if (/HTTP 404/.test(message)) continue; + known = false; + env.log(`could not read required checks from ${path}: ${message}`); } } - return names; + return { names, known }; } /** Files the PR changes, from `gh pr diff --name-only`. */ diff --git a/lib/pr-wait.ts b/lib/pr-wait.ts index 557b90906..b9462a1de 100644 --- a/lib/pr-wait.ts +++ b/lib/pr-wait.ts @@ -46,12 +46,22 @@ export interface PrWaitEnv { pidAlive(pid: number): boolean; } -export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number } +export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number; excludes: string[] } export interface BotLists { gating: string[]; advisory: string[] } interface State { reported: string[]; summaries: Record; base_oid: string | null; merge_state: string | null } -export const PR_WAIT_USAGE = `gstack-pr-wait [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES] +/** + * The base-overlap event, one shape for the watcher and for `gstack-ship-queue + * ready` / `train build`, which read the same fact from git (handoff + * GSTA-119): the base moved and its new commits touch files this PR changes. + */ +export function baseOverlapLine(base: string, baseOid: string, touched: readonly string[]): string { + const more = touched.length > 5 ? ` and ${touched.length - 5} more` : ''; + return `${base} moved to ${baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`; +} + +export const PR_WAIT_USAGE = `gstack-pr-wait [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES] [--exclude CHECK,...] Watches one pull request and prints one line per real event on stdout (prefixed #), so a monitor wakes the agent once per event. Progress and recoverable @@ -69,6 +79,10 @@ comes from the URL or the current checkout. --timeout minutes before a watch that met nothing ends as PR_WAIT_BLOCKED, naming what is still pending (default 60; 0 waits without limit) +--exclude check names (comma-separated, repeatable) left out of the verdict, + as gstack-ci-gate --exclude does: a named check neither holds green + while pending nor blocks when it fails. A check the base branch + requires cannot be excluded; the name is logged as refused and gates Events: a check failed (name and link); all checks finished on the head (one summary line); a comment, review, thread or thread reply from someone @@ -101,7 +115,7 @@ lock, which the next watcher for the PR takes over once the pid is gone. Docs: docs/troubleshooting.md#gstack-pr-wait`; export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true } | { error: string } { - const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES }; + const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES, excludes: [] }; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; if (flag === '--help' || flag === '-h') return { help: true }; @@ -124,6 +138,12 @@ export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true } opts.timeout = Number(value); continue; } + if (flag === '--exclude') { + const names = (argv[++i] ?? '').split(',').map(s => s.trim()).filter(Boolean); + if (!names.length) return { error: '--exclude needs one or more check names, comma-separated' }; + for (const name of names) if (!opts.excludes.includes(name)) opts.excludes.push(name); + continue; + } if (flag.startsWith('-') && !/^-?\d+$/.test(flag)) return { error: `unknown argument ${flag}` }; if (opts.target !== undefined) return { error: `unexpected argument ${flag}` }; opts.target = flag; @@ -185,12 +205,14 @@ export class Watcher { /** First run for this PR: record what exists and report nothing. */ baseline: boolean; private required: Set | null = null; + /** False when a required-check source could not be read: the set may be short, so no --exclude name is excluded. */ + private requiredKnown = true; private prevSnapshot: string | null = null; private prFiles = new Map>(); private pending: string[] = []; private readonly gh: GhEnv; - constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES) { + constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES, readonly excludes: readonly string[] = []) { const stem = path.join(stateDir(env.vars), `${repo.replace('/', '__')}__${pr}`); this.stateFile = `${stem}.json`; this.lockFile = `${stem}.lock`; @@ -295,11 +317,22 @@ export class Watcher { return count === null ? '' : `; ${count} commit${count === '1' ? '' : 's'} behind ${base}`; } - private evaluateChecks(checks: Map, head: string) { + /** + * `--exclude` names that are not required checks: the ones the verdict leaves out, as gstack-ci-gate does. While the + * required set is unknown (a source could not be read) nothing is excluded, as gstack-ci-gate refuses its overrides. + */ + private excluded(): Set { + return new Set(this.requiredKnown ? this.excludes.filter(name => !this.required?.has(name)) : []); + } + + private evaluateChecks(all: Map, head: string) { const required = this.required ?? new Set(); // Every check on the head gates, as gstack-ci-gate does, so land-and-deploy's two gates never // disagree; a required check gates even before it appears. An advisory bot's review is never - // waited for, but its check is a check like any other. + // waited for, but its check is a check like any other. A --exclude name that is not required + // is left out of the verdict; a required one cannot be excluded and gates. + const excluded = this.excluded(); + const checks = new Map([...all].filter(([name]) => !excluded.has(name))); const watched = checks; const gate = new Set([...required, ...watched.keys()]); const bucketOf = (name: string) => checks.get(name)?.bucket ?? 'pending'; @@ -392,10 +425,7 @@ export class Watcher { const oldBase = this.state.base_oid; if (pr.baseOid && oldBase && pr.baseOid !== oldBase && !this.baseline) { const touched = this.baseOverlap(head, oldBase, pr.baseOid); - if (touched.length > 0) { - const more = touched.length > 5 ? ` and ${touched.length - 5} more` : ''; - this.emit(`base:${pr.baseOid}`, `${base} moved to ${pr.baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`); - } + if (touched.length > 0) this.emit(`base:${pr.baseOid}`, baseOverlapLine(base, pr.baseOid, touched)); } if (pr.baseOid) this.state.base_oid = pr.baseOid; const mergeState = pr.mergeStateStatus; @@ -418,9 +448,19 @@ export class Watcher { if (pr.state === 'CLOSED') return this.finish(PR_WAIT_EXIT.blocked, [`closed without merging: ${pr.url}`]); const base = pr.baseRefName; if (this.required === null) { - this.required = requiredChecks(this.gh, this.repo, base); + const read = requiredChecks(this.gh, this.repo, base); + this.required = read.names; + this.requiredKnown = read.known; const gate = [...this.required].sort().join(', ') || 'none set'; this.log(`watching ${this.repo}#${this.pr} until ${this.until}; required checks: ${gate}; every check on the head gates`); + if (this.excludes.length && !this.requiredKnown) { + this.log(`exclude refused for ${this.excludes.join(', ')}: the required checks could not be read, so every check gates`); + } else if (this.excludes.length) { + const refused = this.excludes.filter(name => this.required!.has(name)); + const dropped = this.excludes.filter(name => !this.required!.has(name)); + if (dropped.length) this.log(`excluded from the verdict: ${dropped.join(', ')}`); + if (refused.length) this.log(`exclude refused for required check${refused.length === 1 ? '' : 's'} ${refused.join(', ')}: a required check gates`); + } } const checks = fetchChecks(this.gh, this.repo, this.pr); const head = pr.headRefOid; @@ -530,7 +570,7 @@ export function prWaitMain(argv: string[], env: PrWaitEnv): number { if ('error' in parsed) return refuse(env, parsed.error); const target = resolveTarget(env, parsed); if ('error' in target) return refuse(env, target.error); - const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout); + const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout, parsed.excludes); // Exit 1 always carries a PR_WAIT_BLOCKED line, so a local fault (an unwritable state root, a full disk) is a refusal, never a verdict. try { const lock = watcher.lock(); diff --git a/lib/restamp.ts b/lib/restamp.ts index 852a99ed8..666d8c73f 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -128,12 +128,33 @@ function queueOrderCheck(o: RestampOptions, baseSha: string): void { actual[pr] = merged ? (inBase ? 'merged' : 'merged-not-in-base') : String(info.state ?? 'unknown').toLowerCase(); } const stale = [...ahead.filter(pr => actual[pr] !== 'merged'), ...behind.filter(pr => actual[pr]?.startsWith('merged'))]; - if (stale.length) { - throw new Refusal('QUEUE_STALE', `queue order moved: ${stale.map(pr => `#${pr}=${actual[pr]}`).join(', ')}`, { - expected: { order: o.expectOrder, base: o.expectBase ?? null }, actual: { prs: actual, base: baseSha }, - recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha}${mine ? ` --pr ${mine}` : ''} --expect-order ${o.expectOrder.join(',')}`, - }); - } + if (!stale.length) return; + // The recompute command is the corrected order, never the stale list again: this PR first (a list that left it out read + // every entry as a predecessor), PRs merged into the local base dropped, an unmerged predecessor kept ahead because it + // still has to merge first. A predecessor GitHub calls merged that the local base does not carry stays ahead too: the + // recompute pins --expect-base to that local base, and dropping the PR would let it allocate the version the + // predecessor already took (pre-landing review, pass 1); the lane fetches the base and the entry reads merged. + const corrected = correctedOrder(o.expectOrder, mine, actual); + const waitingOn = ahead.filter(pr => actual[pr] !== 'merged' && actual[pr] !== 'merged-not-in-base'); + const unfetched = ahead.filter(pr => actual[pr] === 'merged-not-in-base'); + const why = mine && idx < 0 ? `; --pr ${mine} is not in --expect-order, so every listed PR read as a predecessor: list this PR first, then the remaining order` + : [waitingOn.length ? `${waitingOn.map(pr => `#${pr}`).join(', ')} must merge before this PR stamps` : '', + unfetched.length ? `${unfetched.map(pr => `#${pr}`).join(', ')} merged but the local ${o.base} does not carry the merge yet: git fetch origin ${o.base}, then rerun` : ''] + .filter(Boolean).map(s => `; ${s}`).join(''); + throw new Refusal('QUEUE_STALE', `queue order moved: ${stale.map(pr => `#${pr}=${actual[pr]}`).join(', ')}${why}`, { + expected: { order: o.expectOrder, base: o.expectBase ?? null }, actual: { prs: actual, base: baseSha }, + recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.slice(0, 12)}${mine ? ` --pr ${mine}` : ''} --expect-order ${corrected.join(',')}`, + ...(unfetched.length ? { fix: `git fetch origin ${o.base}` } : {}), + }); +} + +/** + * The --expect-order to run next: this PR first when the list left it out, every PR merged into the local base dropped, + * the rest (open PRs and merges the local base does not carry yet) in their order. + */ +export function correctedOrder(order: readonly string[], mine: string | null, actual: Record): string[] { + const rest = order.filter(pr => actual[pr] !== 'merged'); + return mine && !order.includes(mine) ? [mine, ...rest] : rest; } interface Plan { @@ -148,7 +169,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri if (o.expectBase && !baseSha.out.startsWith(o.expectBase)) { throw new Refusal('QUEUE_STALE', `base moved: expected ${o.expectBase}, ${baseRef} is ${baseSha.out}`, { expected: { base: o.expectBase }, actual: { base: baseSha.out }, - recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.out.slice(0, 12)}${o.expectOrder?.length ? ` --expect-order ${o.expectOrder.join(',')}` : ''}`, + recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.out.slice(0, 12)}${o.pr ? ` --pr ${o.pr}` : ''}${o.expectOrder?.length ? ` --expect-order ${correctedOrder(o.expectOrder, o.pr ?? null, {}).join(',')}` : ''}`, }); } queueOrderCheck(o, baseSha.out); @@ -313,6 +334,40 @@ export function tentativeMentions(added: Map>, forkVersion: return [...found]; } +/** The versions one line mentions in a stamp form (the built-in forms and the policy's `stamp_patterns` templates). */ +export function stampMentions(text: string, templates: readonly string[] = []): Set { + const found = new Set(); + for (const re of [...BUILT_IN_MENTIONS, ...templates.map(templateMention)]) for (const m of text.matchAll(re)) found.add(m.groups!.v!); + return found; +} + +/** + * A removed/added line pair that a restamp alone explains: every version the + * removed line mentions in a stamp form and the added line no longer does + * moves to `newV`, and the rewritten line is the added line byte for byte. + * The moved versions are the ones the stamp_paths stage moves, not only the + * old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = ''` never equals the base version at the slot and is a stamp all the + * same (#3512). The stage's own bound applies: a tentative mention is newer + * than the version the branch was written against, so a moved version older + * than `oldV` (a released key moved by hand) is not a stamp, and neither is + * one of another width (`tentativeMentions`: a 3-digit dependency pin in a + * 4-digit repo is never a candidate, so moving it is a hand edit). A line that + * moves nothing, or changes anything beyond those mentions, is not one either. + */ +export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean { + if (removed === added) return false; + const floor = parseVersion(oldV); + const width = versionWidth(oldV); + const after = stampMentions(added, templates); + const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v)); + if (!moved.length || !floor) return false; + if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false; + let text = removed; + for (const v of moved) text = rewriteStamps(text, v, newV, templates).text; + return text === added; +} + /** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */ export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string { const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim(); diff --git a/lib/ship-queue-train-ready.ts b/lib/ship-queue-train-ready.ts index 736b74809..bbb4cbda0 100644 --- a/lib/ship-queue-train-ready.ts +++ b/lib/ship-queue-train-ready.ts @@ -28,7 +28,7 @@ import { dispatchCi } from './ship-queue-dispatch-ci'; import { recordVerdict, screenVerdict, verdictLines, type ScreenOutcome } from './ship-queue-screen'; import { execFor, repoSlug } from './ship-queue-train-github'; import { loadReady, nowIso, persist, readyPath, rebuildFromNotes, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record'; -import { fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; +import { baseOverlap, fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; const Q = 'gstack-ship-queue'; @@ -181,6 +181,18 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise !r.ok)?.out.split('\n')[0] || 'git read failed'; + return { ...none, unknown: true, line: `BASE_OVERLAP: could not compare ${base}@${baseSha.slice(0, 12)} with ${head.slice(0, 12)} (${why}); conflicts unknown` }; + } + const changed = new Set(theirs.out.split('\n').filter(Boolean)); + const touched = ours.out.split('\n').filter(f => f && changed.has(f)).sort(); + if (!touched.length) return { ...none, line: `BASE: ${base} moved to ${baseSha.slice(0, 7)} since this PR forked; its new commits touch no file this PR changes` }; + if (!verdict) return { touched, conflicts: null, unknown: false, line: `BASE_OVERLAP: ${baseOverlapLine(base, baseSha, touched)}` }; + const m = mergeStep(repoRoot, baseSha, head, policy); + const conflicts = 'conflicts' in m ? m.conflicts : null; + const said = conflicts ? `the train's merge onto ${base} conflicts beyond the CHANGELOG and the stamp files (${conflicts.join(', ')})` : `the train's merge onto ${base} is clean`; + return { touched, conflicts, unknown: false, line: `BASE_OVERLAP: ${baseOverlapLine(base, baseSha, touched)}; ${said}` }; +} + const TRAIN_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue', GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' }; @@ -353,12 +387,15 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor `ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`, ...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []), ]; - const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair); - if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none'); const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out; const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) })); - const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed'); - if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines }; + // The scratch merges run before the fill rule (#3485): a QUEUE_TRAIN_WAITING result is also a clean dry-run merge of + // every seated pin, and a pin the base moved onto is named (files only; the chain below is the merge verdict) before the + // train is refused for it. + for (const s of order.train) { + const overlap = baseOverlap(env.ctx.repoRoot, env.ctx.base, baseSha, s.head, env.policy.policy, false); + if (overlap.touched.length || overlap.unknown) lines.push(`#${s.pr} ${overlap.line}`); + } const chain = buildChain(env.ctx.repoRoot, baseSha, order.train, env.policy.policy); if ('stoppedAt' in chain) { const without = order.train.filter(s => s.pr !== chain.stoppedAt); @@ -366,6 +403,11 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor `QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build ${prsArgs(without, !!explicit)}`], without.length ? `${Q} train build ${prsArgs(without, !!explicit)}` : 'none', 'none'); } + lines.push(`MERGE_CHECK: T1..T${order.train.length} merge clean onto ${env.ctx.base}@${baseSha.slice(0, 12)} (dry run in object storage; nothing pushed)`); + const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair); + if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none'); + const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed'); + if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines }; const head = chain.prefixes.at(-1)!; const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' }); const now = nowIso(); diff --git a/lib/ship-receipt-prose.ts b/lib/ship-receipt-prose.ts index 471cc8e26..6762fae62 100644 --- a/lib/ship-receipt-prose.ts +++ b/lib/ship-receipt-prose.ts @@ -34,7 +34,9 @@ const ESCAPE = /[.*+?^${}()|[\]\\]/g; * is not a `pass` claim), compared with the receipt. A leaf key shared by * several counters (`pass` under every gate lane, `passed` under gate and ci) * names any of them: the claim matches when its number equals one of those - * counters, and a mismatch names the first of them. + * counters, and a mismatch names the first of them. A number that is the value + * of a `key=` or `key:` pair is that key's claim alone, never the start of a + * ` ` claim about the next key (#3513). */ export function checkReceiptProse(receipt: Record, body: string): ProseCheck { const prose = body.replace(BLOCK_RE, ''); @@ -43,12 +45,20 @@ export function checkReceiptProse(receipt: Record, body: string const leaf = field.split('.').at(-1)!; byLeaf.set(leaf, [...(byLeaf.get(leaf) ?? []), { field, value }]); } + const pattern = (leaf: string) => leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]'); + // The number-then-name branch must not start on a `key=value` or `key: value` pair's value: in `passes=2 blocking_found=1` + // and `passes: 2 blocking_found: 1` the 2 is passes' claim, not a "2 blocking_found" one. The offsets of every counter + // key's value are collected first, whatever the whitespace around the separator, and a claim starting there is skipped. + const values = new Set(); + for (const leaf of byLeaf.keys()) for (const m of prose.matchAll(new RegExp(`(? c.value === n)) mismatches.push({ field: counters[0]!.field, prose: n, receipt: counters[0]!.value }); diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index c24251dc1..298484cd4 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -11,6 +11,7 @@ import { approvalSummary } from './approval-citations'; import { renderIssueLines } from './issue-links'; import { matchesAny } from './ship-policy'; import { stampFileKind, versionStampOnly } from './pregate/stamp-only'; +import { stampOnlyLine } from './restamp'; export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array = [ @@ -165,22 +166,32 @@ export interface CarryPlanInput { export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] } export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string }; -/** The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else `package.json`). */ -export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null } +/** + * The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else + * `package.json`) and the base policy's `stamp_patterns` templates. + */ +export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] } /** * A policy-named stamp path's change is a stamp when the new text reads as the - * old text once every new version in it is the old one: the rule the tree receipt - * applies to the same `stamp_paths`, `stamp_files` and `release_outputs` before - * the train accepts a stamp commit. No version change, or a file missing on - * either side, is not a stamp. + * old text once every new version in it is the old one, or when every changed + * line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old + * version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule + * the tree receipt applies to the same `stamp_paths`, `stamp_files` and + * `release_outputs` before the train accepts a stamp commit. No version + * change, or a file missing on either side, is not a stamp. */ export function stampOnlyText(change: CarryChange): boolean { const { before, after, oldVersion, newVersion } = change; if (before === null || after === null || !oldVersion || !newVersion || oldVersion === newVersion) return false; // Digit-bounded: replacing 1.2.3 inside 11.2.3 or 1.2.34 would read a different version's stamp as this one's. const escaped = newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return after.replace(new RegExp(`(? i).filter(i => was[i] !== now[i]); + return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [])); } /** diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index 12ce5641a..2451bcae6 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -15,7 +15,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { normalizeChangelog } from './changelog-check'; import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle'; -import { parseUnifiedDiff, rewriteStamps, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp'; +import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp'; import type { ResultCodeName } from './result-codes'; import { matchesAny, type ShipPolicy } from './ship-policy'; @@ -49,15 +49,18 @@ function blob(cwd: string, treeish: string, rel: string): string | null { return r.status === 0 ? r.stdout : null; } -/** Every removed/added pair differs only by the exact stamp rewrites (old -> new). */ +/** + * Every removed/added pair differs only by stamp rewrites to the new version: + * the old version, or a tentative one (newer than it) the stamp_paths stage + * moved (#3512), each in a stamp form (`stampOnlyLine`). + */ function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null { for (const h of hunks) { if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at }; for (let i = 0; i < h.removed.length; i++) { const r = h.removed[i]!; const a = h.added[i]!; - const rewritten = rewriteStamps(r, oldV, newV, templates); - if (rewritten.count === 0 || rewritten.text !== a) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; + if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; } } return null; diff --git a/package.json b/package.json index 7fcbbf58c..c42be4052 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.277", + "version": "1.91.278", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/eval-readiness.ts b/scripts/eval-readiness.ts index 81f208092..01b768047 100644 --- a/scripts/eval-readiness.ts +++ b/scripts/eval-readiness.ts @@ -5,7 +5,8 @@ * head's paid slices start only after (1) the PR has been quiet for the * window (no newer push since this run was created; `vars.EVALS_QUIET_MINUTES`, * default 10; the `evals-no-debounce` label sets it to 0) and (2) Free Tests - * (free-tests.yml) has concluded success on the same head SHA. Selection, + * (free-tests.yml) has concluded success on the same head SHA, as GitHub's + * pull_request run or as a workflow_dispatch run on the branch. Selection, * trials and verdict rules are unchanged: only the start time moves. * * A newer push cancels the run through the workflow's cancel-in-progress @@ -40,9 +41,25 @@ export type ReadinessDecision = | { kind: 'superseded'; by: string } | { kind: 'blocked'; reason: string }; -/** The newest Free Tests pull_request run on this exact head decides; a rerun updates that run in place. */ +/** + * The Free Tests run events that count as this head's CI, in order of + * precedence: GitHub's own pull_request run (it builds the merge ref, the tree + * a merge would test), then a workflow_dispatch run on the branch (the + * dispatch route in docs/ship-policy.md for a head whose only conflict with + * the base is the CHANGELOG, which GitHub builds no merge ref for and so never + * runs pull_request CI on; #3508). A dispatched run builds the head alone, so + * it decides only for a head with no pull_request run: it never overrides a red + * pull_request run (rerun that run), and a lane's extra dispatch never holds a + * head its pull_request CI cleared. A push run is a branch build, not this + * head's gate. + */ +export const FREE_TESTS_EVENTS: readonly string[] = ['pull_request', 'workflow_dispatch']; + +/** The newest Free Tests run on this exact head under the first event present decides; a rerun updates that run in place. */ export function freeTestsState(runs: FreeTestsRun[], headSha: string): FreeTestsState { - const newest = runs.filter(run => run.head_sha === headSha && run.event === 'pull_request').sort((a, b) => b.id - a.id)[0]; + const atHead = runs.filter(run => run.head_sha === headSha); + const event = FREE_TESTS_EVENTS.find(e => atHead.some(run => run.event === e)); + const newest = atHead.filter(run => run.event === event).sort((a, b) => b.id - a.id)[0]; if (!newest) return { state: 'missing' }; if (newest.status !== 'completed') return { state: 'running', runId: newest.id }; if (newest.conclusion === 'success') return { state: 'success', runId: newest.id }; @@ -111,8 +128,9 @@ export function waitForReadiness(o: WaitOptions): { decision: ReadinessDecision; if (quietUntil > now()) sleep(quietUntil - now()); let lastError = ''; for (;;) { + // Every event at the head; freeTestsState keeps the pull_request and workflow_dispatch runs. const listed = ghJson<{ workflow_runs?: FreeTestsRun[] }>(gh, ['api', '-X', 'GET', `repos/${o.repo}/actions/workflows/free-tests.yml/runs`, - '-f', `head_sha=${o.headSha}`, '-f', 'event=pull_request', '-f', 'per_page=20']); + '-f', `head_sha=${o.headSha}`, '-f', 'per_page=20']); const pr = ghJson<{ head?: { sha?: string } }>(gh, ['api', `repos/${o.repo}/pulls/${o.pr}`]); lastError = !listed.ok ? listed.error : !pr.ok ? pr.error : ''; if (!lastError && listed.ok && pr.ok) { diff --git a/scripts/train-gate.ts b/scripts/train-gate.ts index 8c38c2fd0..b5b433feb 100644 --- a/scripts/train-gate.ts +++ b/scripts/train-gate.ts @@ -26,7 +26,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { normalizeChangelog } from '../lib/changelog-check'; import { normalizeLockfile } from '../lib/evidence-bundle'; -import { rewriteStamps, versionAtRef } from '../lib/restamp'; +import { stampMentions, rewriteStamps, versionAtRef } from '../lib/restamp'; import { loadPolicyFromHead, matchesAny, type ShipPolicy } from '../lib/ship-policy'; import type { BisectPrefixRecord, TrainGateRecord } from '../lib/ship-queue-train-verdict'; import type { CasePanel } from '../lib/ship-queue-train-record'; @@ -132,20 +132,38 @@ export function fullGateCases(rootDir = ROOT): Map { return plannedCases(buildRunManifest({ tier: 'gate', profile: 'full', sliceCount: 1, evalsAll: true, env: { EVALS_ALL: '1' }, rootDir }), rootDir); } +/** + * A release output or stamp path without its stamps: every version mentioned + * in a stamp form (the built-in forms and the policy's `stamp_patterns` + * templates) collapses to one placeholder. Normalising only the commit's own + * version left a tentative `*_KEY_VERSION = ''` as written on the + * unstamped prefix and normalised once the slot stamp moved it to the released + * version, so the two digests differed and train-backstop found no covering + * record for main after that train (#3512), and a base-version mention on a + * line the base already carried was normalised on the prefix and kept on the + * stamped main. Every stamp-form mention collapsing makes the digest blind to + * which version a stamp names; the receipt (`stampOnlyLine`) is what judges a + * stamp commit's content. + */ +export function stampFreeText(text: string, templates: readonly string[]): string { + let out = text; + for (const v of [...stampMentions(text, templates)].sort((a, b) => b.length - a.length)) out = rewriteStamps(out, v, '\0VERSION', templates).text; + return out; +} + /** * A commit's tree modulo stamps: every path with its mode and blob, except the * files a restamp rewrites, which contribute their content without the stamp * (the version file nothing, package.json without its version, lockfiles * without their root version, the CHANGELOG with its headings normalized, - * release outputs and stamp paths with their own version replaced). Two - * commits that differ only by a restamp have the same digest. + * release outputs and stamp paths with every stamp-form version mention + * replaced). Two commits that differ only by a restamp have the same digest. */ export function stampFreeTree(git: Git, commit: string, repoRoot: string, policy: ShipPolicy): string | { error: string } { const listed = git(['ls-tree', '-r', '-z', '--full-tree', commit]); if (!listed.ok) return { error: `cannot list ${commit.slice(0, 12)} (${listed.out.split('\n')[0]})` }; const at = versionAtRef(repoRoot, commit); const versionRel = 'rel' in at ? at.rel : 'VERSION'; - const version = 'version' in at ? at.version : null; const lines: string[] = []; for (const item of listed.out.split('\0').filter(Boolean)) { const [meta, rel] = [item.slice(0, item.indexOf('\t')), item.slice(item.indexOf('\t') + 1)]; @@ -161,7 +179,7 @@ export function stampFreeTree(git: Git, commit: string, repoRoot: string, policy if (stamp === 'manifest') { try { const parsed = JSON.parse(text); delete parsed.version; text = JSON.stringify(parsed); } catch { /* not JSON: kept verbatim */ } } else if (stamp === 'lockfile') text = normalizeLockfile(base, text); else if (stamp === 'changelog') text = normalizeChangelog(text); - else if (version) text = rewriteStamps(text, version, '\0VERSION', policy.stamp_patterns).text; + else text = stampFreeText(text, policy.stamp_patterns); lines.push(`${rel}\0${mode}\0${createHash('sha256').update(text).digest('hex')}`); } return createHash('sha256').update(lines.sort().join('\n')).digest('hex'); diff --git a/test/eval-readiness.test.ts b/test/eval-readiness.test.ts index b4133414a..7297edcca 100644 --- a/test/eval-readiness.test.ts +++ b/test/eval-readiness.test.ts @@ -53,6 +53,25 @@ describe('readiness decision', () => { expect(freeTestsState([free(3, 'completed', 'cancelled')], HEAD)).toEqual({ state: 'failed', runId: 3, conclusion: 'cancelled' }); }); + // #3508: a head whose only conflict with main is the CHANGELOG gets no pull_request CI (GitHub has no merge ref to build); + // the dispatch route runs Free Tests through workflow_dispatch on the branch, and readiness must read that run. + test('a dispatched-only head: the workflow_dispatch Free Tests run at the exact head decides, green or red, newest first; a pull_request run outranks it', () => { + const dispatched = (id: number, status: string, conclusion: string | null, over: Partial = {}) => free(id, status, conclusion, { event: 'workflow_dispatch', ...over }); + expect(freeTestsState([dispatched(40448356650, 'completed', 'success')], HEAD)).toEqual({ state: 'success', runId: 40448356650 }); + expect(freeTestsState([dispatched(5, 'completed', 'failure')], HEAD)).toEqual({ state: 'failed', runId: 5, conclusion: 'failure' }); + expect(freeTestsState([dispatched(5, 'in_progress', null)], HEAD)).toEqual({ state: 'running', runId: 5 }); + // A dispatched run on another head, or a push run on this one, is not this head's gate. + expect(freeTestsState([dispatched(5, 'completed', 'success', { head_sha: NEWER })], HEAD)).toEqual({ state: 'missing' }); + expect(freeTestsState([free(5, 'completed', 'success', { event: 'push' })], HEAD)).toEqual({ state: 'missing' }); + // A pull_request run at the head decides when one exists, whatever its age: it built the merge ref, a dispatch built the + // head alone, so a newer green dispatch never overrides a red pull_request run (rerun that run instead) and a red or + // running extra dispatch never holds a head its pull_request CI cleared (pre-landing review, pass 1). + expect(freeTestsState([free(1, 'completed', 'failure'), dispatched(2, 'completed', 'success')], HEAD)).toEqual({ state: 'failed', runId: 1, conclusion: 'failure' }); + expect(freeTestsState([dispatched(1, 'completed', 'success'), free(2, 'completed', 'failure')], HEAD)).toEqual({ state: 'failed', runId: 2, conclusion: 'failure' }); + expect(freeTestsState([free(1, 'completed', 'success'), dispatched(2, 'in_progress', null)], HEAD)).toEqual({ state: 'success', runId: 1 }); + expect(freeTestsState([free(1, 'completed', 'success'), dispatched(2, 'completed', 'failure')], HEAD)).toEqual({ state: 'success', runId: 1 }); + }); + test('ready needs both a green Free Tests on the head and an elapsed window; a moved head is superseded', () => { const green = { state: 'success', runId: 7 } as const; expect(readinessDecision({ headSha: HEAD, prHead: HEAD, free: green, quietLeftMs: 0 }).kind).toBe('ready'); @@ -80,6 +99,20 @@ describe('waitForReadiness', () => { expect(lines.join('\n')).toContain('Free Tests run 5 passed'); }); + test('a dispatched-only head starts the gate: the runs listing is not narrowed to pull_request (#3508)', () => { + const { gh, calls } = fakeGh([{ free: [free(40448356650, 'completed', 'success', { event: 'workflow_dispatch' })] }]); + const c = clock(CREATED + 3 * 3600_000); + const argsSeen: string[][] = []; + const spy: GhRunner = (args, timeout) => { argsSeen.push(args); return gh(args, timeout); }; + const { decision, lines } = waitForReadiness({ ...base, gh: spy, now: c.now, sleep: c.sleep }); + expect(decision.kind).toBe('ready'); + expect(lines.join('\n')).toContain('Free Tests run 40448356650 passed'); + const listing = argsSeen.find(args => args.some(a => a.endsWith('free-tests.yml/runs')))!; + expect(listing).toContain(`head_sha=${HEAD}`); + expect(listing.some(a => a.startsWith('event='))).toBe(false); + expect(calls.filter(call => call.endsWith('free-tests.yml/runs'))).toHaveLength(1); + }); + test('a re-run keeps the original creation time, so an elapsed window is not waited again', () => { const { gh } = fakeGh([{ free: [free(5, 'completed', 'success')] }]); const c = clock(CREATED + 3 * 3600_000); diff --git a/test/gstack-restamp-queue.test.ts b/test/gstack-restamp-queue.test.ts index 7cfb9b012..4592f3f43 100644 --- a/test/gstack-restamp-queue.test.ts +++ b/test/gstack-restamp-queue.test.ts @@ -469,7 +469,7 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () => const j = JSON.parse(r.out); expect(j).toMatchObject({ status: 'refused', code: 'QUEUE_STALE', expected: { base: assumed.slice(0, 12) } }); expect(j.actual.base).toBe(repo.git('rev-parse', 'origin/main')); - expect(j.recompute).toMatch(/^gstack-restamp --next --base main --expect-base [0-9a-f]{12}$/); + expect(j.recompute).toMatch(/^gstack-restamp --next --base main --expect-base [0-9a-f]{12} --pr 2$/); expect(repo.git('status', '--porcelain')).toBe(''); const text = restamp(repo, ['--next', '--pr', '2', '--expect-base', assumed.slice(0, 12)]); expect(text.err).toMatch(/base moved.*\(QUEUE_STALE\)\nrecompute: gstack-restamp --next/); @@ -495,9 +495,38 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () => const stale = restamp(repo, ['--next', '--pr', '2', '--expect-order', '1,2,3', '--json'], ghShim(repo, { '1': merged, '3': merged })); expect(stale.status).toBe(EXIT.refused); expect(JSON.parse(stale.out)).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '1': 'merged', '3': 'merged' } } }); - expect(JSON.parse(stale.out).recompute).toContain('--expect-order 1,2,3'); + // The recompute is the corrected order (merged PRs dropped), never the stale list again. + expect(JSON.parse(stale.out).recompute).toMatch(/ --pr 2 --expect-order 2$/); const unmerged = restamp(repo, ['--next', '--pr', '2', '--expect-order', '1,4,2', '--json'], ghShim(repo, { '1': merged, '4': open })); expect(JSON.parse(unmerged.out)).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '4': 'open' } } }); + expect(JSON.parse(unmerged.out).message).toContain('#4 must merge before this PR stamps'); + expect(JSON.parse(unmerged.out).recompute).toMatch(/ --expect-order 4,2$/); + // A predecessor GitHub calls merged that the local base does not carry yet (the slot call came before the lane fetched) + // stays ahead in the recompute: dropping it would let --next allocate from the stale base the version it already took. + const unfetched = { state: 'MERGED', mergeCommit: { oid: repo.git('rev-parse', 'pr-b') }, headRefOid: 'y' }; + const stale2 = restamp(repo, ['--next', '--pr', '2', '--expect-order', '1,4,2', '--json'], ghShim(repo, { '1': merged, '4': unfetched })); + expect(JSON.parse(stale2.out)).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '4': 'merged-not-in-base' } }, fix: 'git fetch origin main' }); + expect(JSON.parse(stale2.out).message).toContain('#4 merged but the local main does not carry the merge yet: git fetch origin main, then rerun'); + expect(JSON.parse(stale2.out).recompute).toMatch(/ --expect-order 4,2$/); + }); + + // The slot-call lesson (GSTA-123, 2026-10-27): a lane given "remaining order 3487" ran --expect-order 3487 without its own + // PR, so every entry read as an unmerged predecessor and the refusal repeated the same list as its recompute. + test('--expect-order without the lane\'s own PR: the refusal says so and the recompute lists this PR first, then the remaining order', () => { + const repo = fixture(); + makePr(repo, 'pr-a'); + makePr(repo, 'pr-b'); + repo.git('checkout', '-q', 'pr-a'); + const open = { state: 'OPEN', mergeCommit: null, headRefOid: 'x' }; + const r = restamp(repo, ['--next', '--pr', '1', '--expect-order', '2,3', '--json'], ghShim(repo, { '2': open, '3': open })); + expect(r.status).toBe(EXIT.refused); + const j = JSON.parse(r.out); + expect(j).toMatchObject({ code: 'QUEUE_STALE', actual: { prs: { '2': 'open', '3': 'open' } } }); + expect(j.message).toContain('--pr 1 is not in --expect-order, so every listed PR read as a predecessor: list this PR first, then the remaining order'); + expect(j.recompute).toMatch(/^gstack-restamp --next --base main --expect-base [0-9a-f]{12} --pr 1 --expect-order 1,2,3$/); + // The corrected command passes against the same gh answers. + expect(restamp(repo, ['--next', '--pr', '1', '--expect-order', '1,2,3', '--dry-run'], ghShim(repo, { '2': open, '3': open })).status).toBe(EXIT.ok); + expect(repo.git('status', '--porcelain')).toBe(''); }); }); diff --git a/test/gstack-ship-receipt-docs.test.ts b/test/gstack-ship-receipt-docs.test.ts index 53c65b665..e889e56ae 100644 --- a/test/gstack-ship-receipt-docs.test.ts +++ b/test/gstack-ship-receipt-docs.test.ts @@ -201,6 +201,23 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit expect(change('1.2.34\n', '1.2.44\n')).toBe(false); }); + // #3512: the carry classifies a stamp path as metadata only while its change is a stamp; a tentative key version the slot + // stamp moved (next minor -> released) was product, so `carry` refused RECEIPT_CARRY_PRODUCT_CHANGED on a stamp commit. + test('stampOnlyText accepts a tentative key version moved to the new version, and refuses a released one moved by hand or any other edit', () => { + const templates = ["_KEY_VERSION = '{version}'"]; + const change = (before: string, after: string) => stampOnlyText({ before, after, oldVersion: '1.2.3.0', newVersion: '1.2.4.0', templates }); + const keys = (docs: string, old: string, extra = '') => `export const DOCS_KEY_VERSION = '${docs}';\nexport const OLD_KEY_VERSION = '${old}';\n${extra}`; + expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(true); + expect(change(keys('1.2.3.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(true); + expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.4.0'))).toBe(false); + expect(change(keys('1.3.0.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0', 'export const EXTRA = 1;\n'))).toBe(false); + // Without the template the constant is not a stamp form, so the move is a product change. + expect(stampOnlyText({ before: keys('1.3.0.0', '1.2.2.0'), after: keys('1.2.4.0', '1.2.2.0'), oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false); + // A mention of another width is never a stamp candidate (tentativeMentions), so moving it by hand is a product change. + expect(change(keys('1.3.0', '1.2.2.0'), keys('1.2.4.0', '1.2.2.0'))).toBe(false); + expect(stampOnlyText({ before: 'left-pad since: 1.2.3\n', after: 'left-pad since: 1.2.4.0\n', oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false); + }); + test('planCarry names at most CARRY_NAMED_PATHS paths', () => { const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`); const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' }); diff --git a/test/pr-wait.test.ts b/test/pr-wait.test.ts index 54689d8a0..5d91a8059 100644 --- a/test/pr-wait.test.ts +++ b/test/pr-wait.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import type { GhResult } from '../lib/ci-gate'; -import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait'; +import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, DEFAULT_TIMEOUT_MINUTES, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait'; import { GhError, runGh } from '../lib/pr-wait-github'; import { RESULT_CODES } from '../lib/result-codes'; @@ -360,7 +360,9 @@ describe('arguments and the gh layer', () => { expect(parsePrTarget('abc')).toMatchObject({ error: "'abc' is not a PR number or PR URL" }); expect(parsePrTarget('0')).toMatchObject({ error: "'0' is not a valid PR number" }); expect(parsePrTarget('42', 'nope')).toMatchObject({ error: "'nope' is not owner/name" }); - expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60 }); + expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60, excludes: [] }); + expect(parsePrWaitArgs(['42', '--exclude', 'quality-gate, canary', '--exclude', 'canary'])).toMatchObject({ excludes: ['quality-gate', 'canary'] }); + expect(parsePrWaitArgs(['42', '--exclude', ' , '])).toMatchObject({ error: expect.stringContaining('--exclude needs') }); expect(parsePrWaitArgs(['42', '--timeout', '0'])).toMatchObject({ timeout: 0 }); expect(parsePrWaitArgs(['42', '--timeout', 'soon'])).toMatchObject({ error: expect.stringContaining('--timeout needs') }); expect(parsePrWaitArgs(['--until', 'soon'])).toMatchObject({ error: expect.stringContaining('--until needs') }); @@ -421,6 +423,44 @@ describe('pass-1 review fixes: gates agree, bounded waits, bot identity, local f expect(lines()).toEqual(['#42 check failed: quality-gate https://ci.example/quality-gate', expect.stringContaining('(PR_WAIT_BLOCKED;')]); }); + // #3495 follow-up (ruled by GStack Minder, 2026-10-26): gstack-ci-gate --exclude has a watcher equivalent, so a non-required + // check stuck in pending (a canary, a deploy preview) no longer holds --until green for the whole timeout. + test('--exclude leaves a named non-required check out of the verdict: pending does not hold green, failed does not block; a required name is refused and gates', () => { + const excluded = (script: Script, ...names: string[]) => { + const e = env(fakeGh(script)); + return new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars), DEFAULT_TIMEOUT_MINUTES, names); + }; + const checks = ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'pending'])); + const held = excluded({ rulesets: RULESETS, checks }, 'other-check'); + expect(pollUntilDone(held, 4)).toBeNull(); + expect(lines()).toEqual([]); + const w = excluded({ rulesets: RULESETS, checks }, 'quality-gate'); + expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met); + expect(lines()).toEqual(['#42 green on aaaaaaa: 2 pass; required 2/2 green; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN; 0 commits behind main']); + expect(stderr.join('')).toContain('excluded from the verdict: quality-gate'); + stdout = []; stderr = []; + const red = excluded({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'quality-gate'); + expect(pollUntilDone(red)).toBe(PR_WAIT_EXIT.met); + expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 2 pass;/)]); + stdout = []; stderr = []; + const refused = excluded({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'free-tests'); + expect(refused.poll()).toBe(PR_WAIT_EXIT.blocked); + expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests'); + expect(stderr.join('')).toContain('exclude refused for required check free-tests: a required check gates'); + stdout = []; stderr = []; + // When a required-check source cannot be read the set may be short, so nothing is excluded (gstack-ci-gate refuses its + // overrides the same way): the excluded failure gates and the first poll says why. + const unknown = excluded({ rulesets: err('gh: HTTP 403: rate limited', 1), checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'quality-gate'); + expect(unknown.poll()).toBe(PR_WAIT_EXIT.blocked); + expect(lines()[0]).toBe('#42 check failed: quality-gate https://ci.example/quality-gate'); + expect(stderr.join('')).toContain('exclude refused for quality-gate: the required checks could not be read, so every check gates'); + stdout = []; stderr = []; + // Through main: the flag reaches the watcher. + const e = env(fakeGh({ rulesets: RULESETS, checks })); + expect(prWaitMain(['42', '--repo', REPO, '--until', 'green', '--exclude', 'quality-gate'], e)).toBe(PR_WAIT_EXIT.met); + expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 2 pass;/)]); + }); + test('a failed bot check is a failed check, gating or advisory, as gstack-ci-gate counts it; never a completed review', () => { const gating = watcher({ pr: prJson({ reviews: [] }), checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'fail'])) }); expect(pollUntilDone(gating)).toBe(PR_WAIT_EXIT.blocked); diff --git a/test/ship-queue-train-ops.test.ts b/test/ship-queue-train-ops.test.ts index e2af535fe..61df3d0dd 100644 --- a/test/ship-queue-train-ops.test.ts +++ b/test/ship-queue-train-ops.test.ts @@ -157,6 +157,8 @@ describe('#3465: the screen is planned on a scratch merge when GitHub\'s merge r expect([dry.status, dry.err]).toEqual([EXIT.ok, '']); const main = repo.git('rev-parse', 'origin/main'); expect(line(dry.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${head.slice(0, 12)} onto main@${main.slice(0, 12)} \\(refs/pull/1/merge [0-9a-f]{12} merges onto [0-9a-f]{12}, not main@${main.slice(0, 12)}\\); would push screen/1-${head.slice(0, 12)} \\(dry run\\)$`)); + // gstack-pr-wait's base-overlap event, read from git at READY (handoff GSTA-119): main's stamp touched the CHANGELOG this PR also changes. + expect(line(dry.out, 'BASE_OVERLAP:')).toBe(`BASE_OVERLAP: main moved to ${main.slice(0, 7)} and touches files in this PR: CHANGELOG.md; the train's merge onto main is clean`); expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe(''); const real = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']); expect([real.status, real.err]).toEqual([EXIT.ok, '']); @@ -195,6 +197,15 @@ describe('#3465: the screen is planned on a scratch merge when GitHub\'s merge r expect(conflict.err).toMatch(/#1 conflicts with main@[0-9a-f]{12} beyond the CHANGELOG and the stamp files \(pr-1\.txt\); GitHub's merge ref lags too \(origin has no refs\/pull\/1\/merge\).*\(QUEUE_SCREEN_STALE_REF\)/); expect(next(conflict.out)).toBe('NEXT: resolve the conflict with main, push, run /ship, then gstack-ship-queue ready --pr 1 --screen'); expect(dispatches(fx, 'evals.yml').length).toBe(1); + const main2 = repo.git('rev-parse', 'origin/main'); + expect(line(conflict.out, 'BASE_OVERLAP:')).toBe(`BASE_OVERLAP: main moved to ${main2.slice(0, 7)} and touches files in this PR: CHANGELOG.md, pr-1.txt; the train's merge onto main conflicts beyond the CHANGELOG and the stamp files (pr-1.txt)`); + // Without a screen there is no scratch merge to refuse at, so READY itself refuses the conflict the train would stop at; + // the fix follows the crew template's rule 14 (the base is merged on the controller's word, never by the lane alone). + const noScreen = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']); + expect(noScreen.status).toBe(EXIT.refused); + expect(noScreen.err).toMatch(new RegExp(`#1 conflicts with main@${main2.slice(0, 12)} beyond the CHANGELOG and the stamp files \\(pr-1\\.txt\\); the train would stop at this PR.*\\(QUEUE_TRAIN_CONFLICT\\)`)); + expect(next(noScreen.out)).toBe('NEXT: report the conflict to the controller; on its word merge main into the pin (never rebase), resolve, push, run /ship, then gstack-ship-queue ready --pr 1 --screen|--no-screen'); + expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`))).toBe(true); }, 120_000); }); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 318a25657..295c280e0 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -16,6 +16,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; +import { baseOverlap } from '../lib/ship-queue-train'; import { TRAIN_HELP } from '../lib/ship-queue-train-cli'; import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture'; import { coordinatorClone } from './helpers/ship-queue-fixture'; @@ -352,6 +353,44 @@ describe('train build', () => { expect(dispatches(fx)).toEqual([]); }, 120_000); + // Handoff GSTA-119 / #3485: a pooled pin the base moved onto is named with the files, and the conflict is found while the + // train is still filling instead of at dispatch time; a base move onto files the PR leaves alone is silent. + test('a pin whose files main moved onto gets the base-overlap line; a conflict with main is QUEUE_TRAIN_CONFLICT at slot 1 even while the train fills', () => { + const fx = make(); + const h1 = openPr(fx, '1', { file: 'same.txt', text: 'one\n' }); + const h2 = openPr(fx, '2'); + readyReceipt(fx, '1', h1, { at: '2026-10-18T11:00:00.000Z' }); + readyReceipt(fx, '2', h2, { at: '2026-10-18T11:00:00.000Z' }); + const { repo } = fx; + repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main'); + fs.writeFileSync(path.join(repo.work, 'same.txt'), 'main\n'); + fs.writeFileSync(path.join(repo.work, 'unrelated.txt'), 'main\n'); + repo.git('add', '-A'); + repo.git('commit', '-qm', 'main moves onto same.txt'); + repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + repo.git('checkout', '-q', 'pr-1'); + const main = repo.git('rev-parse', 'origin/main'); + const r = queue(fx, ['train', 'build', '--prs', `2@${h2},1@${h1}`]); + expect(r.status).toBe(EXIT.fail); + // At build the line is the files alone: the chain merges each pin onto its prefix, and that merge is the verdict. + expect(line(r.out, '#1 BASE_OVERLAP:')).toBe(`#1 BASE_OVERLAP: main moved to ${main.slice(0, 7)} and touches files in this PR: same.txt`); + expect(r.out).not.toContain('#2 BASE'); + expect(line(r.out, 'QUEUE_TRAIN_CONFLICT:')).toMatch(new RegExp(`stopped at #1 \\(conflicts: same\\.txt\\) after merging (\\(none\\)|#2); nothing dispatched, no branch pushed; fix: gstack-ship-queue train build --prs 2@${h2}$`)); + expect(r.out).not.toContain('MERGE_CHECK:'); + expect(remoteRefs(fx)).not.toContain('refs/heads/train/'); + expect(dispatches(fx)).toEqual([]); + const without = queue(fx, ['train', 'build', '--prs', `2@${h2}`]); + expect(without.status).toBe(EXIT.refused); + expect(without.err).toContain('(QUEUE_TRAIN_WAITING)'); + expect(line(without.out, 'MERGE_CHECK:')).toBe(`MERGE_CHECK: T1..T1 merge clean onto main@${main.slice(0, 12)} (dry run in object storage; nothing pushed)`); + // A git read that fails is never "no overlap" (pre-landing review, pass 1): the comparison is reported as unknown. + const unknown = baseOverlap(repo.work, 'main', main, 'f'.repeat(40), TRAIN_POLICY as any); + expect(unknown).toMatchObject({ unknown: true, touched: [], conflicts: null }); + expect(unknown.line).toMatch(new RegExp(`^BASE_OVERLAP: could not compare main@${main.slice(0, 12)} with ffffffffffff \\(.+\\); conflicts unknown$`)); + // At build the overlap line carries the files alone; the verdict is the chain's. + expect(baseOverlap(repo.work, 'main', main, h1, TRAIN_POLICY as any, false).line).toBe(`BASE_OVERLAP: main moved to ${main.slice(0, 7)} and touches files in this PR: same.txt`); + }, 120_000); + test("GSTA-67: a PR whose CHANGELOG drops a released heading (#3345's pin 1eb7092) is a CHANGELOG conflict, never merged with the damage", () => { const fx = make(); const h1 = openPr(fx, '1'); @@ -423,6 +462,8 @@ describe('train build', () => { expect(filling.status).toBe(EXIT.refused); expect(filling.err).toMatch(/1 of 5 PRs; the oldest READY dispatches at 2026-10-18T14:00:00\.000Z.*\(QUEUE_TRAIN_WAITING\)/); expect(result(filling.out)).toBe('GSTACK_RESULT: skill=ship-queue status=incomplete run=none'); + // #3485: the scratch merges run before the fill rule, so a WAITING result also proves the seated pins merge clean. + expect(line(filling.out, 'MERGE_CHECK:')).toBe(`MERGE_CHECK: T1..T1 merge clean onto main@${fx.repo.git('rev-parse', 'origin/main').slice(0, 12)} (dry run in object storage; nothing pushed)`); fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); const urgent = queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--urgent']); expect(urgent.status).toBe(EXIT.ok); diff --git a/test/ship-receipt-prose.test.ts b/test/ship-receipt-prose.test.ts index f352e5765..3e00f8913 100644 --- a/test/ship-receipt-prose.test.ts +++ b/test/ship-receipt-prose.test.ts @@ -48,6 +48,28 @@ describe('checkReceiptProse', () => { expect(bad.mismatches).toEqual([{ field: 'gate.unit.pass', prose: 40, receipt: 41 }]); expect(checkReceiptProse(receipt, 'Gate passed: 41. CI passed=41.').mismatches).toEqual([]); }); + + // #3513: the canonical REVIEW_LOOP line /ship and `gstack-ship-queue ready` print. Before the fix the ` ` branch + // started on passes' value and read `2 blocking_found` as a blocking_found claim of 2 (PROSE: mismatch ... body=2 receipt=1). + test('a key=value pair\'s value is that key\'s claim alone: the REVIEW_LOOP line parses as five claims that all match', () => { + const loop = { ...receipt, review_loop: { passes: 2, blocking_found: 1, deferred: 0, noted: 4, fixed: 8, scope: 'frozen' } }; + const line = 'REVIEW_LOOP: passes=2 blocking_found=1 deferred=0 noted=4 fixed=8 scope=frozen'; + const ok = checkReceiptProse(loop, line); + expect(ok).toEqual({ checked: 5, mismatches: [] }); + expect(proseLine(ok)).toBe('PROSE: 5 receipt counter claims in the body, all match'); + const one = { ...receipt, review_loop: { passes: 1, blocking_found: 0, deferred: 0, noted: 0, fixed: 0, scope: 'frozen' } }; + expect(checkReceiptProse(one, 'REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=0 scope=frozen')).toEqual({ checked: 5, mismatches: [] }); + // A real disagreement on the same line is still caught, and named for the key that carries it. + expect(checkReceiptProse(loop, 'REVIEW_LOOP: passes=2 blocking_found=3 deferred=0 noted=4 fixed=8 scope=frozen').mismatches) + .toEqual([{ field: 'review_loop.blocking_found', prose: 3, receipt: 1 }]); + // Prose claims in the number-then-name spelling keep working when they do not follow a pair. + expect(checkReceiptProse(loop, 'The loop ran 2 passes and found 1 blocking_found item.')).toEqual({ checked: 2, mismatches: [] }); + // The `key: value` spelling with whitespace is the same pair (pre-landing review, pass 1): the value never starts the + // next key's claim, while a non-counter word before the number (`Review: 2 passes`) leaves the claim as it was. + expect(checkReceiptProse(loop, 'REVIEW_LOOP: passes: 2 blocking_found: 1 deferred: 0 noted: 4 fixed: 8')).toEqual({ checked: 5, mismatches: [] }); + expect(checkReceiptProse(loop, 'Gate passed: 41 failed: 0')).toEqual({ checked: 2, mismatches: [] }); + expect(checkReceiptProse(loop, 'Review: 2 passes; passes = 2 fixed = 8.')).toEqual({ checked: 3, mismatches: [] }); + }); }); describe('gstack-ship-receipt read --check-prose', () => { diff --git a/test/train-gate.test.ts b/test/train-gate.test.ts index e0dfa4cd7..32757a46d 100644 --- a/test/train-gate.test.ts +++ b/test/train-gate.test.ts @@ -224,6 +224,40 @@ describe('prefix trees modulo stamps', () => { expect(stampFreeTree(git, 'f'.repeat(40), repo.work, policy)).toHaveProperty('error'); }); + // #3512: a stamp path carrying a tentative `*_KEY_VERSION = ''` (gstack's own convention since #3511) digests the + // same before and after the slot stamp moves it, so train-backstop finds the record that covers main; a base-version mention + // on a line the base already carried digests the same at the prefix (where it equals VERSION) and on the stamped main. + test('stampFreeTree collapses every stamp-form mention, not only the commit\'s version: a moved tentative key and a kept released one both digest equal', () => { + const git = gitIn(repo.work); + const policy = { ...loadPolicyFromHead(repo.work).policy, stamp_paths: ['docs/**/*.md', 'lib/policy.ts'], stamp_patterns: ["_KEY_VERSION = '{version}'"] }; + const policyFile = (text: string) => { fs.mkdirSync(path.join(repo.work, 'lib'), { recursive: true }); fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), text); }; + // The unstamped prefix: VERSION 1.2.3.0, a new key written as the tentative 1.3.0.0, a released key at 1.2.3.0 (the base's version). + repo.git('checkout', '-q', '-B', 'key-prefix', sha.main!); + policyFile("export const DOCS_KEY_VERSION = '1.3.0.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n"); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'prefix with a tentative key'); + const prefix = repo.git('rev-parse', 'HEAD'); + // Main after the slot stamp: VERSION 1.2.4.0, the tentative moved to the released version, the released key as written. + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + policyFile("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n"); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + const stamped = repo.git('rev-parse', 'HEAD'); + expect(stampFreeTree(git, stamped, repo.work, policy)).toBe(stampFreeTree(git, prefix, repo.work, policy)); + // A byte beyond a stamp-form mention still changes the digest. + policyFile("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\nexport const EXTRA = 1;\n"); + repo.git('commit', '-qam', 'a code byte'); + expect(stampFreeTree(git, repo.git('rev-parse', 'HEAD'), repo.work, policy)).not.toBe(stampFreeTree(git, prefix, repo.work, policy)); + // The receipt agrees with the digest on the stamp, and refuses a released key moved by hand (older than the gated version; + // a mention equal to it is the base's own version, which the stamp_paths stage moves on a branch-added line). + const tree = (current: string) => (buildReceipt({ repoRoot: repo.work, gated: prefix, current, policy, policyLabel: 'fixture' }) as { tree: string; residual: string[] }).tree; + expect(tree(stamped)).toBe('same-modulo-stamps'); + repo.git('checkout', '-q', '-B', 'hand-move', prefix); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + policyFile("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const REVIEW_KEY_VERSION = '1.2.3.0';\nexport const OLD_KEY_VERSION = '1.2.4.0';\n"); + repo.git('commit', '-qam', 'stamp plus a released key moved by hand'); + expect(tree(repo.git('rev-parse', 'HEAD'))).toBe('changed'); + repo.git('checkout', '-q', 'squash'); + }); + test('T1..Tk come from the train head by first parents; a head that is not a train is refused', () => { const git = gitIn(repo.work); const policy = loadPolicyFromHead(repo.work).policy; diff --git a/test/tree-receipt-stamp-sensitive.test.ts b/test/tree-receipt-stamp-sensitive.test.ts index dfe462f9c..245c11962 100644 --- a/test/tree-receipt-stamp-sensitive.test.ts +++ b/test/tree-receipt-stamp-sensitive.test.ts @@ -8,7 +8,8 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; +import { stampOnlyLine } from '../lib/restamp'; +import { BASE_POLICY, cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; const fixture = (opts?: Parameters[0]) => { const r = makeFixtureRepo({ prefix: 'tree-receipt-', ...opts }); repos.push(r); return r; }; @@ -55,6 +56,55 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', () expect(r.out).not.toContain('pr-a.txt'); }); + // #3512: under the next-minor convention a new `*_KEY_VERSION` constant is written as a tentative future version, which the + // stamp_paths stage moves to the released one at the slot. The receipt accepted a stamp_paths pair only as base -> new, so the + // moved constant classified the file `changed` and the train refused the stamp commit (QUEUE_STAMP_NOT_STAMP_ONLY). + test('a tentative key version a restamp moved is a stamp, like the base version; a line that changes more than the mention is not', () => { + const repo = fixture({ policy: { ...BASE_POLICY, stamp_paths: ['docs/**/*.md', 'lib/policy.ts'], stamp_patterns: ["_KEY_VERSION = '{version}'"] } }); + makePr(repo, 'pr-a', { expects: '1.2.4.0' }); + fs.mkdirSync(path.join(repo.work, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.3.0.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n"); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'a new policy key, tentative next minor'); + const bundle = path.join(repo.root, 'bundle.json'); + recordAndBundle(repo, repo.work, LANES, bundle); + const gatedHead = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + // The tentative 1.3.0.0 moved; the released 1.2.2.0 stayed. + expect(readFile(repo, 'lib/policy.ts')).toBe("export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n"); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + const r = receipt(repo, ['--gated', gatedHead, '--bundle', bundle]); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toMatch(/^version:\s+1\.2\.3\.0 -> 1\.2\.4\.0$/m); + expect(r.out).toMatch(/^ stamp lib\/policy\.ts \(stamp_paths\)$/m); + expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m); + expect(r.out).toMatch(/^gate-reuse: eligible/m); + // Negative: the version moves and the key is renamed on the same line; a tentative value moved by hand to the wrong version. + fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_CAPS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\n"); + repo.git('commit', '-qam', 'rename the key too'); + const renamed = receipt(repo, ['--gated', gatedHead, '--bundle', bundle]); + expect(renamed.status).toBe(EXIT.fail); + expect(renamed.out).toMatch(/^ changed lib\/policy\.ts \(stamp_paths: "export const DOCS_KEY_VERSION = '1\.3\.0\.0';" -> "export const DOCS_CAPS_KEY_VERSION = '1\.2\.4\.0';"\)$/m); + repo.git('reset', '-q', '--hard', 'HEAD~1'); + fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.4.0';\n"); + repo.git('commit', '-qam', 'move a released key by hand'); + const wrong = receipt(repo, ['--gated', gatedHead, '--bundle', bundle]); + expect(wrong.status).toBe(EXIT.fail); + expect(wrong.out).toMatch(/^TREE: changed \(lib\/policy\.ts\)$/m); + // A mention of another width (a 3-digit pin in a 4-digit repo) is never a stamp candidate, so moving it by hand to the + // new version is a product change even though it reads newer than the gated head (pre-landing review, pass 1). + repo.git('reset', '-q', '--hard', 'HEAD~1'); + fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\nexport const PIN_KEY_VERSION = '9.9.9';\n"); + repo.git('commit', '-qam', 'a 3-digit pin'); + const pinned = repo.git('rev-parse', 'HEAD'); + fs.writeFileSync(path.join(repo.work, 'lib', 'policy.ts'), "export const DOCS_KEY_VERSION = '1.2.4.0';\nexport const OLD_KEY_VERSION = '1.2.2.0';\nexport const PIN_KEY_VERSION = '1.2.4.0';\n"); + repo.git('commit', '-qam', 'move the 3-digit pin by hand'); + expect(stampOnlyLine("export const PIN_KEY_VERSION = '9.9.9';", "export const PIN_KEY_VERSION = '1.2.4.0';", '1.2.3.0', '1.2.4.0', ["_KEY_VERSION = '{version}'"])).toBe(false); + expect(stampOnlyLine('left-pad since: 1.2.3', 'left-pad since: 1.2.4.0', '1.2.3.0', '1.2.4.0')).toBe(false); + const width = receipt(repo, ['--gated', pinned, '--bundle', bundle]); + expect(width.status).toBe(EXIT.fail); + expect(width.out).toMatch(/^TREE: changed \(lib\/policy\.ts\)$/m); + }); + test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => { const repo = fixture(); const g = gated(repo); From b3e94a3f8d1cbaceb9103e9ce777e622fdc75906 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 27 Oct 2026 10:20:55 -0700 Subject: [PATCH 2/2] v1.91.279.0 eval:select exemptions and ETHOS.md registration (#3499), below-floor review rulings (#3504, #3485), Windows curation, GSTA-121 follow-ups (#3515) * Eval selection exemptions, review-floor rulings and the GSTA-121 follow-ups Release metadata (NOTICE.md, LICENSE, licenses/**) no longer restores the full paid gate and ETHOS.md is registered under the two prompts that read it (#3499). docs/review-policy.md records the six finding classes #3485 and #3504 ruled below the floor and the review army's lead judgment applies the table; the floor itself is unchanged. The Windows curation moves test/pregate-remote-stamp-only.test.ts to KNOWN_WINDOWS_INCOMPATIBLE (#3485). From GSTA-121: Blast radius in docs/skills.md and the merge-ask template, the 11-digit run-id exemption from pii.phone.e164 after a run word, relative string literals as import-graph edges, and a ubi-runner pack that never tars the live .git. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Red Team pass 1 (all below the floor): an 11-digit span with a leading 1 after a run word is a US or Chinese national number, not a run id, and reports again; ubi-runner pack removes its temporary repository when it fails and replays branch.* with remote.* so a branch's upstream resolves on the VM; the conservative-refusal example in docs/review-policy.md reads in the refusing direction; the touchfiles header names why ETHOS.md sits only under the two prompts that quote it. Step 6's selection found captain/templates/merge-ask.md as the one unknown dependency of this PR and registered captain/templates/** in FREE_ONLY_PR_FILES (#3499). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 2) The cmd_pack EXIT trap bakes the temp path in: a set -e failure unwinds the function before the trap runs and a local $tmp is unset there, so the old form leaked the snapshot. The leak test creates TMPDIR where the pack reads it and asserts the failure is the missing index. CHANGELOG examples no longer spell a full national number. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: adversarial review fixes The pack snapshot carries the index's staged blobs as a pack of their own, so a VM write-tree (gstack-wtree) can read a staged file the way the old .git tar allowed; git init runs with --template= and gc.auto=0 and a valueless remote/branch key replays as true. scripts/harness-version.json re-pins scripts/test-pr-profile.ts after the captain/templates/** entry. The run-id exemption reads the same way in the scanner and gstack-post: an 11-digit span after a run word is a run id (the leading-1 exclusion from the Red Team pass contradicted gstack-post's tested contract and the ruling that a run id is not a phone number), with the residual named in the comment, the docs and the CHANGELOG. The #3499 pins move to their own test so the boundary test stays under its default budget. Docs audit: TESTING_INTERNALS names RELEASE_METADATA_FILES and captain/templates/**, docs/captain.md names Blast radius. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 4) The pack's staged-blob list comes from the copied index, holds only blobs staged against HEAD (not every tracked blob), skips gitlinks whose commit the superproject lacks, and dies with its own message when pack-objects fails. The live-checkout test covers a gitlink entry and a valueless remote key replayed as true. CHANGELOG names the staged-blob carry. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 5) The pack's blob list is the copied index's blobs that HEAD's tree lacks, so an intent-to-add entry's empty blob and the stages of an unmerged entry travel too (the HEAD-only diff list dropped both); gitlinks still skip. The live-checkout test adds an intent-to-add entry, which the VM fsck assertion covers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 25 +++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-pr-shots | 2 +- captain/templates/merge-ask.md | 5 +- capy/skill/gstack/captain.md | 9 ++-- docs/TESTING_INTERNALS.md | 20 ++++++- docs/captain.md | 2 +- docs/pregate.md | 5 +- docs/review-policy.md | 27 +++++++++- docs/skills.md | 11 ++++ docs/troubleshooting.md | 7 ++- lib/gstack-post.ts | 9 ++-- lib/pregate/import-graph.ts | 6 ++- lib/redact-patterns.ts | 16 +++++- package.json | 2 +- review/sections/review-army.md | 20 +++---- scripts/harness-version.json | 2 +- scripts/lib/windows-curation.ts | 1 + scripts/resolvers/review-army.ts | 20 +++---- scripts/test-pr-profile.ts | 10 ++++ scripts/ubicloud/ubi-runner.sh | 72 ++++++++++++++++++------- ship/sections/capy-pr.md | 2 +- ship/sections/capy-pr.md.tmpl | 2 +- ship/sections/review-army.md | 20 +++---- test/approval-record.test.ts | 12 +++++ test/exercised-map.test.ts | 2 +- test/helpers/touchfiles-data.ts | 28 +++++----- test/paid-free-boundary.test.ts | 21 +++++++- test/pregate-allowlist.test.ts | 6 +-- test/pregate-import-graph-paths.test.ts | 18 +++++++ test/redact-context-exemptions.test.ts | 14 +++++ test/ubicloud-runner.test.ts | 37 +++++++++++++ 33 files changed, 350 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a08871d7..2dd5ca389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [1.91.279.0] - 2026-10-27 + +**A licence line or a changelog entry no longer buys a full paid gate, the review loop stops re-reporting findings the owners have already ruled below the floor, and three follow-ups from the Blast radius ship land.** #3499: every PR that touched `NOTICE.md`, `LICENSE`, `licenses/` or `ETHOS.md` restored every gate case and judge because no paid case was mapped to them (#3493's docs-only PR planned 82 cases, about $30, for a notice and a changelog entry). #3504 and #3485: the same classes of finding (a schema lagging its writer, a helper nobody calls, a re-dispatch, a refusal one step late) reached the owners again on every ship after being judged below the floor once. GSTA-121's ship left three gaps. The Captain's merge-ask template and `docs/skills.md` lacked the `## Blast radius` item the PR body now carries, an 11-digit GitHub Actions run id after a run word was redacted from an approval record as a phone number, and the remote free gate failed with `PREGATE_REMOTE_INFRA` when a reviewer ran git in the checkout while the gate tarred `.git`. + +### Added +- **`RELEASE_METADATA_FILES` in `scripts/test-pr-profile.ts`** (`NOTICE.md`, `LICENSE`, `licenses/**`): release metadata no paid case reads, exempt like `CHANGELOG.md` and `VERSION` already were in `knownNonBehaviorFile`; a PR of only these files selects no paid case and keeps mode `pr`; `captain/templates/**` (the crew templates no paid case reads, found as the one unknown dependency of this PR's own selection) joins `FREE_ONLY_PR_FILES`. `ETHOS.md` is registered under the cases and judge of the two prompts that read it (`/office-hours`, `/plan-ceo-review`) in `test/helpers/touchfiles-data.ts`, so an ETHOS edit selects those cases (three gate cases and one judge today) instead of the whole gate; `SECURITY.md` replaces it as the tests' example of an unregistered file. The "unknown dependency restores the full gate" rule itself is unchanged. +- **`docs/review-policy.md#rulings-below-the-floor`**: six finding classes put to the floor by #3485 and #3504 and ruled below it (own-artifact hardening, unwired or unread, bounded repeat work, conservative refusal, artifact presentation, test gap), each with what it looks like and why it fails the floor; a finding in one is a note named by its ruling from pass 1 on. The review army's lead judgment (`scripts/resolvers/review-army.ts`, rendered into both `review-army.md` sections) gains the one-line **On record** ground that applies the table; its other grounds were tightened by the same byte count so the generated review SKILL.md stays at its pins. The floor ("would fail a test, break production, or lose data or secrets") is unchanged. +- **`docs/TESTING_INTERNALS.md` Windows free lane**: the command that shows whether one test is in the curated Windows subset and why not, and which list (`KNOWN_WINDOWS_SAFE`, `WINDOWS_PROBE_SAFE`, `KNOWN_WINDOWS_INCOMPATIBLE`) moves it. +- **`docs/skills.md` `/ship` "The PR body"**: the four Capy sections in order (What changed, Why, Blast radius, Approvals), how to write the Blast radius item, and what `gstack-prose-lint` gates. `captain/templates/merge-ask.md` carries `## Blast radius` between Why and Approvals; `bin/gstack-pr-shots`'s example caption and the `capy-pr` section heading name it too. + +### Fixed +- **`lib/redact-patterns.ts` `isCiRunIdentifier`**: an 11-digit GitHub Actions run id right after a run word on the same line (`screen 40477638211 GREEN`, `gate=…`, `rerun passed …`, `run #…`) is not `pii.phone.e164`, in every scanner (`gstack-approvals add`, `gstack-decision-trail`, the pregate secrets check, `gstack-post`); the run-word regex moved there from `lib/gstack-post.ts`, which imports it for its chained-id allowlist. A 10-digit national number after a run word, a bare 11-digit number, an E.164 `+` number and `job`, `id`, `workflow` or `attempt` before one still report; a national number written without its `+` right after a run word reads as a run id, the accepted residual of the ruling that a run id is not a phone number (`gstack-post` has read it that way since GSTA-121). +- **`scripts/ubicloud/ubi-runner.sh pack`**: the live `.git` is never tarred. The refs are fetched into a fresh repository through git's transport (`--update-shallow`, so a shallow CI or Capy checkout packs), the `remote.*` and `branch.*` config is replayed so `origin/` and each branch's upstream resolve on the VM, a detached HEAD gets a `detached-` branch, the index is copied with the blobs staged against HEAD (a VM `gstack-wtree` can read a staged file), and a pack that fails removes its temporary repository; a review subagent's `git diff` or a trial merge's `worktree add` during the first minute of a remote gate no longer makes tar exit 1 (`PREGATE_REMOTE_INFRA`). The linked-worktree path uses the same snapshot. +- **`lib/pregate/import-graph.ts`**: a relative string literal (`'../ship/sections/pr-body.md.tmpl'` through `new URL(…, import.meta.url)` or `readFileSync`) is a dependency edge from the file that names it, like a relative import; `test/ship-publication-gates.test.ts` is now selected when the pr-body template changes (the remote free gate missed it in GSTA-121's ship). A literal that climbs out of the repository names nothing. +- **`scripts/lib/windows-curation.ts`**: `test/pregate-remote-stamp-only.test.ts` is `KNOWN_WINDOWS_INCOMPATIBLE` (it expects the Linux-host lanes verdict and skips itself on win32), as #3485's follow-up asked. + +### Tests +- `test/paid-free-boundary.test.ts`: release metadata alone and beside `CHANGELOG.md` keeps mode `pr` with no paid case; `ETHOS.md` with `NOTICE.md` selects the two skills' gate cases and judge and nothing else. `test/pr-dependencies.test.ts`, `test/paid-pr-profile.test.ts`, `test/exercised-map.test.ts`: `SECURITY.md` as the unregistered example. +- `test/redact-context-exemptions.test.ts`: seven run-word rows with run 40477638211 and five controls; `test/approval-record.test.ts`: an approval whose words cite `screen 40477638211 GREEN` is recorded verbatim, a phone number still refuses the row; `test/pregate-allowlist.test.ts`: its run-id fixture reads `job `, which the engine does not exempt, so the allowlist mechanism it tests still has a finding to silence. +- `test/ubicloud-runner.test.ts`: pack of a plain checkout under twenty seconds of index, worktree and ref churn, three times; the unpacked snapshot has the same HEAD, `origin/main`, remote URL, status and `gstack-wtree` fingerprint, a detached HEAD lands on `detached-`, a pack with no index exits 1 and leaves nothing in `TMPDIR`, and a branch's upstream survives the snapshot. +- `test/pregate-import-graph-paths.test.ts`: relative literals through `new URL` and `readFileSync`, one and two directories up, and a literal that escapes the repository. + +### Gate integrity +- No threshold, timeout, trial count or case kind changed. The paid selection narrows only for `NOTICE.md`, `LICENSE`, `licenses/**`, `captain/templates/**` and `ETHOS.md`; every other unmapped file still restores the full gate. Generated SKILL.md sizes stay at their pins and the ship parity union stays under its 1.7500 cap; no allowlist or cap moved. + ## [1.91.278.0] - 2026-10-27 **The merge train's tooling stops failing the lanes for things the tooling itself could see.** A head whose only conflict with main was the CHANGELOG got its Free Tests through the dispatch route and the paid screen still called it NOT-RUN; the receipt-versus-prose check read the canonical `REVIEW_LOOP` line as a false mismatch; a PR that writes a new `*_KEY_VERSION` constant as a tentative next-minor version (the convention #3511 introduces) could not clear the train's stamp-only receipt; a pooled pin learned that main had moved onto its files only when the train was built; a watcher had no way to leave a stuck non-required check out of its verdict; and a lane that passed the slot call's remaining order to `gstack-restamp` without its own PR got the same stale list back as the fix. Backlog items from GSTA-119, #3485, #3495 and the 2026-10-27 slot calls. diff --git a/VERSION b/VERSION index ec63d14a2..057e0ed5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.278.0 +1.91.279.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 109a4f394..cd16452a1 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.278.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.279.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/bin/gstack-pr-shots b/bin/gstack-pr-shots index a41052903..653d816d2 100755 --- a/bin/gstack-pr-shots +++ b/bin/gstack-pr-shots @@ -33,7 +33,7 @@ const HELP = `gstack-pr-shots — before/after image of a change for a PR body ( [--title ] [--name ] [--out ] [--json] Example: - gstack-pr-shots --path ship/SKILL.md --heading "Step 19: Create PR/MR" --caption "PR body opens with What changed, Why and Approvals under Capy" + gstack-pr-shots --path ship/SKILL.md --heading "Step 19: Create PR/MR" --caption "PR body opens with What changed, Why, Blast radius and Approvals under Capy" ${exitTableHelp()}`; diff --git a/captain/templates/merge-ask.md b/captain/templates/merge-ask.md index b477437f1..568a0a287 100644 --- a/captain/templates/merge-ask.md +++ b/captain/templates/merge-ask.md @@ -10,7 +10,10 @@ it before training the PR. ## Why - + + +## Blast radius + ## Approvals diff --git a/capy/skill/gstack/captain.md b/capy/skill/gstack/captain.md index cd07f93d0..28564d917 100644 --- a/capy/skill/gstack/captain.md +++ b/capy/skill/gstack/captain.md @@ -126,9 +126,12 @@ Every PR you send READY on, and the READY report itself, has: With no browser that line says `unavailable`: screenshot the printed `shot.html` with your own browser. Upload each PNG with Capy's PR asset tool (`tools.pr.uploadAsset`), only for public repositories, and embed the URL it returns with the caption as alt text. -2. **Why.** The cause and the effect the user will see, in two or three sentences. -3. **Approvals.** `gstack-approvals render`. -4. **Evidence.** Checks green at the head, gate or train results, flaky cases. +2. **Why.** The problem in the person's words first, then the cause and the effect the user will see, + in two or three sentences. +3. **Blast radius.** What else the change touches and the one fact that makes that safe, with the + evidence-ladder rung that fact reached. +4. **Approvals.** `gstack-approvals render`. +5. **Evidence.** Checks green at the head, gate or train results, flaky cases. 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. diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index d85b1656d..549999ec0 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -1097,8 +1097,10 @@ PR selection (`scripts/test-pr-profile.ts`) classifies every changed file. A fil mapped by a paid case's touchfiles selects that case. These files are exempt and select nothing: docs, any non-paid `*.test.ts(x)`, `FREE_ONLY_PR_FILES` (the free and paid duration seeds, `scripts/ubicloud/**`, every workflow that runs no -paid evals, and free-only tools), and the fixtures in `FREE_FIXTURES` -(`test/helpers/free-fixtures-data.ts`, each fixture with its free consumers). +paid evals, `captain/templates/**`, and free-only tools), the release metadata +in `RELEASE_METADATA_FILES` (`NOTICE.md`, `LICENSE`, `licenses/**`), and the +fixtures in `FREE_FIXTURES` (`test/helpers/free-fixtures-data.ts`, each fixture +with its free consumers). Unmapped files under `DERIVABLE_PREFIXES` (bin/, lib/, scripts/, browse/, design/, make-pdf/, hosts/, extension/, model-overlays/, agents/, test/helpers/, capy/) are not unknown: `scripts/pr-dependencies.ts` derives the cases whose @@ -1506,6 +1508,20 @@ summary). Files a probe proved safe despite a `/tmp` literal are force-included `WINDOWS_CURATED_FLOOR`; when Windows-safe tests are deleted on purpose, lower it in the same PR with a reason. +To see whether one test is in the subset, and why not when it is excluded: + +```bash +bun run scripts/test-free-shards.ts --windows-only --list | grep test/.test.ts +``` + +A test a content pattern excludes wrongly joins the subset through +`KNOWN_WINDOWS_SAFE` (with its reason) or, for a `/tmp` literal a probe proved +safe, `WINDOWS_PROBE_SAFE`. A test that fails on `windows-latest` for a reason +the patterns cannot see (it expects the Linux-host lanes verdict, skips itself +on win32, drives a POSIX signal path) leaves it through a +`KNOWN_WINDOWS_INCOMPATIBLE` entry naming the reason; rerun `--list` to confirm +the file moved. + Native qualification campaigns (Windows cookie extraction, Windows launch diagnostics, Dia on macOS) run only on dispatch of `native-qualification.yml`: diff --git a/docs/captain.md b/docs/captain.md index faba03ec7..deae58020 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -232,7 +232,7 @@ recommendation at full scope. `gstack-owner-brief render --captain` prints it fr autoplan run. **C. The merge ask** ([template](../captain/templates/merge-ask.md)): captioned -before/after images, why, the Approvals table, and the evidence. /ship writes the PR +before/after images, why, blast radius, the Approvals table, and the evidence. /ship writes the PR body in this shape on Capy; the controller's merge report shows the key image inline. ## The digest diff --git a/docs/pregate.md b/docs/pregate.md index a1bf4619e..975e20f5f 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -566,7 +566,10 @@ that reads them ships. The worked gbrain configuration is in The `secrets` check cannot be downgraded, and that is right for a key. It also refused 1,800 per-trial dummy tokens in eval receipts (`"key": "sk-trial-"` -reads as `env.kv`), an 11-digit Actions run id read as a phone number, and +reads as `env.kv`), an 11-digit Actions run id read as a phone number (one +after a run word such as `run`, `screen` or `gate` is exempt in the scanner +since the GSTA-121 follow-up; the allowlist still covers one after any other +word), and excalidraw.com's public Firebase key that every rebuild of a bundle re-adds (harness-evals #81/#83, GSTA-102, GSTA-106). `allowlist` in the policy names those, each with its scope and its reason: diff --git a/docs/review-policy.md b/docs/review-policy.md index a9b44f990..046220eb6 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -39,6 +39,31 @@ floor above; everything else is INFORMATIONAL. Data loss, a trust-boundary bypass and uncapped spend stay forced CRITICAL (the sideways sweep rule); no lane, specialist, dedup or saved decision downgrades them. + +### Rulings below the floor + +The floor is a test, and these classes have been put to it, each more than +once, and failed it (#3485 from the /ship of #3484, #3504 from the /ship of +#3487). A finding in one of them is a **note** from the first pass on, named +by its ruling in the PR body's `## Review notes`; it never earns a fix round +or another review pass, and the lead judgment stage drops it from the defect +count with the pointer. The floor itself does not move, so a finding that also +loses data, crosses a trust boundary or spends without a cap stays CRITICAL +whatever class it resembles. + +| Ruling | What it looks like | Why it is below the floor | +|---|---|---| +| **own-artifact hardening** | A check missing between two things the repository's own tooling writes: a screen verdict not compared with the receipt it belongs to; a receipt schema that does not declare a key the writer records; a policy loader that defaults when the base branch's own file is invalid; a size read that follows a symlink inside the repository. | The only writer is the repository's maintainers through its tools; no caller inside the intended case reaches the gap, and no test fails. A writer outside that trust boundary makes it a bypass, and CRITICAL. | +| **unwired or unread** | A helper with no caller, a field written and never read, a duplicated helper, a header comment naming a step that does not run it, a key-version constant that moved before any release carried the old value. | Nothing takes the path, so nothing breaks; it is dead code to delete or wire in its own change. | +| **bounded repeat work** | A re-dispatch on every call, a redundant screen after main moves, an orphaned scratch branch, a refusal that could come one step earlier in the sequence, a settle that could wait one more check. | The operation completes, nothing is lost, and the cost is one more bounded run; uncapped spend is the sideways sweep's CRITICAL, not this class. | +| **conservative refusal** | Stale state scoped wider than its cause: a RED verdict that keeps refusing a head after main moved, a pending result that keeps refusing after it settled. | A refusal costs a rerun and never passes something wrong; a wrong pass would be the floor. | +| **artifact presentation** | A string from an artifact printed verbatim into a parsed line (whitespace, control characters); a classifier that reads a non-zero exit code beside a non-RED verdict as RED. | Display and classification of the repository's own output; it forges no parsed field. | +| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. | + +A reviewer that wants a class promoted argues it against the floor with a +reaching input, in the finding, and the owner rules; the table records the +rulings, it does not close them. + ## `NO FINDINGS` is a result A reviewer that finds nothing answers `NO FINDINGS` and nothing else. That is a @@ -171,7 +196,7 @@ 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`); 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. +- `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-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary`; 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. diff --git a/docs/skills.md b/docs/skills.md index 66827cd33..24ab4a175 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -735,6 +735,17 @@ Every `/ship` run builds a code path map from your diff, searches for correspond A lot of branches die when the interesting work is done and only the boring release work is left. Humans procrastinate that part. AI should not. +### The PR body + +`/ship` writes the PR body from the diff, the receipt and the review, and a reader who was not in the session decides the merge from it. On Capy (`CAPY_PR: on`) the body opens with four sections, in this order, before the standard Summary, Test plan and Documentation sections: + +1. **`## What changed`**: one to three captioned before/after images from `gstack-pr-shots` or a UI screenshot, highest-signal first, with a collapsed text version under each. +2. **`## Why`**: two or three sentences. The first states the problem in the person's words (the issue, the message, the failing run), then the cause and the user-visible effect. +3. **`## Blast radius`**: what else the change touches and the one fact that makes that safe, with the evidence-ladder rung that fact reached (a test that exercises the shared path, a grep over every caller, a measured run). A change to a shared helper, a generated file, a selection list or a policy default always has one; a leaf fix says so in one line. +4. **`## Approvals`**: the table from `gstack-approvals render`, verbatim. + +Before the body is composed, `gstack-prose-lint` reads the Why, Summary and Blast radius drafts (`--sections 'Why,Summary,Blast radius' --gate`) and the CHANGELOG entry (`--base origin/ --gate`), and a `PROSE_LINT_FOUND` line names the sentence to rewrite; the rest of the body is advisory. The Captain's merge-ask template (`captain/templates/merge-ask.md`) carries the same four sections, so a READY report and its PR say the same thing. + ### Versions: where `/ship` looks, and when it ships without one `/ship` bumps a version only when the project says where the version lives. It checks, in order: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 04255e7bb..a1089de32 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2373,7 +2373,12 @@ exact tokens it finds in these positions: `gates`, `backstop`, `bisect`, `rerun`, `dispatched`, `dispatch`, `failed`, `passed` (`failed 40269992077 | rerun passed 40287239206`, `gate 40323989742`, `run #19368712345`); a generic word such as `id`, - `job`, `attempt` or `workflow` exempts nothing; + `job`, `attempt` or `workflow` exempts nothing. This position is exempt in + the scanner itself (`isCiRunIdentifier` in `lib/redact-patterns.ts`), so + `gstack-approvals add`, `gstack-decision-trail` and the pregate secrets + check accept it too, not only `gstack-post`. A national number written + without its `+` right after a run word reads as a run id there; that is the + accepted residual of the ruling that a run id is not a phone number; - every 11-digit id chained after an allowlisted one through `, `, ` and `, ` & `, ` or ` or ` / `, optionally with a `(#1234)` between (`screen 40269992077 (#3434) and 40275443393 (#3453)`). diff --git a/lib/gstack-post.ts b/lib/gstack-post.ts index 0bd643598..80e991e09 100644 --- a/lib/gstack-post.ts +++ b/lib/gstack-post.ts @@ -21,6 +21,7 @@ */ import { createHash } from "crypto"; import { scan, type Finding, type RepoVisibility } from "./redact-engine"; +import { CI_RUN_WORD_BEFORE } from "./redact-patterns"; import { RECEIPT_FENCE } from "./ship-receipt"; import { canonicalRemote } from "./remote-identity"; import { parseChangelog } from "./changelog-check"; @@ -329,8 +330,6 @@ function repoReleaseVersions(env: PostEnv): string[] { return [...versions]; } -/** A run word with an optional ':' '#' '=' right before an 11-digit token: `run 19368712345`, `gate=40323989742`, `rerun passed 40287239206`. */ -const RUN_WORD_BEFORE = /\b(?:runs?|screens?|gates?|backstop|bisect|rerun|dispatch(?:ed)?|failed|passed)\s*[:#=]?\s*$/i; /** Between two 11-digit tokens of one list: `, `, ` and `, ` & `, ` or `, ` / `, with an optional `(#1234)` after the first. */ const RUN_ID_CHAIN = /^(?:\s*\(#\d+\))?\s*(?:,|and|&|or|\/)\s*$/i; @@ -344,7 +343,9 @@ const RUN_ID_CHAIN = /^(?:\s*\(#\d+\))?\s*(?:,|and|&|or|\/)\s*$/i; * word on the same line (run, runs, screen, screens, gate, gates, backstop, * bisect, rerun, dispatched, dispatch, failed, passed, with an optional ':' * '#' '=' between: `failed 40269992077`, `gate 40323989742`, - * `run #19368712345`; generic words such as id, job, attempt and workflow + * `run #19368712345`; the engine's isCiRunIdentifier exempts that position on + * its own since the run-id fix for approval records, and the same regex drives + * the chain below; generic words such as id, job, attempt and workflow * exempt nothing, since `customer id 14155551234` is a number), plus every 11-digit id chained * after an allowlisted one through ", ", " and ", " & ", " or " or " / ", * optionally with a "(#1234)" between (`screen 40269992077 (#3434) and @@ -361,7 +362,7 @@ function receiptDigitTokens(texts: string[]): string[] { let chainFrom = -1; for (const m of line.matchAll(/(?= 0 && RUN_ID_CHAIN.test(line.slice(chainFrom, at))); + const listed = CI_RUN_WORD_BEFORE.test(line.slice(0, at)) || (chainFrom >= 0 && RUN_ID_CHAIN.test(line.slice(chainFrom, at))); if (listed) tokens.add(m[0]); chainFrom = listed ? at + m[0].length : -1; } diff --git a/lib/pregate/import-graph.ts b/lib/pregate/import-graph.ts index 22b90e4fb..19176e94b 100644 --- a/lib/pregate/import-graph.ts +++ b/lib/pregate/import-graph.ts @@ -49,7 +49,11 @@ const scanner = new Bun.Transpiler({ loader: 'tsx' }); // `( ) [ ] +` appear in framework route paths: Next.js route groups `(app)`, // dynamic segments `[id]` / `[[...slug]]`, SvelteKit `+page.svelte`. A literal // that matches more text only gains an edge when it resolves to a repo file. -const QUOTED = /['"`]([A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g; +// A relative literal (`'../ship/sections/pr-body.md.tmpl'` read through +// `new URL(…, import.meta.url)` or `readFileSync`) resolves from the file that +// names it, like a relative import; without it a test reading a template by +// relative path had no edge to the template (GSTA-121's test/ship-publication-gates.test.ts). +const QUOTED = /['"`]((?:\.\.?\/)*[A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g; const JOIN = /\bjoin\(([^()]*)\)/g; const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.mjs']; // A statement starts a line or follows `;` or a compound statement's `:`. The module spec is diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 6ece3ff4b..9c820900c 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -328,16 +328,28 @@ export function isNumericMetadataValue(span: string, match: RegExpExecArray): bo * command. Only the position decides: a run/job flag (`--run 37235771700`, * `--job=111534615007`), a `gh run view|watch|rerun|download|cancel` * argument, an `actions/runs/` or `/job/` URL segment, or a - * `run_id` / `job_id` key. The same digits anywhere else still report. + * `run_id` / `job_id` key. An 11-digit id (every GitHub Actions run id since + * 2025) also counts right after a run word on the same line, the way a ship + * receipt, a READY block or an approval record names one (`screen 40477638211 + * GREEN`, `gate=40323989742`, `rerun passed 40287239206`, `run #19368712345`); + * generic words (id, job, attempt, workflow) exempt nothing, since `customer id + * 14155551234` is a number. A national number written without its `+` right + * after a run word (`failed 14155551234`) is the accepted residual of the + * owner's ruling that a run id is not a phone number; gstack-post's chained-id + * allowlist reads the same position the same way. The same digits anywhere + * else still report. */ const CI_ID_POSITION_BEFORE = /(?:--(?:run|job)(?:-id)?(?:[ \t]+|[ \t]*=[ \t]*)|\bgh[ \t]+run[ \t]+(?:view|watch|rerun|download|cancel)[ \t]+|\/actions\/runs\/|\/jobs?\/|(?:run|job)_id["']?[ \t]*[:=][ \t]*["']?)$/i; +/** A run word with an optional ':' '#' '=' right before an 11-digit token; shared with gstack-post's chained-id allowlist. */ +export const CI_RUN_WORD_BEFORE = /\b(?:runs?|screens?|gates?|backstop|bisect|rerun|dispatch(?:ed)?|failed|passed)\s*[:#=]?\s*$/i; export function isCiRunIdentifier(span: string, match: RegExpExecArray): boolean { if (!/^\d+$/.test(span)) return false; const input = match.input ?? ""; const { start } = spanBounds(match); const lineStart = input.lastIndexOf("\n", start - 1) + 1; - return CI_ID_POSITION_BEFORE.test(input.slice(Math.max(lineStart, start - 80), start)); + const before = input.slice(Math.max(lineStart, start - 80), start); + return CI_ID_POSITION_BEFORE.test(before) || (span.length === 11 && CI_RUN_WORD_BEFORE.test(before)); } /** diff --git a/package.json b/package.json index c42be4052..2b5266344 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.278", + "version": "1.91.279", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/review/sections/review-army.md b/review/sections/review-army.md index 22a98c1fc..885f3f24c 100644 --- a/review/sections/review-army.md +++ b/review/sections/review-army.md @@ -173,20 +173,22 @@ Core findings never earn a specialist confidence boost. Preserve `advisory`, #### 4. Lead judgment -Reviewers fill the space they are given. Before any merged finding counts, the -parent reads each one and drops or downgrades it on these grounds, saying so: -- **Nit gravity.** When a reviewer's whole return is style, naming and preference, - the code is probably fine; write that down and count nothing from it as a defect. +Reviewers fill the space they are given. Before a merged finding counts, the +parent reads it and drops or downgrades it on these grounds, saying so: +- **Nit gravity.** A reviewer whose whole return is style, naming and preference + found nothing; say so and count no defect. - **Hypothetical, not actual.** A defect names the call site or input that reaches - it. One that does not is a note until someone traces it. + it; one that does not is a note until someone traces it. - **Structure for a case that does not exist.** Advice to abstract, generalize or - guard a path nothing takes is advisory, never a defect. + guard a path nothing takes is advisory. - **A different design is not a defect.** "I would have done it differently" with - no failing case behind it is dropped. + no failing case is dropped. - **Missing context.** A finding that assumes a rule the repository documents otherwise is dropped with the pointer. -Agreement between reviewers that did not see each other's output is the strongest -signal; the `MULTI-SPECIALIST CONFIRMED` boost above records it. A reviewer that +- **On record.** A class `docs/review-policy.md` rules below the floor is a note, + named by its ruling. +Independent agreement between reviewers is the strongest signal (the +`MULTI-SPECIALIST CONFIRMED` boost records it). A reviewer that returned `NO FINDINGS` was right until a traced defect says otherwise. #### 5. Apply specialist confidence gates diff --git a/scripts/harness-version.json b/scripts/harness-version.json index b651b9656..f71515581 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -17,7 +17,7 @@ "scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6}, "scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"}, "scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}, - "scripts/test-pr-profile.ts": {"blob":"a80de69f1923afed67127640d68c99e1a8528c5b","since":6,"nonBehavioral":"classify the new weekly-release.yml workflow and scripts/release-cut.ts as free-lane only PR files: the release cut runs on a cron and manual dispatch after merge and inside no case, so no case's selection on a PR, input or verdict changes"}, + "scripts/test-pr-profile.ts": {"blob":"188710401a08156f585aebcb699151c60e3d6273","since":6,"nonBehavioral":"#3499: RELEASE_METADATA_FILES exempts NOTICE.md, LICENSE and licenses/** and captain/templates/** joins FREE_ONLY_PR_FILES; selection only, no case runs or is judged differently"}, "scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6}, "test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6}, "test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6}, diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 772a4d663..7098f3c7b 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -75,6 +75,7 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> { file: 'test/pregate-dart.test.ts', reason: 'its dart, flutter and fvm stand-ins are bash scripts on a colon-joined PATH, which Bun.which and CreateProcess cannot use on Windows, and its containment case needs a POSIX symlink; the Dart lane code it drives is POSIX-run like the other pregate fixtures' }, { file: 'test/pregate-non-js.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin and expects the Linux-host lanes verdict, like test/pregate-docs-lane-review.test.ts and test/pregate-selection.test.ts' }, { file: 'test/pregate-remote-gbrain.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess) and runs the gbrain-shaped ci:ubicloud package script through the POSIX lane runner, like test/pregate-remote.test.ts' }, + { file: 'test/pregate-remote-stamp-only.test.ts', reason: 'expects the Linux-host lanes verdict like test/pregate-remote.test.ts (on Windows the fixture\'s Linux lane is a requires-remote obligation) and skips itself on win32 (describe.skipIf), so the curated lane would run an empty file (#3485, #3486)' }, // C-pregate: these drive bin/gstack-issue-links and bin/gstack-ship-receipt through // test/helpers/pregate-fixture.ts runBin. { file: 'test/stamp-templates.test.ts', reason: 'spawns bin/gstack-tree-receipt through test/helpers/restamp-fixture.ts runBin (shebang via CreateProcess)' }, diff --git a/scripts/resolvers/review-army.ts b/scripts/resolvers/review-army.ts index 67fc6f125..f2a6e2e4a 100644 --- a/scripts/resolvers/review-army.ts +++ b/scripts/resolvers/review-army.ts @@ -218,20 +218,22 @@ Core findings never earn a specialist confidence boost. Preserve \`advisory\`, #### 4. Lead judgment -Reviewers fill the space they are given. Before any merged finding counts, the -parent reads each one and drops or downgrades it on these grounds, saying so: -- **Nit gravity.** When a reviewer's whole return is style, naming and preference, - the code is probably fine; write that down and count nothing from it as a defect. +Reviewers fill the space they are given. Before a merged finding counts, the +parent reads it and drops or downgrades it on these grounds, saying so: +- **Nit gravity.** A reviewer whose whole return is style, naming and preference + found nothing; say so and count no defect. - **Hypothetical, not actual.** A defect names the call site or input that reaches - it. One that does not is a note until someone traces it. + it; one that does not is a note until someone traces it. - **Structure for a case that does not exist.** Advice to abstract, generalize or - guard a path nothing takes is advisory, never a defect. + guard a path nothing takes is advisory. - **A different design is not a defect.** "I would have done it differently" with - no failing case behind it is dropped. + no failing case is dropped. - **Missing context.** A finding that assumes a rule the repository documents otherwise is dropped with the pointer. -Agreement between reviewers that did not see each other's output is the strongest -signal; the \`MULTI-SPECIALIST CONFIRMED\` boost above records it. A reviewer that +- **On record.** A class \`docs/review-policy.md\` rules below the floor is a note, + named by its ruling. +Independent agreement between reviewers is the strongest signal (the +\`MULTI-SPECIALIST CONFIRMED\` boost records it). A reviewer that returned \`NO FINDINGS\` was right until a traced defect says otherwise. #### 5. Apply specialist confidence gates diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index a80de69f1..188710401 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -235,6 +235,7 @@ export const FREE_ONLY_PR_FILES = [ 'scripts/paid-test-durations.json', 'scripts/ubicloud/**', 'capy/skill/**', // The vendored Capy skill (Drive bootstrapper): run by Capy agents, read by no paid case; test/capy-skill-wrapper.test.ts covers it. + 'captain/templates/**', // The /captain crew templates (CREW.md, merge-ask.md, slot-call.md, …): copied into a Captain's drive by people and Captains, read by no paid case; free test/captain-skill.test.ts covers them (#3499). 'tsconfig.test.json', // Read only by `tsc -p tsconfig.test.json` (typecheck:test); Bun's runtime reads tsconfig.json. '.gitignore', // Changes which untracked files git reports, never tracked content a case reads. '.gstack/generated.json', // gstack's own regen registry: gstack-regen/gstack-pregate read it from origin/ of the repo they run in; paid cases run in fixture repos. @@ -285,6 +286,14 @@ export const FREE_ONLY_PR_FILES = [ '.github/workflows/windows-setup-e2e.yml', ] as const; +/** + * Release metadata no paid case reads: the third-party notices and licence + * texts, and the root LICENSE. A courtesy entry or a licence line never + * widens the PR gate (#3499); CHANGELOG.md and VERSION, which the train stamp + * rewrites on every slot, are already exempt in knownNonBehaviorFile. + */ +export const RELEASE_METADATA_FILES = ['NOTICE.md', 'LICENSE', 'licenses/**'] as const; + /** * Inputs every paid case consumes that must always restore the full gate * (fail closed, even when a touchfile or a derived reference also names @@ -317,6 +326,7 @@ export function knownNonBehaviorFile(file: string): boolean { // by free doc/generation checks and are not copied into paid fixtures. || ['AGENTS.md', 'CLAUDE.md', 'agents-digest/gstack-AGENTS.md'].includes(file) || FREE_ONLY_PR_FILES.some(pattern => matchGlob(file, pattern)) + || RELEASE_METADATA_FILES.some(pattern => matchGlob(file, pattern)) || (PERIODIC_ONLY_PR_FILES as readonly string[]).includes(file) || (file.startsWith('test/fixtures/') && Object.keys(FREE_FIXTURES).some(pattern => matchGlob(file, pattern))) // Free test files anywhere (test/, browse/test/, design/test/, make-pdf/test/): no paid case runs or reads a free test. diff --git a/scripts/ubicloud/ubi-runner.sh b/scripts/ubicloud/ubi-runner.sh index 77925713d..19fb9435e 100755 --- a/scripts/ubicloud/ubi-runner.sh +++ b/scripts/ubicloud/ubi-runner.sh @@ -587,35 +587,69 @@ cmd_down() { } # pack SRC: write a gzipped checkout tarball to stdout (tracked + untracked -# files that are not ignored, plus .git; a non-git directory is taken whole). -# A linked worktree's .git is a one-line pointer into the main repository, -# useless on the VM, so it is replaced by a self-contained clone of the -# checked-out branch carrying the worktree's own index. +# files that are not ignored, plus a .git snapshot; a non-git directory is +# taken whole). The live .git is never tarred: another process running git in +# the checkout (a review subagent's diff, a trial merge's worktree add) writes +# index.lock, packed-refs and objects while tar reads, and GNU tar exits 1 +# on a file that changed under it (GSTA-121's PREGATE_REMOTE_INFRA). Instead +# the refs are fetched into a fresh repository through git's own transport, +# which reads a consistent view, the remote.* config is replayed so +# origin/ still resolves, and the index is copied (git writes it +# atomically through a rename). A linked worktree's .git is a one-line +# pointer into the main repository, useless on the VM; the same snapshot +# replaces it, on the worktree's own branch and index. Not carried: submodule +# repositories (.git/modules) and a partial clone's promisor (the fetch pulls +# every missing blob first); gstack has neither. cmd_pack() { - local src tmp="" gitdir branch + local src tmp gitdir branch head entry src=$(cd "${1:-.}" && pwd) if git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - if [ -f "$src/.git" ]; then - branch=$(git -C "$src" rev-parse --abbrev-ref HEAD) - [ "$branch" != HEAD ] || die "pack: $src is a linked worktree on a detached HEAD; check out a branch so the VM gets a self-contained .git" - tmp=$(mktemp -d) - gitdir=$(cd "$src" && cd "$(git rev-parse --git-common-dir)" && pwd) - git clone -q --bare --no-local --single-branch --branch "$branch" "$gitdir" "$tmp/.git" - git -C "$tmp/.git" config core.bare false - # The worktree's index rides along so the VM's status matches, except a - # split index, whose shared half lives only in the original git dir. - [ "$(git -C "$src" config --bool core.splitIndex 2>/dev/null)" = true ] \ - || cp "$(cd "$src" && cd "$(dirname "$(git rev-parse --git-path index)")" && pwd)/index" "$tmp/.git/index" + head=$(git -C "$src" rev-parse --verify -q HEAD) || die "pack: $src has no commits; the VM needs a HEAD to fingerprint" + branch=$(git -C "$src" symbolic-ref -q --short HEAD || true) + tmp=$(mktemp -d) + # The path is baked in: a set -e failure unwinds the function before the trap runs, and a local $tmp is unset there. + trap "rm -rf $(printf %q "$tmp")" EXIT + # No template hooks from a global init.templateDir, and no auto-gc under the tar. + git -c gc.auto=0 init -q --bare --template= "$tmp/.git" + git -C "$tmp/.git" -c gc.auto=0 fetch -q --no-tags --update-shallow "$src" '+refs/*:refs/*' \ + || die "pack: could not snapshot the refs of $src" + if [ -z "$branch" ]; then + branch="detached-${head:0:12}" + git -C "$tmp/.git" fetch -q --no-tags --update-shallow "$src" "+HEAD:refs/heads/$branch" + fi + git -C "$tmp/.git" symbolic-ref HEAD "refs/heads/$branch" + git -C "$tmp/.git" config core.bare false + gitdir=$(cd "$src" && cd "$(git rev-parse --git-common-dir)" && pwd) + while IFS= read -r -d '' entry; do + # A key with no value (`[remote "x"] mirror`) is boolean true. + case $entry in *$'\n'*) ;; *) entry="$entry"$'\n'true ;; esac + git -C "$tmp/.git" config --add "${entry%%$'\n'*}" "${entry#*$'\n'}" + done < <(git -C "$gitdir" config --local --null --get-regexp '^(remote|branch)\.' 2>/dev/null || true) + # The checkout's index rides along so the VM's status matches, except a + # split index, whose shared half lives only in the original git dir. The + # index's blobs that HEAD's tree lacks (staged, intent-to-add, the stages + # of an unmerged entry) are behind no ref, so they travel as a pack of + # their own; without them the VM's write-tree (gstack-wtree) cannot read a + # staged file. + if [ "$(git -C "$src" config --bool core.splitIndex 2>/dev/null)" != true ]; then + cp "$(cd "$src" && cd "$(dirname "$(git rev-parse --git-path index)")" && pwd)/index" "$tmp/.git/index" + # Read the copy, not the live index, so the pack matches it; a gitlink's + # sha (mode 160000) is a submodule commit this object store lacks. + GIT_INDEX_FILE="$tmp/.git/index" git -C "$src" ls-files -s | awk '$1 != "160000" {print $2}' | sort -u \ + | comm -23 - <(git -C "$src" ls-tree -r HEAD | awk '{print $3}' | sort -u) \ + | git -C "$src" pack-objects -q --stdout \ + | git -C "$tmp/.git" unpack-objects -q \ + || die "pack: could not snapshot the staged blobs of $src" fi ( cd "$src" - { git ls-files -z -co --exclude-standard; [ -n "$tmp" ] || printf '.git\0'; } \ + git ls-files -z -co --exclude-standard \ | while IFS= read -r -d '' f; do if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi done \ - | if [ -n "$tmp" ]; then tar --null -T - -c -C "$tmp" .git -zf -; else tar --null -T - -czf -; fi + | tar --null -T - -c -C "$tmp" .git -zf - ) - [ -z "$tmp" ] || rm -rf "$tmp" + rm -rf "$tmp"; trap - EXIT else tar -C "$src" -czf - . fi diff --git a/ship/sections/capy-pr.md b/ship/sections/capy-pr.md index 370dfad87..e0d538cec 100644 --- a/ship/sections/capy-pr.md +++ b/ship/sections/capy-pr.md @@ -1,6 +1,6 @@ -## Step 19-C: Capy PR body (What changed, Why, Approvals) +## Step 19-C: Capy PR body (What changed, Why, Blast radius, Approvals) Read only when Step 19 printed `CAPY_PR: on`. Under Capy a person decides the merge from the PR and the READY report, through a Captain, so the body shows what changed in pictures, why, and who diff --git a/ship/sections/capy-pr.md.tmpl b/ship/sections/capy-pr.md.tmpl index 43f06922f..89598e739 100644 --- a/ship/sections/capy-pr.md.tmpl +++ b/ship/sections/capy-pr.md.tmpl @@ -1,4 +1,4 @@ -## Step 19-C: Capy PR body (What changed, Why, Approvals) +## Step 19-C: Capy PR body (What changed, Why, Blast radius, Approvals) Read only when Step 19 printed `CAPY_PR: on`. Under Capy a person decides the merge from the PR and the READY report, through a Captain, so the body shows what changed in pictures, why, and who diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index df0d0dec1..efffdf030 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -456,20 +456,22 @@ Core findings never earn a specialist confidence boost. Preserve `advisory`, #### 4. Lead judgment -Reviewers fill the space they are given. Before any merged finding counts, the -parent reads each one and drops or downgrades it on these grounds, saying so: -- **Nit gravity.** When a reviewer's whole return is style, naming and preference, - the code is probably fine; write that down and count nothing from it as a defect. +Reviewers fill the space they are given. Before a merged finding counts, the +parent reads it and drops or downgrades it on these grounds, saying so: +- **Nit gravity.** A reviewer whose whole return is style, naming and preference + found nothing; say so and count no defect. - **Hypothetical, not actual.** A defect names the call site or input that reaches - it. One that does not is a note until someone traces it. + it; one that does not is a note until someone traces it. - **Structure for a case that does not exist.** Advice to abstract, generalize or - guard a path nothing takes is advisory, never a defect. + guard a path nothing takes is advisory. - **A different design is not a defect.** "I would have done it differently" with - no failing case behind it is dropped. + no failing case is dropped. - **Missing context.** A finding that assumes a rule the repository documents otherwise is dropped with the pointer. -Agreement between reviewers that did not see each other's output is the strongest -signal; the `MULTI-SPECIALIST CONFIRMED` boost above records it. A reviewer that +- **On record.** A class `docs/review-policy.md` rules below the floor is a note, + named by its ruling. +Independent agreement between reviewers is the strongest signal (the +`MULTI-SPECIALIST CONFIRMED` boost records it). A reviewer that returned `NO FINDINGS` was right until a traced defect says otherwise. #### 5. Apply specialist confidence gates diff --git a/test/approval-record.test.ts b/test/approval-record.test.ts index a38424384..154688981 100644 --- a/test/approval-record.test.ts +++ b/test/approval-record.test.ts @@ -76,6 +76,18 @@ describe('gstack-approvals add and render', () => { expect(fs.existsSync(path.join(state, 'projects'))).toBe(false); }); + test('a GitHub Actions run id after a run word is not a phone number, and the record keeps it (GSTA-121)', () => { + const cwd = repo(); const state = tmp('appr-state-'); + const words = 'Rule-19 grant: re-READY with --no-screen citing screen 40477638211 GREEN at 5619199800a2; gate=40477638210 passed'; + const r = bin(['add', '--ask', 'Third rule 19 grant on the GREEN screen', '--words', words, '--by', 'GStack Minder', '--scope', 'one merge'], { cwd, env: { GSTACK_STATE_ROOT: state } }); + expect(r.status, r.err).toBe(0); + const file = /^APPROVAL_RECORDED: a-[0-9a-f]{12} (.*\.jsonl)$/m.exec(r.out)![1]!; + expect(fs.readFileSync(file, 'utf8')).toContain('screen 40477638211 GREEN'); + const control = bin(['add', '--ask', 'ok', '--words', 'call me on 40477638211 or +14155550123', '--by', 'g', '--scope', 's'], { cwd, env: { GSTACK_STATE_ROOT: state } }); + expect(control.status).toBe(3); + expect(control.err).toContain('pii.phone.e164'); + }); + test('missing fields, bad outcomes and impossible dates are usage errors', () => { const cwd = repo(); const state = tmp('appr-state-'); expect(bin(['add', '--ask', 'x'], { cwd, env: { GSTACK_STATE_ROOT: state } }).err).toContain('APPROVAL_RECORD_INVALID'); diff --git a/test/exercised-map.test.ts b/test/exercised-map.test.ts index 1a783285a..e3967fce9 100644 --- a/test/exercised-map.test.ts +++ b/test/exercised-map.test.ts @@ -161,7 +161,7 @@ describe('the five recorded variant-c misses on this checkout', () => { describe('shadow output never changes the real selection', () => { test('mode, cases, judges and reasons equal the selection without a shadow', () => { - for (const files of [['bin/gstack-config'], ['design/src/evolve.ts'], ['review/SKILL.md.tmpl'], ['lib/fs-utils.ts', 'docs/x.md'], ['ETHOS.md']]) { + for (const files of [['bin/gstack-config'], ['design/src/evolve.ts'], ['review/SKILL.md.tmpl'], ['lib/fs-utils.ts', 'docs/x.md'], ['SECURITY.md']]) { const withShadow = selectPrProfile({ selectedE2E: [], selectedJudges: [], changedFiles: files }); const without = selectPrProfile({ selectedE2E: [], selectedJudges: [], changedFiles: files, exercised: null }); expect(withShadow.shadow, files.join()).toBeDefined(); diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index f76ae9a38..b2c12cb51 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -19,6 +19,10 @@ /** * E2E test touchfiles — keyed by testName (the string passed to runSkillTest). * Each test lists the file patterns that, if changed, require the test to run. + * + * ETHOS.md is registered under the /office-hours and /plan-ceo-review cases, + * whose prompts quote it; the tier>=3 preamble (Search Before Building) only + * names it as a read-on-demand pointer that no paid case asserts on (#3499). */ export const E2E_TOUCHFILES: Record = { 'ship-skipped-queued-finding': [ 'lib/outside-review-result.ts', 'lib/gate-outcomes.ts', 'bin/gstack-codex-probe','bin/gstack-state-root.sh', 'lib/state-root.ts', 'package.json', 'bun.lock', '.github/docker/Dockerfile.ci', @@ -220,7 +224,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-target-aw.json", - 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-plan-ceo-plan-mode.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'bin/gstack-question-preference', 'test/helpers/fake-plan-seed.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', + 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'ETHOS.md', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-plan-ceo-plan-mode.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'bin/gstack-question-preference', 'test/helpers/fake-plan-seed.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', 'test/fixtures/design-scope-announcement-ao.json', 'test/fixtures/design-scope-declaration-ak.json', @@ -310,7 +314,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", "test/fixtures/design-scope-checkpoint-at.json", - 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-plan-mode-no-op.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'bin/gstack-question-preference', 'test/helpers/fake-plan-seed.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', + 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'ETHOS.md', 'plan-eng-review/**', 'plan-design-review/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-plan-mode-no-op.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-scope-selection.ts', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'bin/gstack-question-preference', 'test/helpers/fake-plan-seed.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', 'test/fixtures/design-scope-announcement-ao.json', 'test/fixtures/design-scope-declaration-ak.json', @@ -332,7 +336,7 @@ export const E2E_TOUCHFILES: Record = { 'office-hours-auto-mode': [ 'ETHOS.md','bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/fixtures/auto-decide-recommendation-361c.json', 'test/fixtures/auto-decide-target-361c.json', - 'test/fixtures/native-auto-decide-ag.json', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'office-hours/**', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-office-hours-auto-mode.test.ts', 'test/helpers/plan-mode-evidence.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', + 'test/fixtures/native-auto-decide-ag.json', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'office-hours/**', 'ETHOS.md', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-office-hours-auto-mode.test.ts', 'test/helpers/plan-mode-evidence.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/pty-screen.ts', 'bin/gstack-context-recovery'], 'office-hours-phase4-fork': [ 'ETHOS.md','bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'office-hours/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/question-tuning.ts', 'test/helpers/llm-judge.ts', 'test/skill-e2e-office-hours-phase4.test.ts', 'bin/gstack-context-recovery' ], 'llm-judge-recommendation': ['codex/**', 'test/helpers/llm-judge.ts', 'test/llm-judge-recommendation.test.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'codex/SKILL.md.tmpl', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts'], @@ -350,7 +354,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/autoplan-public-narration-ad.json', 'test/fixtures/auto-decide-completed-mode-f359.json', 'test/helpers/plan-count-transcript.ts', 'test/fixtures/plan-count-cross-cwd-ancestry-0bcd.json', - 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-session-kind', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'plan-ceo-review/**', 'bin/gstack-question-preference', 'bin/gstack-config', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'hosts/claude/hooks/question-preference-hook.ts', 'lib/bin-context.ts', 'lib/remote-identity.ts', 'hosts/claude/hooks/spawned-directive.ts', 'lib/is-conductor.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-auto-decide-preserved.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'test/helpers/fake-plan-seed.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', + 'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-session-kind', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'plan-ceo-review/**', 'ETHOS.md', 'bin/gstack-question-preference', 'bin/gstack-config', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'hosts/claude/hooks/question-preference-hook.ts', 'lib/bin-context.ts', 'lib/remote-identity.ts', 'hosts/claude/hooks/spawned-directive.ts', 'lib/is-conductor.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-auto-decide-preserved.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'test/helpers/fake-plan-seed.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', 'test/helpers/plan-count-fixture.ts', 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/fixtures/auto-decide-mode-selector-749df.json', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'hosts/claude/hooks/hook-log.ts', 'bin/gstack-context-recovery'], @@ -380,7 +384,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/ceo-mode-pending-submit.json', "test/fixtures/plan-count-cross-cwd-ancestry-0bcd.json", - "test/fixtures/ceo-mode-colon-at.json", 'plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/ceo-mode-option.ts', 'test/fixtures/ceo-expansion-disposition-77.json', 'test/helpers/plan-count-transcript.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-fixture.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/eng-prerequisite-77.json', 'test/skill-e2e-plan-ceo-mode-routing.test.ts', 'test/fixtures/ceo-hold-posture-l.json', 'test/fixtures/ceo-mode-labels-l.json', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/ceo-mode-prerequisite-o-calls.json', 'test/fixtures/ceo-mode-prerequisite-q-call.json', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/design-preview-v-screen.txt', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/ceo-mode-preview-aa-screen.txt', 'test/fixtures/ceo-count-mode-preview-aa-screen.txt', 'test/fixtures/ceo-expansion-auq-ac.json', 'test/helpers/plan-count-pending-question.ts', 'test/fixtures/ceo-barless-submit-ac.json', 'test/fixtures/pending-question-completion-ad.json', 'test/fixtures/ceo-mode-posture-ad.json', 'test/fixtures/ceo-mode-full-ad.json', 'test/fixtures/ceo-prerequisite-ad-v2.json', 'test/fixtures/ceo-hold-posture-ag.json', + "test/fixtures/ceo-mode-colon-at.json", 'plan-ceo-review/**', 'ETHOS.md', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/ceo-mode-option.ts', 'test/fixtures/ceo-expansion-disposition-77.json', 'test/helpers/plan-count-transcript.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-fixture.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/eng-prerequisite-77.json', 'test/skill-e2e-plan-ceo-mode-routing.test.ts', 'test/fixtures/ceo-hold-posture-l.json', 'test/fixtures/ceo-mode-labels-l.json', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/fixtures/ceo-mode-prerequisite-o-calls.json', 'test/fixtures/ceo-mode-prerequisite-q-call.json', 'test/fixtures/ceo-preview-u-call.json', 'test/fixtures/ceo-preview-u-screen.txt', 'test/fixtures/design-preview-v-screen.txt', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/ceo-mode-preview-aa-screen.txt', 'test/fixtures/ceo-count-mode-preview-aa-screen.txt', 'test/fixtures/ceo-expansion-auq-ac.json', 'test/helpers/plan-count-pending-question.ts', 'test/fixtures/ceo-barless-submit-ac.json', 'test/fixtures/pending-question-completion-ad.json', 'test/fixtures/ceo-mode-posture-ad.json', 'test/fixtures/ceo-mode-full-ad.json', 'test/fixtures/ceo-prerequisite-ad-v2.json', 'test/fixtures/ceo-hold-posture-ag.json', "test/fixtures/ceo-hold-commitment-ar.json", 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/owned-claude-transcript.ts', 'scripts/resolvers/tasks-section.ts', 'test/fixtures/ceo-expansion-pacing-77.json', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'], @@ -417,7 +421,7 @@ export const E2E_TOUCHFILES: Record = { 'ship-section-loading': [ 'ship/**', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-ship-section-loading.test.ts', 'scripts/resolvers/testing.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'], 'plan-ceo-section-loading': [ 'ETHOS.md', - 'test/fixtures/ceo-fill-lifetime.json', 'plan-ceo-review/**', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/helpers/ceo-section-loading-fixture.ts', 'test/helpers/ceo-stale-fill-decision.ts', 'test/skill-e2e-plan-ceo-review-section-loading.test.ts', 'test/fixtures/ceo-section-loading-l-report.md', 'test/fixtures/ceo-section-loading-q-report.md', 'test/fixtures/ceo-section-r-rejected-report.md', 'test/fixtures/ceo-section-s-trace-report.md', 'test/fixtures/ceo-section-u-report.md', 'test/fixtures/ceo-section-y-report.md', 'test/fixtures/ceo-section-aa-report.md', 'test/fixtures/sdk-stale-table-ad-v3.json', 'test/fixtures/sdk-ordering-ae.json', 'test/fixtures/sdk-columnar-af.json', 'test/fixtures/sdk-order-b-ag.json', 'test/fixtures/sdk-schedule-continuation-ah.json', 'test/fixtures/sdk-original-order-ai.json', 'test/fixtures/sdk-compact-sequence-aj.json', + 'test/fixtures/ceo-fill-lifetime.json', 'plan-ceo-review/**', 'ETHOS.md', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/helpers/ceo-section-loading-fixture.ts', 'test/helpers/ceo-stale-fill-decision.ts', 'test/skill-e2e-plan-ceo-review-section-loading.test.ts', 'test/fixtures/ceo-section-loading-l-report.md', 'test/fixtures/ceo-section-loading-q-report.md', 'test/fixtures/ceo-section-r-rejected-report.md', 'test/fixtures/ceo-section-s-trace-report.md', 'test/fixtures/ceo-section-u-report.md', 'test/fixtures/ceo-section-y-report.md', 'test/fixtures/ceo-section-aa-report.md', 'test/fixtures/sdk-stale-table-ad-v3.json', 'test/fixtures/sdk-ordering-ae.json', 'test/fixtures/sdk-columnar-af.json', 'test/fixtures/sdk-order-b-ag.json', 'test/fixtures/sdk-schedule-continuation-ah.json', 'test/fixtures/sdk-original-order-ai.json', 'test/fixtures/sdk-compact-sequence-aj.json', "test/fixtures/sdk-reported-coordination-ar.md", "test/fixtures/sdk-ordered-schedule-ar.md", 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'], // Data-driven behavioral guard for the 'plan'/'prompt' carves: one case per @@ -477,7 +481,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/plan-floor-routing-361c.json', - 'test/fixtures/ceo-report-permission-fb10.json', 'test/fixtures/plan-floor-permission-fb10.json', 'test/helpers/plan-count-file-permission.ts', 'test/fixtures/plan-edit-cropped-permission-1579.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/design-crop-gutter-ap.json', 'test/fixtures/plan-count-crop-ak.json', 'test/fixtures/plan-count-long-edit-0bcd.json', 'test/fixtures/plan-count-cropped-wrap-6714.json', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-finding-floor.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-artifacts.ts', + 'test/fixtures/ceo-report-permission-fb10.json', 'test/fixtures/plan-floor-permission-fb10.json', 'test/helpers/plan-count-file-permission.ts', 'test/fixtures/plan-edit-cropped-permission-1579.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/design-crop-gutter-ap.json', 'test/fixtures/plan-count-crop-ak.json', 'test/fixtures/plan-count-long-edit-0bcd.json', 'test/fixtures/plan-count-cropped-wrap-6714.json', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'ETHOS.md', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-finding-floor.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-artifacts.ts', 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'bin/gstack-context-recovery'], @@ -567,7 +571,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/plan-count-cropped-wrap-6714.json', "test/fixtures/plan-count-cross-cwd-ancestry-0bcd.json", - 'test/fixtures/design-crop-gutter-ap.json', 'bin/gstack-config', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/plan-count-transcript.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/eng-prerequisite-77.json', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/helpers/plan-count-file-permission.ts', 'test/fixtures/plan-edit-cropped-permission-1579.json', 'test/fixtures/plan-count-edit-permission-t.json', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/plan-count-permission-ah.json', + 'test/fixtures/design-crop-gutter-ap.json', 'bin/gstack-config', 'plan-ceo-review/**', 'ETHOS.md', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/plan-count-transcript.ts', 'test/fixtures/autoplan-public-narration-ad.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/fixtures/ceo-prerequisite-n-call.json', 'test/fixtures/eng-prerequisite-77.json', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts', 'test/fixtures/ceo-checkbox-l.screen.txt', 'test/helpers/plan-count-file-permission.ts', 'test/fixtures/plan-edit-cropped-permission-1579.json', 'test/fixtures/plan-count-edit-permission-t.json', 'test/fixtures/plan-count-owned-permission-v.json', 'test/fixtures/ceo-questionless-w-native.json', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/fixtures/plan-count-permission-ac.json', 'test/fixtures/plan-count-permission-ad.json', 'test/fixtures/plan-count-permission-ae.json', 'test/fixtures/plan-count-permission-target-ad-v2.json', 'test/fixtures/plan-count-permission-ah.json', 'test/fixtures/plan-count-crop-ak.json', @@ -618,7 +622,7 @@ export const E2E_TOUCHFILES: Record = { 'plan-ceo-review-prosons-cadence': [ 'ETHOS.md', 'test/helpers/prosons-posture.ts', "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", 'plan-ceo-review/**', 'ETHOS.md', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", @@ -627,7 +631,7 @@ export const E2E_TOUCHFILES: Record = { 'plan-review-prosons-format': [ 'ETHOS.md', 'test/helpers/prosons-posture.ts', "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", 'plan-ceo-review/**', 'ETHOS.md', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'model-overlays/opus-4-7.md', 'test/skill-e2e-plan-prosons.test.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts", @@ -764,7 +768,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", "test/fixtures/disabled-dated-record-at.json", - 'plan-eng-review/**', 'plan-ceo-review/**', 'hosts/claude.ts', 'hosts/define-host.ts', + 'plan-eng-review/**', 'plan-ceo-review/**', 'ETHOS.md', 'hosts/claude.ts', 'hosts/define-host.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/index.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/constants.ts', 'bin/gstack-config', 'bin/gstack-codex-probe', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-brain-enqueue', 'test/helpers/session-runner.ts', @@ -906,7 +910,7 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/outside-async-task-m-events.json', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json','scripts/resolvers/learnings.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', - 'scripts/resolvers/design-doc-discovery.ts', 'bin/gstack-design-doc-find', 'bin/gstack-ceo-plan-find', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'], + 'scripts/resolvers/design-doc-discovery.ts', 'bin/gstack-design-doc-find', 'bin/gstack-ceo-plan-find', 'plan-ceo-review/**', 'ETHOS.md', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'], // Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs 'benchmark-providers-live': ['bin/gstack-model-benchmark', 'test/helpers/providers/**', 'test/helpers/benchmark-runner.ts', 'test/helpers/pricing.ts', 'test/skill-e2e-benchmark-providers.test.ts'], diff --git a/test/paid-free-boundary.test.ts b/test/paid-free-boundary.test.ts index 87c208ebc..f66ffec4a 100644 --- a/test/paid-free-boundary.test.ts +++ b/test/paid-free-boundary.test.ts @@ -106,6 +106,25 @@ describe('paid/free dependency boundary', () => { } finally { fs.rmSync(root, { recursive: true, force: true }); } }); + test('release metadata and crew templates never widen the PR gate; ETHOS.md selects only the prompts that quote it (#3499)', () => { + // Release metadata no paid case reads keeps the PR profile, alone and beside a mapped prompt (#3499). + for (const file of ['NOTICE.md', 'LICENSE', 'licenses/MIT-pstack.txt', 'captain/templates/merge-ask.md']) { + expect(fs.existsSync(path.join(ROOT, file)), file).toBe(true); + const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: [file, 'CHANGELOG.md'] }); + expect(result.coverage?.mode, file).toBe('pr'); + expect(result.coverage?.unknownFiles, file).toEqual([]); + expect(result.selection, file).toEqual({ e2e: [], judges: [] }); + } + // ETHOS.md is read by the /office-hours and /plan-ceo-review prompts, so it selects their cases and judge, not the whole gate. + const ethos = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: ['ETHOS.md', 'NOTICE.md'] }); + expect(ethos.coverage?.mode).toBe('pr'); + expect(ethos.coverage?.unknownFiles).toEqual([]); + expect(ethos.selection.e2e).toContain('plan-ceo-review-plan-mode'); + expect(ethos.selection.e2e).toContain('office-hours-auto-mode'); + expect(ethos.selection.judges).toEqual(['plan-ceo-review/SKILL.md modes']); + expect(ethos.selection.e2e!.length).toBeLessThan(10); + }); + test('only audited free-only dependencies avoid unknown-dependency fallback', () => { for (const file of [...FREE_ONLY_PR_FILES, 'scripts\\test-free-shards.ts']) { const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: [file] }); @@ -114,7 +133,7 @@ describe('paid/free dependency boundary', () => { expect(result.selection).toEqual({ e2e: [], judges: [] }); } // Tracked files outside every mapping and outside the derivable directories restore the full gate. - for (const file of ['LICENSE', 'SECURITY.md', 'conductor.json', 'NOTICE.md']) { + for (const file of ['SECURITY.md', 'conductor.json']) { const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: [FREE_ONLY_PR_FILES[0], file] }); expect(result.coverage?.mode, file).toBe('full-fallback'); expect(result.coverage?.unknownFiles).toContain(file); diff --git a/test/pregate-allowlist.test.ts b/test/pregate-allowlist.test.ts index 5c5ea19a9..5cafdb346 100644 --- a/test/pregate-allowlist.test.ts +++ b/test/pregate-allowlist.test.ts @@ -84,17 +84,17 @@ describe('allowlist: the secrets check', () => { expect(secrets(shape).status).toBe(EXIT.fail); }); - // Value: protects=a category entry covers an advisory pii hit (a run id read as a phone number) and the WARN count drops; + // Value: protects=a category entry covers an advisory pii hit (a run id after a generic word, which the engine's run-word exemption does not cover, read as a phone number) and the WARN count drops; // fails_when=category matching is id-only; why_new=new; seam=none; no_claim=none test('a category entry silences the advisory pii finding for a run id', () => { const repo = fixture({ ...BASE_PREGATE, allowlist: [{ finding: 'pii', pattern: '\\d{11}', reason: 'GitHub Actions run ids in reports' }] }); - commit(repo, { 'docs/runs.md': 'run 40302077688 passed\n' }); + commit(repo, { 'docs/runs.md': 'job 40302077688 passed\n' }); expect(secrets(repo).status).toBe(EXIT.ok); const r = row(repo); expect([r.status, r.code]).toEqual(['pass', undefined]); expect(r.detail).toContain('1 allowlisted (pii.phone.e164)'); const control = fixture(BASE_PREGATE); - commit(control, { 'docs/runs.md': 'run 40302077688 passed\n' }); + commit(control, { 'docs/runs.md': 'job 40302077688 passed\n' }); secrets(control); expect([row(control).status, row(control).code]).toEqual(['warn', 'PREGATE_SECRETS']); expect(row(control).detail).toContain('pii.phone.e164'); diff --git a/test/pregate-import-graph-paths.test.ts b/test/pregate-import-graph-paths.test.ts index 500226105..4631a3662 100644 --- a/test/pregate-import-graph-paths.test.ts +++ b/test/pregate-import-graph-paths.test.ts @@ -30,6 +30,24 @@ describe('import graph: route-group / dynamic-segment paths and tsconfig path al expect(testsFor(g, 'app/[[...slug]]/page.tsx').tests).toEqual(['test/pages.test.ts']); }); + test('a relative literal a test reads through new URL or readFileSync maps from the file that names it', () => { + const repo = fixture(); + commit(repo, { + 'ship/sections/pr-body.md.tmpl': '## Summary\n', + 'ship/sections/other.md.tmpl': '## Other\n', + 'docs/guide.md': '# guide\n', + 'test/publication.test.ts': "import { readFileSync } from 'node:fs';\nreadFileSync(new URL('../ship/sections/pr-body.md.tmpl', import.meta.url), 'utf8');\nreadFileSync('./../docs/guide.md', 'utf8');\n", + 'test/nested/deep.test.ts': "import { readFileSync } from 'node:fs';\nreadFileSync('../../ship/sections/other.md.tmpl', 'utf8');\n", + }); + const g = buildGraph(repo.work); + expect(testsFor(g, 'ship/sections/pr-body.md.tmpl').tests).toEqual(['test/publication.test.ts']); + expect(testsFor(g, 'docs/guide.md').tests).toEqual(['test/publication.test.ts']); + expect(testsFor(g, 'ship/sections/other.md.tmpl').tests).toEqual(['test/nested/deep.test.ts']); + // A relative literal that climbs out of the repository names nothing. + commit(repo, { 'test/escape.test.ts': "import { readFileSync } from 'node:fs';\nreadFileSync('../../../ship/sections/pr-body.md.tmpl', 'utf8');\n" }); + expect(testsFor(buildGraph(repo.work), 'ship/sections/pr-body.md.tmpl').tests).toEqual(['test/publication.test.ts']); + }); + test('a relative import of a bracketed path already maps (control: the import scanner is not the bug)', () => { const repo = fixture(); commit(repo, { diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index 0fd131100..39dd75a6c 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -225,6 +225,14 @@ describe("pii.phone.e164: GitHub Actions run and job ids in their id positions", "https://github.com/garrytan/gstack/actions/runs/37235771700/job/111534615007", "GITHUB_RUN_ID=37235771700", '{"run_id": 37235771700}', + // An 11-digit id after a run word, as a READY block, a ship receipt or an approval record names one (GSTA-121's `screen GREEN`). + "citing screen 40477638211 GREEN at 5619199800a2", + "gate=40477638211", + "rerun passed 40477638211", + "run #40477638211", + "Screens: 40477638211", + "dispatched 40477638211 at the pin", + "cleared by run 19368712345", ], [ "call 37235771700 tomorrow", @@ -232,6 +240,12 @@ describe("pii.phone.e164: GitHub Actions run and job ids in their id positions", "https://example.com/contact/37235771700", '{"phone_id": 37235771700}', "run 4155550123 laps", + // A run word before a 10-digit national number, a bare 11-digit number and a generic word exempt nothing. + "gate 4155550123", + "the id is 40477638211", + "job 40477638211", + "workflow 40477638211", + "40477638211 failed", ], ); }); diff --git a/test/ubicloud-runner.test.ts b/test/ubicloud-runner.test.ts index 43085e731..8adacf278 100644 --- a/test/ubicloud-runner.test.ts +++ b/test/ubicloud-runner.test.ts @@ -311,6 +311,43 @@ test('pack ships a linked worktree with a self-contained .git on the checked-out } finally { rmSync(d, { recursive: true, force: true }); } }, 60_000); + test('pack snapshots a plain checkout without reading its live .git: remotes resolve, a detached HEAD gets a branch, and git running beside it cannot fail the tar', () => { + const d = mkdtempSync(join(tmpdir(), 'ubi-pack-live-')); + try { + const sh = (cmd: string, cwd: string) => { const r = Bun.spawnSync(['bash', '-c', cmd], { cwd, timeout: 60_000, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } }); expect(r.exitCode, r.stderr.toString()).toBe(0); return r.stdout.toString(); }; + sh('git init -q --bare origin.git && git clone -q origin.git src 2>/dev/null && cd src && echo a > a && git add a && git commit -qm one && git push -q origin HEAD:main && echo b > b && git add b && git commit -qm two && git push -q origin HEAD:main && git checkout -q --detach && echo c > c && echo s > staged && git add staged && echo i > inta && git add -N inta', d); + const src = join(d, 'src'); + const pack = `bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack .`; + // Twenty seconds of index.lock, worktree and ref churn in the checkout while pack runs three times. + const churn = Bun.spawn(['bash', '-c', 'end=$((SECONDS+20)); while [ $SECONDS -lt $end ]; do git update-index -q --refresh; git worktree add -q ../wt HEAD >/dev/null 2>&1; git worktree remove --force ../wt >/dev/null 2>&1; git update-ref refs/heads/churn HEAD; git update-ref -d refs/heads/churn; done'], { cwd: src, stdout: 'ignore', stderr: 'ignore' }); + try { + for (const i of [1, 2, 3]) sh(`${pack} > ../pack-${i}.tgz`, src); + } finally { churn.kill(); } + sh('mkdir vm && tar -xzf pack-3.tgz -C vm', d); + const vm = join(d, 'vm'); + expect(lstatSync(join(vm, '.git')).isDirectory()).toBe(true); + const head = sh('git rev-parse HEAD', src).trim(); + expect(sh('git rev-parse HEAD && git rev-parse --abbrev-ref HEAD && git rev-parse origin/main && git config --get remote.origin.url && git status --short', vm).split('\n').filter(Boolean)) + .toEqual([head, `detached-${head.slice(0, 12)}`, head, join(d, 'origin.git'), ' A inta', 'A staged', '?? c']); + // The staged blob and the intent-to-add entry's empty blob are behind no ref; the snapshot carries them, so the VM can read and write-tree them. + expect(sh('git fsck --no-progress 2>&1; git diff --cached --name-only', vm).trim()).toBe('staged'); + const wtree = (cwd: string) => sh(`bash ${JSON.stringify(join(ROOT, 'bin/gstack-wtree'))}`, cwd).trim(); + expect(wtree(vm)).toBe(wtree(src)); + // The snapshot carries no hook path or other local config from the source checkout. + expect(sh('git config --local --list', vm)).not.toContain('hooksPath'); + // A failed pack leaves no object copy behind in TMPDIR, and a branch's upstream rides along with its remote. + sh('mkdir ../tmpd && git checkout -q -b topic && git branch -q --set-upstream-to=origin/main topic', src); + const broken = Bun.spawnSync(['bash', '-c', `rm -f .git/index && TMPDIR=../tmpd ${pack} > ../pack-broken.tgz; echo rc=$?; ls ../tmpd`], { cwd: src, timeout: 60_000 }); + expect(broken.stderr.toString()).toContain('.git/index'); + expect(broken.stdout.toString().trim()).toBe('rc=1'); + // A gitlink in the index (a submodule commit this object store lacks) is not packed, and a valueless + // remote key (`[remote "origin"] mirror`) replays as true. + sh('git reset -q && git update-index --add --cacheinfo 160000,1111111111111111111111111111111111111111,sub && printf \'[remote "origin"]\\n\\tmirror\\n\' >> .git/config', src); + sh('mkdir vm2 && ' + pack.replace('pack .', 'pack src') + ' | tar -xzf - -C vm2', d); + expect(sh('git rev-parse --abbrev-ref @{u} && git config --bool --get remote.origin.mirror && git ls-files -s sub | cut -c1-6', join(d, 'vm2')).split('\n').filter(Boolean)).toEqual(['origin/main', 'true', '160000']); + } finally { rmSync(d, { recursive: true, force: true }); } + }, 90_000); + test('up records the VM in the scratch file before the create request is sent', () => { const runner = read('scripts/ubicloud/ubi-runner.sh'); const create = runner.indexOf('cli "${args[@]}" >/dev/null 2>"$dir/create.err"');