RESTAMP: 1.91.279.0 -> 1.91.280.0 (stamped; queue_mode=stamp-at-merge base=origin/main@b3e94a3f8d1c policy=base origin/main@b3e94a3f8d1c:.gstack/ship-policy.json)

STAMP_VERSION: 1.91.280.0
stage version: done (wrote 1.91.280.0)
stage release_tool: done (bun scripts/gen-agents-digest.ts)
stage changelog: done (Unreleased -> 1.91.280.0)
stage stamp_paths: skipped (no tentative mention newer than the fork version 1.91.277.0 and none of base 1.91.279.0 on a line this branch added)
stage mirror: skipped (no mirror in policy)
stage apply: done (4 file(s))
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=a755d3155d0921b05b9ca350710ef6724658376c
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack/.gstack/tmp/restamp-journal.json

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-27 17:23:30 +00:00
commit 550f0e8813
56 files changed
+1001 -169

No files matched your search

+46 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [Unreleased]
## [1.91.280.0] - 2026-10-27
**Three free-tier tests measured something other than what they assert, and the Ubicloud quota wait shaved its own budget.** The daily flaky pass (GSTA-128) traced the free tier's open timing reds to measurement defects, not product ones: `quota_preflight` in `scripts/ubicloud/ubi-runner.sh` pinned its deadline to the wall clock before the first usage call, so a slow CLI spawn on a loaded runner printed "waiting up to 2s more" where the test expects the full 3 s; the pregate-remote teardown test read the `.down` file as soon as it existed, before the stub's `echo` had written the line; and the design-lint linearity check timed one wall-clock run of the 8000-label lint against a 500 ms floor on a shared runner. Under eight busy loops on four cores the quota case went from 8 of 12 green to 12 of 12. No ms bound, threshold, trial count or retry changed.
@@ -15,6 +15,51 @@
### Gate integrity
- Four hunks listed, three read, none a relaxation. The teardown wait is stricter, the watchdog assertion is unchanged with a message added, and the linearity measure keeps its bound (measurement above).
## [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/<base>` and each branch's upstream resolve on the VM, a detached HEAD gets a `detached-<sha12>` 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 <id>`, 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-<sha12>`, 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.
### 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 <base>` (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: <base> moved to <sha7> and touches files in this PR: <files>; the train's merge onto <base> 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 `#<pr> BASE_OVERLAP: <base> moved to <sha7> and touches files in this PR: <files>` (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<k> merge clean onto <base>@<sha>` ahead of `QUEUE_TRAIN_WAITING`, so the waiting verdict is also a dry-run merge (#3485).
- **`gstack-pr-wait --exclude <check,...>`** (#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<N>` 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<N>` 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).
+1 -1
View File
@@ -1 +1 @@
1.91.277.0
1.91.280.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.277.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.280.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,
+1 -1
View File
@@ -33,7 +33,7 @@ const HELP = `gstack-pr-shots — before/after image of a change for a PR body (
[--title <text>] [--name <slug>] [--out <dir>] [--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()}`;
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* gstack-pr-wait — watch one pull request and print one line per real event.
*
* gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]
* gstack-pr-wait <pr> [--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
+1 -1
View File
@@ -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}`);
+4 -1
View File
@@ -10,7 +10,10 @@ it before training the PR.
<one to three images, highest-signal first; a collapsed text version under each>
## Why
<the cause and the user-visible effect, two or three sentences>
<the problem in the person's words first, then the cause and the user-visible effect, two or three sentences>
## Blast radius
<what else the change touches and the one fact that makes that safe, with the evidence-ladder rung reached>
## Approvals
<the table from gstack-approvals render>
+6 -3
View File
@@ -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.
+24 -3
View File
@@ -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
@@ -1236,7 +1238,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
@@ -1501,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/<name>.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`:
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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
@@ -564,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-<id>"`
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:
+26 -1
View File
@@ -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.
<a id="rulings-below-the-floor"></a>
### 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=<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=<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.
+32 -5
View File
@@ -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/<pr>-<head12>`
(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: <base> moved to <sha7> and touches files
in this PR: <files>; the train's merge onto <base> is clean` (or `conflicts
beyond the CHANGELOG and the stamp files (<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: #<n> run <id> planned
@@ -453,7 +464,14 @@ and waits (`--free-timeout <s>`, 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 `#<pr> BASE_OVERLAP: <base> moved to <sha7> and touches files in
this PR: <files>` 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<k> merge clean onto <base>@<sha>`, 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 <nonce>` 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
+11
View File
@@ -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/<base> --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:
+1 -1
View File
@@ -18,7 +18,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` 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 <token>` 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 <lane> -- <cmd>` 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 <pr> [--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 `<state root>/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 <pr> [--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 `<state root>/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 <n>`, `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. |
+44 -14
View File
@@ -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)`).
@@ -2708,11 +2713,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=<head>`).
**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=<head>`). A
predecessor GitHub calls merged that the local base does not carry yet
(`#<pr>=merged-not-in-base`) is kept ahead too, with `fix: git fetch origin
<base>`: fetch, then rerun, so the recompute, pinned to that local base, never
allocates the version the predecessor already took.
<a id="restamp-version-source"></a>
### `RESTAMP_VERSION_SOURCE`
@@ -4174,13 +4187,19 @@ with the new pin (`train build --prs <n>@<new sha>,…`).
### `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 <list without that PR>`. 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 <list without
that PR>`. 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.
<a id="queue-train-free-red"></a>
### `QUEUE_TRAIN_FREE_RED`
@@ -4209,7 +4228,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<k> merge clean onto <base>@<sha>` line above it says every
seated pin merges in slot order (a pin that does not is `QUEUE_TRAIN_CONFLICT`
instead), and a `#<pr> 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 +4711,21 @@ reason }` and carry the `Documentation exception: accepted by <owner> <date> via
## gstack-pr-wait
`gstack-pr-wait <pr> [--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 `#<pr>`; 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 <check,...>` 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: <names>`; a required check cannot be excluded
(`exclude refused for required check <name>`) and gates, and while a
required-check source cannot be read nothing is excluded (`exclude refused for
<names>: 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`
+5 -4
View File
@@ -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(/(?<![\d+])\d{11}(?!\d)/g)) {
const at = m.index!;
const listed = RUN_WORD_BEFORE.test(line.slice(0, at)) || (chainFrom >= 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;
}
+9 -5
View File
@@ -193,24 +193,28 @@ export function fetchChecks(env: GhEnv, repo: string, pr: number): Map<string, C
* Required check names for the base branch, from rulesets and classic branch
* protection. Reading them up front means a required check that has not
* started yet counts as pending instead of green by omission. A 404 means no
* protection of that kind, which is normal; any other failure is logged and
* that source contributes nothing.
* protection of that kind, which is normal; any other failure is logged, that
* source contributes nothing, and `known` is false: the set may be short, so a
* caller that would relax the gate on it (`--exclude`) must not.
*/
export function requiredChecks(env: GhEnv, repo: string, base: string): Set<string> {
export function requiredChecks(env: GhEnv, repo: string, base: string): { names: Set<string>; 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<string>();
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`. */
+52 -12
View File
@@ -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<string, string>; base_oid: string | null; merge_state: string | null }
export const PR_WAIT_USAGE = `gstack-pr-wait <pr> [--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 <pr> [--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
#<pr>), 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<string> | 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<string, Set<string>>();
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<string, Check>, 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<string> {
return new Set(this.requiredKnown ? this.excludes.filter(name => !this.required?.has(name)) : []);
}
private evaluateChecks(all: Map<string, Check>, head: string) {
const required = this.required ?? new Set<string>();
// 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();
+5 -1
View File
@@ -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
+14 -2
View File
@@ -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/<id>` or `/job/<id>` 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));
}
/**
+62 -7
View File
@@ -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, string>): 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<string, Set<string>>, 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<string> {
const found = new Set<string>();
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 = '<next
* minor>'` 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();
+13 -1
View File
@@ -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<Tr
if (kind.kind === 'invalid') return outRefused({ code: 'QUEUE_NOT_READY', message: `the Version Gate refuses ${head.slice(0, 12)}: ${kind.reason}` }, base.lines, `fix the head, push, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
const sel = await screenSelection(env, head, baseSha);
const lines = [...base.lines, ...sel.lines];
// gstack-pr-wait's base-overlap event, read from git at READY (handoff GSTA-119): the lane learns here that the base
// moved onto its files, and a merge the train would stop at is refused before the pin is pooled. Under --screen the
// scratch merge below reports the same conflict as QUEUE_SCREEN_STALE_REF. A comparison git could not make is printed as
// unknown and refuses nothing: the screen or the train makes the merge for real.
const overlap = baseOverlap(ctx.repoRoot, ctx.base, baseSha, head, env.policy.policy);
if (overlap.line) lines.push(overlap.line);
if (overlap.conflicts && o.screen !== 'screen') {
// The fix follows the crew template (captain/templates/CREW.md rule 14): a lane merges the base into a READY PR only on
// the controller's word, so the refusal is reported and the merge waits for that word.
return outRefused({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${o.pr} conflicts with ${ctx.base}@${baseSha.slice(0, 12)} beyond the CHANGELOG and the stamp files (${overlap.conflicts.join(', ')}); the train would stop at this PR` },
lines, `report the conflict to the controller; on its word merge ${ctx.base} into the pin (never rebase), resolve, push, run /ship, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
}
if (!loadReady(env.repo.keys, o.pr, head)) rebuildFromNotes(ctx.repoRoot, env.remote);
const prior = loadReady(env.repo.keys, o.pr, head);
const slug = repoSlug(ctx);
+46 -4
View File
@@ -18,6 +18,7 @@ import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
import type { ResultStatus } from './headless-artifacts';
import { baseOverlapLine } from './pr-wait';
import { versionAtRef, versionFiles } from './restamp';
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
import { loadBasePolicy, type QueueContext } from './ship-queue';
@@ -194,6 +195,39 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
return 'error' in out ? { conflicts: [`(${out.error})`] } : { tree: out.tree };
}
export interface BaseOverlap { touched: string[]; conflicts: string[] | null; unknown: boolean; line: string | null }
/**
* What gstack-pr-wait's base-overlap event tells a lane, read from git at
* READY and at train build (handoff GSTA-119): the files the base changed
* since this head forked that the head also changes and, when `verdict` is
* asked for, whether the train's own merge of the head onto the base is
* clean. A base that moved onto nothing the PR touches is one line of fact; a
* conflict is what QUEUE_TRAIN_CONFLICT would otherwise discover at the
* scratch merge. A git read that fails is `unknown`, said so, never "no
* overlap". Train build asks for no verdict: its own chain merges each pin
* onto its prefix, and a merge onto the bare base would contradict it for T2+.
*/
export function baseOverlap(repoRoot: string, base: string, baseSha: string, head: string, policy: ShipPolicy, verdict = true): BaseOverlap {
const fork = git(repoRoot, ['merge-base', baseSha, head]);
const none = { touched: [] as string[], conflicts: null, unknown: false };
if (fork.ok && fork.out === baseSha) return { ...none, line: null };
const theirs = fork.ok && fork.out ? git(repoRoot, ['diff', '--name-only', fork.out, baseSha]) : fork;
const ours = fork.ok && fork.out ? git(repoRoot, ['diff', '--name-only', fork.out, head]) : fork;
if (!fork.ok || !fork.out || !theirs.ok || !ours.ok) {
const why = [fork, theirs, ours].find(r => !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();
+14 -4
View File
@@ -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
* `<n> <name>` claim about the next key (#3513).
*/
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
const prose = body.replace(BLOCK_RE, '');
@@ -43,12 +45,20 @@ export function checkReceiptProse(receipt: Record<string, unknown>, 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<number>();
for (const leaf of byLeaf.keys()) for (const m of prose.matchAll(new RegExp(`(?<![\\w-])${pattern(leaf)}\\s*[=:]\\s*(?=\\d)`, 'gi'))) values.add(m.index + m[0].length);
const mismatches: ProseClaim[] = [];
let checked = 0;
for (const [leaf, counters] of byLeaf) {
const name = leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]');
const re = new RegExp(`(?:(?<![\\w-])${name}\\s*[=:]\\s*(\\d+)\\b|\\b(\\d+)\\s+${name}(?![\\w-]))`, 'gi');
for (const m of prose.matchAll(re)) {
const name = pattern(leaf);
const re = new RegExp(`(?:(?<![\\w-])${name}\\s*[=:]\\s*(\\d+)\\b|(?<![=:\\w.-])(\\d+)\\s+${name}(?![\\w-]))`, 'gi');
for (let m = re.exec(prose); m; m = re.exec(prose)) {
// A skipped value hands the scan back right after its digits, so the key that follows still makes its own pair claim.
if (m[2] !== undefined && values.has(m.index)) { re.lastIndex = m.index + m[2].length; continue; }
checked++;
const n = Number(m[1] ?? m[2]);
if (!counters.some(c => c.value === n)) mismatches.push({ field: counters[0]!.field, prose: n, receipt: counters[0]!.value });
+18 -7
View File
@@ -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<keyof ShipReceipt | 'docs'> = [
@@ -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(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before;
if (after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before) return true;
const was = before.split('\n');
const now = after.split('\n');
if (was.length !== now.length) return false;
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? []));
}
/**
+7 -4
View File
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.277",
"version": "1.91.280",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+11 -9
View File
@@ -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
+22 -4
View File
@@ -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) {
+1 -1
View File
@@ -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},
+1
View File
@@ -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)' },
+11 -9
View File
@@ -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
+10
View File
@@ -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/<base> 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.
+23 -5
View File
@@ -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<string, string> {
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 = '<next minor>'` 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');
+53 -19
View File
@@ -590,35 +590,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/<base> 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
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- AUTO-GENERATED from capy-pr.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## 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
+1 -1
View File
@@ -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
+11 -9
View File
@@ -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
+12
View File
@@ -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');
+33
View File
@@ -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<FreeTestsRun> = {}) => 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);
+1 -1
View File
@@ -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();
+31 -2
View File
@@ -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('');
});
});
+17
View File
@@ -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: '' });
+16 -12
View File
@@ -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<string, string[]> = {
'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<string, string[]> = {
"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<string, string[]> = {
"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<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]> = {
'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<string, string[]> = {
"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<string, string[]> = {
'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'],
+20 -1
View File
@@ -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);
+42 -2
View File
@@ -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);
+3 -3
View File
@@ -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');
+18
View File
@@ -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, {
+14
View File
@@ -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 <id> 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",
],
);
});
+11
View File
@@ -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);
});
+41
View File
@@ -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);
+22
View File
@@ -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 `<n> <name>` 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', () => {
+34
View File
@@ -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 = '<next minor>'` (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;
+51 -1
View File
@@ -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<typeof makeFixtureRepo>[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);
+37
View File
@@ -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"');