Merge origin/main into gsta-106/osv-bumps and stamp 1.91.241.0

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-26 10:31:35 +00:00
commit f01cb37638
124 files changed
+3203 -362

No files matched your search

+3
View File
@@ -26,6 +26,9 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
# The head's release_tool runs inside the gate (lib/version-gate-train.ts);
# no token may sit in the checkout it stages. The base fetch below is public.
persist-credentials: false
# The gate's own code comes from the PR's merge commit (github.sha): a PR
# that edits the gate runs its edit, and an open PR picks up a gate fix
+37 -4
View File
@@ -267,7 +267,7 @@ flow, and a layered prompt-injection defense — all behind a compiled CLI that
prints plain text to stdout. ~100-200ms per call. Zero context-token overhead.
It runs whenever the probe above does not print `READY`, and `$B` is a
legitimate tool in that context; on a Mac with Aside open the skills never
reach for it.
reach for it. Its HTTPS trust has a known gap: see [TLS trust](#tls-trust-the-bundled-chromiums-root-store).
---
@@ -311,7 +311,7 @@ $B connect # headed Chromium + Side Panel extension
9. [Side Panel + sidebar agent](#side-panel--sidebar-agent)
10. [Pair-agent — remote agents over an ngrok tunnel](#pair-agent)
11. [Authentication + tokens](#authentication)
12. [Prompt-injection security stack (L1–L6)](#security-stack)
12. [Prompt-injection security stack (L1–L6)](#security-stack) — including [TLS trust: the bundled Chromium's root store](#tls-trust-the-bundled-chromiums-root-store)
13. [Screenshots, PDFs, visual inspection](#screenshots-pdfs-visual)
14. [Local HTML — `goto file://` vs `load-html`](#local-html)
15. [Batch endpoint](#batch-endpoint)
@@ -562,7 +562,7 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table:
|---------|-------------|
| `cookie <name>=<value>` | Set cookie on current page domain |
| `cookie-import <json>` | Import cookies from JSON file |
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy selected browser cookies; picker by default, explicit scoped or all-domain import, optional storage reset and sign-in assertion |
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy selected browser cookies; picker by default, explicit scoped or all-domain import, optional storage reset and sign-in assertion. The imported session rides on the fallback browser's root store ([TLS trust](#tls-trust-the-bundled-chromiums-root-store)) |
| `header <name>:<value>` | Set custom request header (sensitive values auto-redacted) |
| `useragent <string>` | Set user agent (triggers context recreation, invalidates refs) |
@@ -673,7 +673,7 @@ pass `--force-restart` to replace it explicitly (see "Daemon lifecycle" above).
| Command | Description |
|---------|-------------|
| `handoff [reason]` | Open visible Chrome at current page for user takeover (CAPTCHA, MFA, complex auth) |
| `handoff [reason]` | Open visible Chrome at current page for user takeover (CAPTCHA, MFA, complex auth). What you type there trusts the bundled Chromium's root store ([TLS trust](#tls-trust-the-bundled-chromiums-root-store)) |
| `resume` | Re-snapshot after user takeover, return control to AI |
### Meta + chains
@@ -1296,6 +1296,39 @@ only writer when the chat-path agent was removed, so they reported stale or
empty data. The live defenses report through their own call sites. See
ARCHITECTURE.md § "Prompt injection defense" for the full threat model.
### TLS trust: the bundled Chromium's root store
The fallback engine checks HTTPS certificates against the root store built into
its bundled Playwright Chromium. Its default headless browser launches with
Playwright's `--disable-component-update`, so it never receives the CRLSet
revocation updates Chrome pushes to running browsers. Aside uses your own
browser's trust store and updates.
**Known gap: the compromised eMudhra intermediates
([#3338](https://github.com/garrytan/gstack/issues/3338)).** Chrome blocked
eight eMudhra intermediate CAs by CRLSet on 2026-10-17, the day their key
compromise was reported, and scheduled the removal of their root, emSign ECC
Root CA - G3, from the Chrome Root Store for 2026-10-27. The root store built
into gstack's pinned Playwright 1.62.1 (Chromium 151) still trusts that root, so
the headless browser accepts certificates those intermediates issued. That lasts
until gstack moves to the first Playwright release whose Chromium is
158.0.8115.0 or later, the first Chrome for Testing build measured to refuse
them. Until then, don't do signed-in work in this engine on a network you do not
trust, and prefer Aside for signed-in work where you have it.
To check a build, run it on a network that does not inspect TLS, because a
captive portal or an intercepting proxy can fail some or all sites with the
same error. Run `$B stop`, confirm in the default headless mode that a
known-good HTTPS site such as `$B goto https://example.com/` loads, then run
`$B goto https://dv-g3b-emudhra.we-have-a-problem.com/`. Only
`ERR_CERT_AUTHORITY_INVALID` there means the root store no longer trusts them.
Any other error proves nothing.
Caveats that depend on your environment (proxy variables, headed and
per-project profiles, `GSTACK_CHROMIUM_PATH` and `BROWSE_EXTENSIONS_DIR`
builds, and CRLSet timing in headed mode and `handoff`) are tracked in
[#3440](https://github.com/garrytan/gstack/issues/3440).
---
## Screenshots, PDFs, visual
+73 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [Unreleased]
## [1.91.241.0] - 2026-10-26
**The weekly OSV scan on main is green again, so the weekly release cut can run.** The 2026-10-26 scan ([run 40288353843](https://github.com/garrytan/gstack/actions/runs/40288353843), #3462) found 14 fixable vulnerabilities in six packages across both lockfiles, and the release workflow skips any main commit with a red check run. Every one is bumped to its fixed release; no application code changed, because gstack imports none of these packages directly (the MCP SDK and zod arrive through `@anthropic-ai/claude-agent-sdk`, body-parser through its express, fast-uri through ajv, ip-address through socks and express-rate-limit, dompurify through mermaid).
@@ -16,6 +16,78 @@
- `test/diagram-render-drift.test.ts` asserts the bundle's DOMPurify is the `package.json` override and no other version, and that it carries no Google API key; its path constants are repo-relative segment runs so the pre-gate's import graph selects it for the bundle, `BUILD_INFO.json` and the build script.
- `test/deps-smoke.test.ts` refuses `ip-address` below 10.7.4 and checks every resolved version of the six packages against its OSV-fixed floor in both lockfiles (all six fail on main's lockfiles). OSV-Scanner 2.3.8 with the workflow's own arguments (`--config=.osv-scanner.toml --include-git-root --recursive ./`) reports no issues on this tree; the katex ignore is the only filtered advisory.
## [1.91.240.0] - 2026-10-26
**Proactive skill suggestions default to the four core skills.** `proactive` now takes `core` (the default: only /office-hours, /review, /qa and /ship auto-invoke when a request matches), `all` (any matching skill, the old behavior) or `off`. `true` and `false` keep working as `all` and `off`. The sentiment research (GSTA-93) found auto-invocation of every skill is what makes gstack collide with other skill packs; the four core skills are where the praise is, so they keep the nudge. Garry, 2026-10-25: "do it".
### Changed
- **`bin/gstack-config`**: `proactive` defaults to `core`; `set proactive` accepts `core`, `all`, `off`, `true` and `false` in any case, stores the YAML yes/no family (`yes`/`on`/`1`, `no`/`none`/`0`/`disabled`/`never`) as `all`/`off`, and warns on anything else, storing `core`. The config header documents the three values.
- **`SKILL.md.tmpl`** (the router): three branches, `off`, `core` and `all`; under `core` only the four core routing rules auto-invoke, the browser step and the Failures branches name `/browse` or `/investigate` for the user to type, and a skill the user names is always invoked. The opt-out line writes `off`, and `all` and `core` have their own lines.
- **Every skill's preamble** (`scripts/resolvers/preamble/generate-upgrade-check.ts`): the steady-state rule names the four core skills under `core`, scopes the setting to unasked suggestions, carves out only the skills the user names or the running skill invokes itself ("Skills the user names or this one invokes run", so `/ship` dispatching `/document-release` is not a suggestion, while an in-skill offer to run another skill still counts as one under `off`), reads only `core`, `all` and `off` because `gstack-skill-start` now reports the legacy spellings as those, and is exactly as long as before, so the 89 size pins in `test/fixtures/skill-md-size-allowlist.json` are unchanged.
- **`bin/gstack-skill-start`**: the one-time prompt offers "Keep the core four (recommended)", "Suggest every matching skill" and "Turn it off", writing `core`, `all` or `off`. `scripts/question-registry.ts` lists the same three options.
- **`bin/gstack-skill-start`**: the `PROACTIVE:` STATUS fallback when `gstack-config` is missing is `core`, not `true` (found by /ship's coverage audit, red at the first head); a stored value gstack does not know reads as `core`, the legacy `true`/`false` and the yes/no family read as `all`/`off`, and a hand-edited value is unquoted, lowercased and stripped of a trailing comment first, so STATUS only ever says `core`, `all` or `off` and a typed opt-out never fails open (found by /ship's red team: `proactive: "off"` read as `core`); the first-run and first-loop tips name only core skills and stay quiet under `off`; the one-time offer to add `## Skill routing` to the project's CLAUDE.md is not made under `off` (found by /ship's red team: the routing rules are the auto-invocation the user turned off); a user who stored `off` before the one-time proactive prompt is not asked (the prompt marker is written before STATUS reports `PROACTIVE_PROMPTED`).
- **`office-hours/sections/design-and-handoff.md.tmpl`**: the next-review handoff takes the recommend-only path under `off` as well as the legacy `false` (found by /ship's review); under `core` it still offers the review, because a step inside an invoked skill is part of that skill, which the router now says in so many words.
### Added
- **`docs/proactive.md`**: the three values, who each is for, the legacy spellings, the commands, and the approved fallback (`off` if collisions continue). Linked from docs/skills.md.
### Tests
- `test/proactive-config.test.ts`: the default in get, list and defaults; the five accepted values stored verbatim, an upper-case `OFF` stored as `off`, the yes/no family stored as `all`/`off`; the warning and `core` fallback on an unknown value (negative control: `maybe`).
- `test/proactive-core-set.test.ts`: every surface (config header and default, router template and render, preamble, one-time prompt and registry, docs, office-hours gate, tips) names the same four core skills and the same three values, with negative controls (no non-core skill in the core block, `true` no longer "the default", no `set proactive false` left).
- `test/proactive-skill-start.test.ts` runs the script: STATUS is `core` when unset or unknown, the stored value otherwise, `all`/`off` for the legacy spellings and for a quoted, upper-case or commented hand edit; the missing-`gstack-config` fallback equals the config default (regression test: failed at the first head); the CLAUDE.md routing offer and the first-run and first-loop tips fire under `core` and not under `off` (marker unwritten).
- Known gap, follow-up #3446: under `core`, the `## Skill routing` section gstack offers for a project's CLAUDE.md, and the "Proactively suggest" sentences in non-core skill descriptions (full catalog mode), still route every skill; docs/proactive.md says so.
### Gate integrity
- No threshold, budget, timeout, trial count or case kind changed. Size pins unchanged (the preamble is byte-for-byte the same length).
## [1.91.239.0] - 2026-10-26
**BROWSER.md now says that gstack's own browser still trusts the compromised eMudhra intermediates, and how to check (#3338).** Chrome blocked eight eMudhra intermediate CAs by CRLSet on 2026-10-17, the day their key compromise was reported, and scheduled the removal of their root, emSign ECC Root CA - G3, from the Chrome Root Store for 2026-10-27. gstack's default headless browser gets neither: it launches with Playwright's `--disable-component-update`, so it receives no CRLSet updates, and the root store built into the bundled Chromium still trusts the root. On 2026-10-24, `$B goto https://dv-g3b-emudhra.we-have-a-problem.com/` loaded the test page on gstack's pinned Playwright 1.62.1 (Chromium 151.0.7922.34), and Chrome for Testing builds from 158.0.8115.0 fail it with `ERR_CERT_AUTHORITY_INVALID`, so the Playwright bump waits for a release that bundles one of those builds.
### Changed
- **`BROWSER.md`**: a new "TLS trust: the bundled Chromium's root store" section, at the end of the security stack, gives the warning, the plan (move to the first Playwright release whose Chromium is 158.0.8115.0 or later; until then, no signed-in work in this engine on an untrusted network) and the check: on a network that does not inspect TLS, after `$B stop` and once a known-good HTTPS site loads in headless mode, only `ERR_CERT_AUTHORITY_INVALID` on the test host means a build is fixed. Environment-dependent caveats (proxy variables, profiles, custom builds, headed CRLSet timing) are tracked in #3440. The table of contents, the fallback-engine introduction and the `cookie-import-browser` and `handoff` command rows link to the section. Aside is unaffected, because it is your own browser with its own updates.
### For contributors
- The edits above the cookie workflow excerpt change no line count, so the `setup-browser-cookies/SKILL.md workflow` judge input is byte-identical (prompt SHA-256 `c851e635…`, 12,805 bytes, before and after).
## [1.91.238.0] - 2026-10-26
**Five /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit.
### Fixed
- **`gstack-gate-diff <base>` in a clone with no local `<base>` branch.** Before, `gstack-gate-diff main` in a checkout that cloned only its feature branch printed `GATE_ERROR=no_base ref=main fix=git fetch origin main`, and that fix did not help: the tool only tried the local branch, and in a single-branch clone a bare `git fetch origin main` updates FETCH_HEAD alone. Now `lib/gate-diff/git.ts` resolves the base to its remote-tracking branch (`origin/main`, origin first, then the other remotes) when no local branch exists, says so once on stderr, and the merge-base is the same commit. When the remote-tracking branch is missing too, the fix line fetches into it explicitly: `git fetch origin +main:refs/remotes/origin/main` (with `--unshallow` on a shallow clone), which works in every clone shape. `test/gate-diff-cli.test.ts` adds a single-branch clone fixture with no local `main` and no `origin/main`: it asserts the error, runs the printed fix verbatim, and expects the next run to list the fixture's RH-1 hunk; two more clones cover a second remote that carries the branch when origin does not, and an upstream-only clone of a master-default repository whose default base (no argument) now comes from the same remote list as the fix line. The refspec is forced (`+main:refs/remotes/origin/main`) so the line also repairs a tracking ref that exists but shares no history with HEAD.
- **`gstack-post` no longer flags the repository's own release versions as public IPs.** A four-part version is byte-for-byte a dotted quad, so "(1.91.191.0)" in a PR body raised `pii.ip_public` (MEDIUM) and stopped an unattended docs audit that cannot confirm a MEDIUM finding. The engine's version exemption only reads the declaration right before the span (`"version": "`, `## [`), so prose that merely names a release got no help. Now `lib/gstack-post.ts` reads the root `VERSION` value and every version in a root `CHANGELOG.md` release heading (`## [1.2.3.4]`, `## v1.2.3.4`, through the same `parseChangelog` gstack-restamp uses) and passes those exact strings to the scan's allowlist. Verdict: this narrows one detector by the repo's own release set, and nothing else. The allowlist is exact-string and context-free, so a released quad is exempt wherever it appears in the text, `host: 1.91.191.0` included; the set is the repo's own releases and nothing wider. `8.8.8.8` and `203.0.113.7` in prose, a quad the repo never released (`1.128.1.0`), and the same text outside a repository or in a repo whose VERSION is three-part all still need confirmation; `test/gstack-post.test.ts` carries those controls beside the four benign bodies (`(1.91.191.0)`, `v1.91.226.0`, a bare `1.91.191.0`, `release 1.91.190.0`) against a synthetic CHANGELOG in which `1.91.192.0` is not a release. The same post path also stops reading a ship receipt's all-digit tree hash (`tree=704992822996`) and an 11-digit GitHub Actions run id in prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`) as `pii.phone.e164`: only the exact tokens in those positions are allowlisted, and `+14155551234`, a bare 11-digit number and a 10-digit number after `run` still need confirmation (GSTA-94 hit this on #3424).
- **`gstack-evidence` records the test count of gstack's shard runner and of the remote pregate lane.** `bun run test:free` / `test:ubicloud` run quiet shards that forward bun's `Ran N tests` line without its ` N pass` counts, so the bun parser read every full-suite run as `tests_ran: 0` and /ship's Step 16 saw a zero-run suite (GSTA-94 on #3424); the remote lane prints `PREGATE_REMOTE: tree=… tests=9660 failed=0` (a lane row under `lanes:` in the pregate table) and `requires-remote: … cleared by remote-gate tree=… tests=9660`, which no parser knew, so it was `tests_ran: unknown` and the ship receipt and the Review Readiness Dashboard never carried the number. Now a bun terminal line with no counts block above it is not a bun summary (bun always prints ` 0 pass` / ` 0 fail`, even for an all-skip run, which still grades ZERO-RUN), the shard runner's per-shard `[test:free] PASS — N tests` epilogues are summed for the last run in the output (a files-from run's `FILES-FROM RESULT: … tests=N` is the same sum; a run with a `FAIL —` or `FLAKY-PASS —` epilogue and no such total has lost a shard's count and is recorded as unknown, not a partial sum), and when the shard runner spoke its verdict stands for the whole tail (the tail is the stdout tail then the stderr tail, so a line's position cannot order bun's stderr counts block against the runner's stdout epilogues; a focused `bun test` run before the suite in the same command no longer records its count, or ZERO-RUN, for the 10k-test run that followed), and the remote lane's two lines are parsed as whole lines like every other runner pattern (a prefixed or quoted echo does not count; the later line wins when both appear); a paid-only selection's `tests=0` is ZERO-RUN, not a pass. Honest limit: the shard runner and the remote gate print only bun's terminal totals, which include skipped tests, so for those two the number is tests ran with skips included, not executed (the shard runner refuses a shard whose bun summary counts fewer files than planned, but a shard whose files all loaded and executed nothing still passes with that total). The exit code is still never changed by the parse. `test/evidence.test.ts` runs the wrapper over `test/fixtures/evidence/pregate-remote-failed.out`, the stdout of a real `gstack-pregate --stage tests --remote` run captured under gstack-evidence on 2026-10-26 (9658 tests, 1 failed, recorded as `tests_ran=9658` with `exit=1`, never FRESH), over a cleared lane's table (`tests_ran=9660`, FRESH), over the receipt line alone, over the two lines with differing counts in both orders, and over `test/fixtures/evidence/shard-runner-files-from.out`, the remote gate's own shard-runner stream for this branch (275 files, 16 shards, 10138 tests: the epilogue sum equals the `FILES-FROM RESULT` total, with and without that final line, and a forwarded `Ran N tests` line on stderr no longer zeroes it).
- **`gstack-restamp` refuses only the CHANGELOG duplicates the PR introduces.** `CHANGELOG_DUPLICATE_HEADING` compared the branch's headings with themselves, so a base branch whose released history already heads one version twice (gbrain's `master` carries two `## [0.38.0.0]` sections, and its own check requires every one of them to stay) could never be stamped. The check now takes the base branch's CHANGELOG, read at `origin/<base>` and at the commit the branch's own lines are counted from (its fork point, or the predecessor's head in a gate-ahead stamp), the larger count per heading winning: a heading the base carries N times may appear N times on the PR, and only a further copy is refused, with a message that says how many the base has. When a reading is unavailable (the PR adds the CHANGELOG itself) the stage detail and the refusal name it. A duplicate the PR adds, on a clean base or beside an inherited one, is still refused with nothing written. Tests: a fixture whose `main` heads `1.2.2.0` twice stamps and keeps both copies, and still does after `main` collapsed the copies behind the PR's back; a PR that adds a third copy, one that adds the first duplicate on a clean base, and one whose new CHANGELOG on a base without one heads a version twice, are refused. `docs/troubleshooting.md` says which duplicates count.
- **`gstack-restamp --next` under a train no longer asks the lane to edit the CHANGELOG.** Its `next:` line told every stamp to "write the CHANGELOG entry text"; a lane that obeyed on #3422 added a release summary under the new heading, the train's verify refused the head (the CHANGELOG must equal the base's plus the PR's own entry re-headed) and the queue froze. Under `queue_mode stamp-at-merge` the line now says to commit and push only the stamp diff with no edit of the lane's own; the claim-at-open wording is unchanged, since that bump still writes its entry at /ship's CHANGELOG step. Test: the stamp-at-merge fixture's output carries no instruction to edit the CHANGELOG by hand, and the claim-at-open fixture still does.
## [1.91.237.0] - 2026-10-26
**An unstamped PR can change a release output again (GSTA-96 on #3434).** Regenerating `agents-digest/gstack-AGENTS.md` after a generator change made the Version Gate classify the head as stamped (the digest is in `release_outputs`) and demand base + bump, while changing only `scripts/gen-agents-digest.ts` failed `test/agents-digest.test.ts`'s freshness check, so a digest rewording could not land before its slot.
### Fixed
- **Version Gate under a train policy** (`lib/version-gate-train.ts`, `scripts/compare-pr-version.ts`): a changed `release_outputs` file no longer counts as a stamp when it is byte for byte what the policy's `release_tool` generates at the head's merge base's `VERSION` (the head's own, since `VERSION` is unchanged on this path; the base tip may have stamped since, and CREW rule 14 says not to merge it); the gate runs the head's tool in a throwaway worktree with that `VERSION` (the changed outputs removed first, a bare environment with `GSTACK_RESTAMP_VERSION` and `GSTACK_RESTAMP_STAGING=1` and none of the gate's tokens, no `node_modules` link, a 120 s limit enforced with SIGKILL so a tool that ignores TERM cannot hold the gate open, output read from a file, first 4 KB, so a lingering child cannot either) and compares byte for byte. `VERSION` and `package.json` version changes still classify the head as stamped, as does an output that differs (hand-edited, generated at a bumped version, or never written by the tool) or a policy with no `release_tool`; a tool that exits non-zero, is killed or cannot be staged is an error with the reason, never a pass; the staging worktree is removed and pruned on every path. `version-gate.yml` checks the head out with `persist-credentials: false`, since the head's tool now runs inside the gate. The freshness test stays strict and a stamp commit still changes only the stamp files, since the slot's restamp regenerates the output. `docs/ship-policy.md` states the rule under "Lane: report ready".
### Tests
- `test/compare-pr-version.test.ts`: a generator change with its regenerated output at the merge base VERSION passes as unstamped (red without the fix), and stays unstamped after main stamps again; a tool whose background child outlives it does not hold the gate open, nor does one that traps TERM (`regeneratedAtBase` is exported for that test); a hand-edited output, an output generated at the bumped version, an output the tool never writes, a VERSION bump without the output, a failing release tool and a policy without `release_tool` all still refuse; the tool sees no `GH_TOKEN`; no worktree is left behind.
## [1.91.236.0] - 2026-10-26
**The merge queue now refuses a PR that never ran /ship.** Garry, 2026-10-25: "we should really be using the gstack ship skill before merging PRs." `gstack-ship-queue ready` printed `SHIP_RECEIPT: none in the PR body` as information and returned ok, so every PR in the pool was READY'd without a /ship run. The receipt /ship writes into the PR body is now a precondition at `ready`, `train build` and `verify --train`, with no flag around it.
### Changed
- **`gstack-ship-queue ready`** refuses with `QUEUE_RECEIPT_MISSING` when the PR body has no valid ```gstack-ship-receipt``` block and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR). The re-READY after a slot's stamp push accepts the receipt written for the pin when HEAD is the pin plus stamp files, or (from slot 2 on) the pin merged with main plus stamp files. The informational `SHIP_RECEIPT:` line still prints in the block, and it is one line whatever the receipt's strings contain (control characters are collapsed, so a crafted `base` cannot add a forged `MERGE_CONDITION:` line to `verify --train`'s output).
- **`gstack-ship-queue ready --screen` refuses a stale merge ref** with the new `QUEUE_SCREEN_STALE_REF` (GSTA-95 on #3431): GitHub stops recomputing `refs/pull/<n>/merge` while a PR conflicts with the base (a CHANGELOG-only conflict after a stamp counts), so a screen dispatched there planned 0 cases, reported success and `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's head parent with the PR head before dispatching (a missing merge ref carries the same code), never adopts a finished screen that planned no cases (a superseded run skips plan-slices and uploads no `paid-plan` artifact; a manifest with nothing planned counts the same), and `train build` prints `SCREEN: #<n> run <id> planned no cases …; counted as not-run` and seats that slot unscreened (a plan it cannot read keeps the slot screened and prints `… counted as screened, unverified`).
- **`gstack-ship-queue train build`** refuses a pin whose PR has no current receipt with the same codes, naming the PR, before any write, push or dispatch, `--dry-run` included. **`verify --train`** refuses the slot the same way before `MERGE_CONDITION`, and accepts a receipt written for the slot's pinned head when the current head is its stamp commit (the stamp changes only stamp files, or merges main into the pin from slot 2 on), since no /ship runs on a stamp.
- **`gstack-ship-receipt result`** (new) is /ship's terminal step: it binds the posted receipt to HEAD, writes `<state root>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md}` (a /ship record, not a `run` v1 manifest: `gstack-artifact validate` does not apply) and prints `GSTACK_RESULT: skill=ship status=complete run=<dir>`, `status=refused run=-` with the receipt code, or `status=incomplete run=-` when gh cannot read the PR, so a parent's merge check reads `ship-run.json` instead of parsing the PR body. The ship skill's new Step 22 runs it after the pr-body and handoff sections.
- **Unattended /ship may update an existing PR's body.** `gstack-skill-start`'s unattended block and `UNATTENDED_SHIP:` line, the ship skill's Step 18 and `docs/unattended.md` now say: when an open PR for the branch exists and HEAD is its pushed head, Step 18 takes the pr-body section's existing-PR path (body with the receipt first, and title); it never runs `pr-create` and never pushes a commit; anything else takes the handoff section. Before this the block said the receipt lived only in `.gstack/tmp/ship-receipt.md` while rule 22 and `ready` read it from the PR body.
- **`INSTALL_UNVERIFIED` with no verdict names the directory to install from.** An installer run from the skill's own folder records a verdict for no project, so /ship launched from the project found none and the fix said only "run the installer on this machine" (GSTA-85). Both twins (`lib/install-verdict.ts`, `bin/gstack-install-verdict.sh`) now say `run … scripts/install.sh --for ship from <project>: the verdict is keyed by the project directory the installer runs in`.
- **Docs:** `docs/ship-policy.md#ship-before-ready` (the lane runs /ship before every READY and after every push; the stamp keeps the pin's receipt; the receipt is a process gate, not an authenticity check), the three codes in `docs/troubleshooting.md`, `docs/unattended.md` (/ship now prints the result line), and the ship skill's pr-body section says what refuses without a receipt.
- **Gate edit (size budgets):** `test/helpers/carve-guards.ts` raises ship's `maxSkeletonBytes` 81,000 → 81,600 and `maxSizeRatio` 1.6690 → 1.6740 for Step 22 and the Step 18 routing, offset in part by tightening Steps 14, 16, 18 and 20 prose (measured skeleton 81,535, union 314,027; the 2026-10-14 tightening had left 2 bytes of headroom). No threshold, trial count or case kind changed.
### Tests
- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit; verify refuses `QUEUE_PROOF_MISSING` when gh cannot read the PR; `--screen` refuses a lagging or missing merge ref and never follows a 0-case screen. The train fixture's `refs/pull/<n>/merge` is now a real merge commit (`mergeRefCommit`), as GitHub keeps it. `test/gstack-ship-queue.test.ts`: `ready` at a stamp commit (slot 1, and slot 2 with main merged in) accepts the pin's receipt; a bare merge of main with no stamp on top, an empty commit over the merge, or an edit on the pin side under the slot-2 stamp, is `QUEUE_RECEIPT_STALE`. `gstack-ship-receipt result --pr <n>` refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head and prints `status=incomplete` when gh returns no head or the run record cannot be written; `ready --screen` names a failed `ls-remote` or fetch of the merge ref instead of blaming a conflict. Both install-verdict twins tell an unreadable verdict file from a missing one. `test/auq-error-fallback-hook.test.ts`: the unattended directive names the same `GSTACK_RESULT` writers as docs/unattended.md and `gstack-skill-start` (GSTA-91). `test/gstack-ship-receipt.test.ts`: the `bindReceipt` head-matching table (12-character floor, other PR, one-line `SHIP_RECEIPT:`), `result --body` refusals, `--head`, a non-git cwd and gh failures. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block.
## [1.91.235.0] - 2026-10-26
**An issue we intend to fix later now stays open under the `parked` label instead of being closed.** The Oct 11 triage closed 75 feature requests with a "not rejected" note, and GitHub showed them as closed with no fix. Those 75 are open again under the new `parked` label on garrytan/gstack ("Not fixed yet, not rejected: stays open; a comment says where it is tracked and what fixes it"), each with one comment saying where the work is tracked and what fixes it. The rule is written down: an issue closes only as fixed, duplicate or won't fix, each with its reason; a report that main already fixed closes as fixed, not won't fix; and a duplicate uses GitHub's own duplicate close reason.
+21 -10
View File
@@ -49,9 +49,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
@@ -170,17 +170,27 @@ output echoed:
~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type route --skill gstack --outcome ROUTE_OUTCOME --session-id "SESSION_ID" 2>/dev/null || true
```
If `PROACTIVE` is `false`: do not proactively invoke or suggest other gstack skills during
this session, including by asking whether to run one ("want me to run /X?"). Only run
skills the user explicitly invokes. This preference persists across sessions via
If `PROACTIVE` is `false` or `off`: do not proactively invoke or suggest other gstack
skills during this session, including by asking whether to run one ("want me to run /X?").
Only run skills the user explicitly invokes. This preference persists across sessions via
`gstack-config`.
If `PROACTIVE` is `true` (default): **invoke the Skill tool** when the user's request
matches a skill's purpose. Do NOT answer directly when a skill exists for the task.
If `PROACTIVE` is `core` (default): **invoke the Skill tool** only for the four core
skills when the user's request matches their rule below: `/office-hours`, `/review`,
`/qa` and `/ship`. For every other rule, answer directly and do not suggest the skill;
the user types it. The browser step above and the **Failures** branches below follow the
same rule: under `core`, name `/browse` or `/investigate` for the user to type instead of
invoking it. A skill the user names is always invoked, whatever this setting says,
and a step inside a skill that is already running (a handoff, a review gate, a subagent it
dispatches) is part of that skill, not a suggestion.
If `PROACTIVE` is `all` (or `true`): **invoke the Skill tool** when the user's request
matches any skill's purpose. Do NOT answer directly when a skill exists for the task.
Use the Skill tool to invoke it. The skill has specialized workflows, checklists, and
quality gates that produce better results than answering inline.
**Routing rules — when you see these patterns, INVOKE the skill via the Skill tool:**
**Routing rules — when you see these patterns, INVOKE the skill via the Skill tool** (under
`core`, only the four core rules auto-invoke):
- User describes a new idea, asks "is this worth building", brainstorms, pitches a concept → invoke `/office-hours`
- User asks to spec something out, file an issue, write up a ticket, "turn this into a GitHub issue", "backlog item" → invoke `/spec`
- User asks about strategy, scope, ambition, "think bigger", "what should we build" → invoke `/plan-ceo-review`
@@ -257,5 +267,6 @@ the skill's workflow, checklists and gates beat an ad-hoc answer. Answer directl
no skill matches, for a quick factual question or a small edit the user scoped
themselves, and when the user asks for a direct answer instead of a workflow.
If the user opts out of suggestions, run `gstack-config set proactive false`.
If they opt back in, run `gstack-config set proactive true`.
If the user opts out of suggestions, run `gstack-config set proactive off`. If they want
every skill suggested, run `gstack-config set proactive all`. `gstack-config set proactive
core` restores the default (the four core skills only).
+19 -8
View File
@@ -46,17 +46,27 @@ output echoed:
~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type route --skill gstack --outcome ROUTE_OUTCOME --session-id "SESSION_ID" 2>/dev/null || true
```
If `PROACTIVE` is `false`: do not proactively invoke or suggest other gstack skills during
this session, including by asking whether to run one ("want me to run /X?"). Only run
skills the user explicitly invokes. This preference persists across sessions via
If `PROACTIVE` is `false` or `off`: do not proactively invoke or suggest other gstack
skills during this session, including by asking whether to run one ("want me to run /X?").
Only run skills the user explicitly invokes. This preference persists across sessions via
`gstack-config`.
If `PROACTIVE` is `true` (default): **invoke the Skill tool** when the user's request
matches a skill's purpose. Do NOT answer directly when a skill exists for the task.
If `PROACTIVE` is `core` (default): **invoke the Skill tool** only for the four core
skills when the user's request matches their rule below: `/office-hours`, `/review`,
`/qa` and `/ship`. For every other rule, answer directly and do not suggest the skill;
the user types it. The browser step above and the **Failures** branches below follow the
same rule: under `core`, name `/browse` or `/investigate` for the user to type instead of
invoking it. A skill the user names is always invoked, whatever this setting says,
and a step inside a skill that is already running (a handoff, a review gate, a subagent it
dispatches) is part of that skill, not a suggestion.
If `PROACTIVE` is `all` (or `true`): **invoke the Skill tool** when the user's request
matches any skill's purpose. Do NOT answer directly when a skill exists for the task.
Use the Skill tool to invoke it. The skill has specialized workflows, checklists, and
quality gates that produce better results than answering inline.
**Routing rules — when you see these patterns, INVOKE the skill via the Skill tool:**
**Routing rules — when you see these patterns, INVOKE the skill via the Skill tool** (under
`core`, only the four core rules auto-invoke):
- User describes a new idea, asks "is this worth building", brainstorms, pitches a concept → invoke `/office-hours`
- User asks to spec something out, file an issue, write up a ticket, "turn this into a GitHub issue", "backlog item" → invoke `/spec`
- User asks about strategy, scope, ambition, "think bigger", "what should we build" → invoke `/plan-ceo-review`
@@ -112,5 +122,6 @@ the skill's workflow, checklists and gates beat an ad-hoc answer. Answer directl
no skill matches, for a quick factual question or a small edit the user scoped
themselves, and when the user asks for a direct answer instead of a workflow.
If the user opts out of suggestions, run `gstack-config set proactive false`.
If they opt back in, run `gstack-config set proactive true`.
If the user opts out of suggestions, run `gstack-config set proactive off`. If they want
every skill suggested, run `gstack-config set proactive all`. `gstack-config set proactive
core` restores the default (the four core skills only).
+1 -1
View File
@@ -1 +1 @@
1.91.235.0
1.91.241.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.235.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.241.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,
+2 -2
View File
@@ -65,9 +65,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -49,9 +49,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+2 -2
View File
@@ -50,9 +50,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+20 -3
View File
@@ -54,8 +54,11 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# Docs: https://github.com/garrytan/gstack
#
# ─── Behavior ────────────────────────────────────────────────────────
# proactive: true # Auto-invoke skills when your request matches one.
# # Set to false to only run skills you type explicitly.
# proactive: core # core = auto-invoke only /office-hours, /review, /qa and /ship
# # when your request matches one; every other skill waits
# # for you to type it. all = auto-invoke any matching skill.
# # off = only run skills you type explicitly.
# # (true and false still work: true means all, false means off.)
#
# routing_declined: false # Set to true to skip the CLAUDE.md routing injection
# # prompt. Set back to false to be asked again.
@@ -282,7 +285,7 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# AND the env override is not set. Keep in sync with the CONFIG_HEADER comments.
lookup_default() {
case "$1" in
proactive) echo "true" ;;
proactive) echo "core" ;; # core | all | off (true = all, false = off); core suggests only /office-hours, /review, /qa, /ship
routing_declined) echo "false" ;;
telemetry) echo "off" ;;
auto_upgrade) echo "false" ;;
@@ -746,6 +749,20 @@ $_gstack_legacy_root"
echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2
VALUE="default"
fi
if [ "$KEY" = "proactive" ]; then
# Case-insensitive, and the YAML yes/no family maps like gstack-skill-start
# reads a hand-edited value: an opt-out must never fail open to core.
VALUE=$(printf '%s' "$VALUE" | tr '[:upper:]' '[:lower:]')
case "$VALUE" in
yes|y|on|1) VALUE="all" ;;
no|n|none|0|disabled|never) VALUE="off" ;;
core|all|off|true|false) ;;
*)
echo "Warning: proactive '$VALUE' not recognized. Valid values: core, all, off (true = all, false = off). Using core." >&2
VALUE="core"
;;
esac
fi
if [ "$KEY" = "plan_mode_preamble" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Warning: plan_mode_preamble '$VALUE' not recognized. Valid values: on, off. Using on." >&2
VALUE="on"
+3
View File
@@ -277,6 +277,9 @@ gstack_result_anchor() {
QUEUE_TRAIN_REBUILD_REFUSED) printf '%s' 'queue-train-rebuild-refused' ;;
QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;;
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
READY_SCREEN_CHOICE_REQUIRED) printf '%s' 'ready-screen-choice-required' ;;
PREGATE_TIMING_ASSERTION) printf '%s' 'pregate-timing-assertion' ;;
APPROVAL_CLASSES_INVALID) printf '%s' 'approval-classes-invalid' ;;
+99 -7
View File
@@ -19,9 +19,24 @@
* `tests_ran` (zero-run green, W3 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md)
* is parsed from a bounded tail of the streamed output for the supported
* runners only: bun (shard-engine's summary parser where importable; executed =
* pass + fail, so an all-skip run is 0), jest, vitest, pytest, rspec, and Go's
* zero-only case (every package `[no test files]`). The last summary per
* runner wins; anything else is "unknown". Three states, never merged:
* pass + fail, so an all-skip run is 0), jest, vitest, pytest, rspec, Go's
* zero-only case (every package `[no test files]`), gstack's own shard runner
* (`bun run test:free` / `test:ubicloud`: the sum of its per-shard
* `[test:free] PASS — N tests` epilogues, or a files-from run's
* `FILES-FROM RESULT: … tests=N`), and a `gstack-pregate --remote` lane (the
* remote gate's `PREGATE_REMOTE: … tests=N` row, or the cleared obligation's
* `cleared by remote-gate … tests=N` receipt). The shard runner and the remote
* gate print only bun's terminal totals, which count skipped tests, so for
* those two the number is "ran, skips included" rather than executed (the shard
* runner refuses a shard whose bun summary counts fewer files than planned, but
* a shard whose files all loaded and executed nothing still passes with that
* total). When the shard runner spoke, its verdict stands for the whole tail:
* the tail is the stdout tail then the stderr tail, so a line's position says
* nothing about when it was printed across streams, and bun's own counts (on
* stderr) would otherwise outrank a 10k-test run whichever of the two ran
* first. Any summary when it did not speak follows the last-summary-wins rule;
* anything else is "unknown".
* Three states, never merged:
* a count, 0 (ZERO-RUN, graded like STALE by `check`) and unknown (non-blocking).
*
* TRANSPARENCY INVARIANT (load-bearing): the child's exit code is ALWAYS the
@@ -76,7 +91,7 @@ interface EvidenceRecord {
wtree?: string;
log_path?: string;
redacted?: boolean;
/** Executed tests per the runner's own summary; "unknown" when no supported summary was seen. */
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
tests_ran?: number | "unknown";
}
@@ -108,7 +123,17 @@ const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const strip = (l: string) => l.replace(ANSI, "").replace(/\r$/, "");
const count = (text: string, word: string): number => Number(new RegExp(`(\\d+) ${word}\\b`).exec(text)?.[1] ?? 0);
/** Bun: the last "Ran N tests across M files" block; executed = its " N pass" + " N fail" lines. */
/**
* Bun: the last "Ran N tests across M files" block; executed = its " N pass" +
* " N fail" lines. Bun always prints that counts block (" 0 pass" / " 0 fail"
* even for an all-skip or empty run), so a terminal line with no " N pass"
* above it is a line gstack's quiet shard runner forwarded (it forwards the
* terminal line without the " N pass" line), not a bun summary:
* it is skipped and the shard runner's own epilogue counts. A terminal line
* that itself says "Ran 0 tests" is ZERO-RUN even without counts, unless the
* shard runner spoke: a forwarded "Ran 0 tests across 1 file" is one shard's
* empty file, not the run.
*/
async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number } | null> {
let isTerminal = (l: string) => /^Ran \d+ tests? across \d+ files?\. \[/.test(l);
try {
@@ -119,7 +144,7 @@ async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number }
}
for (let i = lines.length - 1; i >= 0; i--) {
if (!isTerminal(lines[i])) continue;
let pass = 0;
let pass: number | null = null;
let fail = 0;
for (let j = i - 1; j >= 0 && j >= i - 12; j--) {
if (isTerminal(lines[j])) break;
@@ -128,11 +153,64 @@ async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number }
if (p) pass = Number(p[1]);
if (f) fail = Number(f[1]);
}
if (pass === null) {
if (/^Ran 0 tests? across/.test(lines[i])) return { at: i, ran: 0 };
continue;
}
return { at: i, ran: pass + fail };
}
return null;
}
/**
* gstack's shard runner (scripts/test-free-shards.ts; `bun run test:free` /
* `test:ubicloud`). Its quiet shards forward bun's terminal line without the
* counts the bun branch reads, so each shard's epilogue "[test:free] PASS — N
* tests, M files, Ts." carries the shard's "Ran N" total (skips included) and
* the run's total is their sum; a files-from run ends with "[test:free]
* FILES-FROM RESULT: files=F tests=N failed=K exit=E", the same sum. Only the
* last run in the tail counts (its "[test:free] full suite:" / "files-from" /
* "QUICK SUBSET" start line); the other parsers decide only when no runner
* line at all is in the tail. A run with a "FAIL —" or "FLAKY-PASS —" epilogue and no FILES-FROM
* total has lost a shard's count (a FAIL epilogue carries none; the flaky retry
* re-runs only the failing files), so it is unknown rather than a partial sum.
* The runner's verdict covers the whole tail once it spoke: the tail is
* stdout-then-stderr, bun's counts block is on stderr and the runner's
* epilogues are on stdout, so "after" cannot be told from "before" across the
* two streams and a bun block printed before the suite (a focused file run
* first) would otherwise record its count, or ZERO-RUN, for a 10k-test run.
* The quiet shards forward only count-less terminal lines (a shard's "Ran 0
* tests across 1 file" is one empty file, not the run); under `--verbose` they
* forward whole blocks, which the runner's sum outranks. The epilogue is
* matched as the runner's whole line (the log path may contain spaces); like
* every runner summary here it is the child's own stdout, so a test that
* forges one is trusted the same way a forged bun summary is (see W3's trust
* model), and the exit is never derived from it. Returns undefined when no
* runner line is in the tail. A verdict line (PASS/FAIL/FLAKY-PASS epilogue or
* FILES-FROM RESULT) without its start line means the start line was evicted
* from the tail (a chatty default-mode or --verbose run): the runner spoke,
* the sum is incomplete, so the result is unknown rather than a forwarded
* shard block's count. A full-suite start line names its shard count
* ("across N shard processes", plus one exclusive host-state shard when it
* says so); fewer PASS epilogues than that (killed mid-shards, `--ci-verify`)
* is unknown, never a partial sum. An empty shard prints "shard i/N: 0 files,
* 0s, pass" instead of a PASS epilogue and counts as zero.
*/
function shardRunnerTestsRan(lines: string[]): number | "unknown" | undefined {
const verdictLine = /^\s*\[test:free\] (?:PASS — |FAIL — |FLAKY-PASS — |FILES-FROM RESULT: )/;
const start = lines.findLastIndex((l) => /^\s*\[test:free\] (?:full suite:|files-from |QUICK SUBSET:)/.test(l));
if (start < 0) return lines.some((l) => verdictLine.test(l)) ? "unknown" : undefined;
const run = lines.slice(start);
const result = /^\s*\[test:free\] FILES-FROM RESULT: files=\d+ tests=(\d+) failed=\d+ exit=\d+\s*$/;
const total = run.findLast((l) => result.test(l));
if (total) return Number(result.exec(total)![1]);
if (run.some((l) => /^\s*\[test:free\] (?:FAIL|FLAKY-PASS) — /.test(l) || /^\s*\[test:free\] PASS — \? tests/.test(l))) return "unknown";
const counts = run.map((l) => /^\s*\[test:free\] PASS — (\d+) tests?, \d+ files?, \d+s\. Full log: \S.*$/.exec(l)?.[1] ?? (/^\s*\[test:free\] shard \d+\/\d+: 0 files, \d+s, pass\s*$/.test(l) ? "0" : undefined)).filter((n): n is string => n !== undefined);
const planned = /across (\d+) shard processes/.exec(run[0]!);
const expected = planned ? Number(planned[1]) + (/exclusive host-state file/.test(run[0]!) ? 1 : 0) : 1;
return counts.length >= expected ? counts.reduce((sum, n) => sum + Number(n), 0) : "unknown";
}
function otherRunnersTestsRan(lines: string[]): { at: number; ran: number } | null {
const hits: { at: number; ran: number }[] = [];
const last = (re: RegExp) => {
@@ -162,14 +240,28 @@ function otherRunnersTestsRan(lines: string[]): { at: number; ran: number } | nu
const goNoTests = lines.filter((l) => /^\?\s+\S+\s+\[no test files\]$/.test(l));
const goRan = lines.some((l) => /^(?:ok|FAIL|---)\s/.test(l));
if (goNoTests.length > 0 && !goRan) hits.push({ at: last(/\[no test files\]$/), ran: 0 });
// gstack-pregate --remote: the remote gate's own "PREGATE_REMOTE: tree=… tests=N failed=M"
// line (tests = the shard runner's FILES-FROM RESULT sum, relayed by scripts/ubicloud/pregate-remote.sh;
// lib/pregate/checks/lanes.ts parseRemoteReport is the canonical parser), rendered as a lane
// row under "lanes:" in the pregate table, and the obligation's "requires-remote: … cleared by
// remote-gate tree=… tests=N" receipt line (lib/pregate/index.ts). Whole lines, like every
// other runner pattern here, so a prefixed or quoted echo does not count (the row is indented
// under "lanes:" by design, so indentation is accepted). When both appear they carry the same
// count; the later one wins.
for (const re of [/^\s*(?:lanes:\s+)?PREGATE_REMOTE: tree=\S+ tests=(\d+) failed=\d+(?: spent=\S+)?\s*$/, /^\s*requires-remote: .* cleared by remote-gate tree=\S+ tests=(\d+)\b/]) {
i = last(re);
if (i >= 0) hits.push({ at: i, ran: Number(re.exec(lines[i])![1]) });
}
if (hits.length === 0) return null;
return hits.reduce((a, b) => (b.at > a.at ? b : a));
}
/** The executed-test count from the output tail, or "unknown". Never throws into the caller's exit path. */
/** The runner-reported test count from the output tail (see the header for which runners report executed vs ran), 0, or "unknown". Never throws into the caller's exit path. */
async function parseTestsRan(tail: string): Promise<number | "unknown"> {
if (process.env.GSTACK_EVIDENCE_FAULT === "tests_ran") throw new Error("injected tests_ran parser fault");
const lines = tail.split("\n").map(strip);
const shard = shardRunnerTestsRan(lines);
if (shard !== undefined) return shard;
const candidates = [await bunTestsRan(lines), otherRunnersTestsRan(lines)].filter((c): c is { at: number; ran: number } => c !== null);
if (candidates.length === 0) return "unknown";
return candidates.reduce((a, b) => (b.at > a.at ? b : a)).ran;
+5 -4
View File
@@ -29,7 +29,7 @@ import * as os from 'node:os';
import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index';
import {
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolvesToCommit, treePaths, untrackedAsAdded,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
import { resolveStateRoot } from '../lib/state-root';
@@ -142,13 +142,14 @@ function main(argv: string[]): number {
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
} else {
const baseRef = parsed.base ?? defaultBase(root);
const baseSha = resolvesToCommit(baseRef, root);
const head = headSha(root);
if (!baseSha || !head) {
const base = resolveBase(baseRef, root, head ?? undefined);
if (!base || !head) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
const mb = mergeBase(baseSha, head, root);
if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
+5 -1
View File
@@ -133,11 +133,15 @@ gstack_install_admission() {
fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints"
if [ ! -f "$file" ]; then
detail="no install verdict at $file for $src"
fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints"
else
trailer=$(gstack_install_verdict_field "$file" trailer)
v_head=$(gstack_install_verdict_field "$file" head)
head=$(git -C "$src" rev-parse HEAD 2>/dev/null)
if [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
v_schema=$(gstack_install_verdict_field "$file" schema_version)
if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then
detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer"
elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
detail="the install verdict says: $trailer"
elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then
detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src"
+1
View File
@@ -97,6 +97,7 @@ function report(r: RestampResult, json: boolean): number {
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`);
if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out <file>\` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`);
else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
else if (r.status === 'stamped' || r.status === 'noop') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; record the release decision, then write the CHANGELOG entry text only (the heading is already ${r.newVersion}); merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
}
process.stdout.write(formatResult('restamp', status, r.journal ?? JOURNAL_REL) + '\n');
+3 -1
View File
@@ -4,6 +4,7 @@
* step (field list 2, W1; docs/ship-policy.md#run-a-shared-merge-queue).
*
* gstack-ship-queue ready --pr <n> [--pregate <pregate.json>] [--gate-exit <rc> --gate-log <file>]
* (refused with QUEUE_RECEIPT_MISSING|STALE unless the PR body carries /ship's receipt for HEAD)
* gstack-ship-queue stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>
* [--expect-order <pr,...>] [--allow-repo-commands] [--gate-exit <rc> --gate-log <file>]
* gstack-ship-queue verify --pr <n> (--gated <sha> | --record <gate-ahead.json>) [--receipt <sha256>]
@@ -33,7 +34,8 @@ import { attemptFile, listAttempts, prepareGateAhead, recordGateAhead, type Queu
const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (docs/ship-policy.md#run-a-shared-merge-queue)
ready --pr <n> [--pregate <file>] [--gate-exit <rc> --gate-log <file>]
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state,
SHIP_RECEIPT (refused unless the PR body carries /ship's receipt for that head)
stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha> [--expect-order <pr,...>] [--allow-repo-commands]
[--gate-exit <rc> --gate-log <file>]
restamp, commit and prove: QUEUE_STAMP: <pr> <X> head= gated= tree= receipt=
+80 -2
View File
@@ -10,11 +10,16 @@
* [--out <file>] [--body <pr-body.md>] [--json]
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--json]
* gstack-ship-receipt read --body <file> [--json]
* gstack-ship-receipt result (--pr <n> [--repo <owner/repo>] | --body <file>) [--head <sha>] [--out <dir>] [--json]
* gstack-ship-receipt census --repo <owner/repo> --since <tag|YYYY-MM-DD> [--cohort <file>] [--limit <n>] [--json]
*
* `write` validates against `gstack-artifact schema ship-receipt`, prints the
* fenced block (and SHIP_RECEIPT: <identifiers> on stderr), writes it to --out,
* and with --body makes it the first section of that PR body file.
* `result` is /ship's terminal step: it binds the receipt to HEAD, writes the
* run record under <state>/ship/runs/ (ship-run.json: a /ship record, not a
* `run` v1 manifest, so `gstack-artifact validate` does not apply to it) and
* prints GSTACK_RESULT: skill=ship. A gh failure prints status=incomplete.
* Query commands: no GSTACK_RESULT line. Logic: lib/ship-receipt.ts.
*/
import { spawnSync } from 'node:child_process';
@@ -27,15 +32,20 @@ import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { resolveBaseBranch } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
import {
computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock,
validateReceipt, type CensusPr,
bindReceipt, computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus,
renderReceiptBlock, validateReceipt, type CensusPr,
} from '../lib/ship-receipt';
import { resolveStateRoot } from '../lib/state-root';
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
write [flags] compose, validate and print the block; --body <file> puts it first in that PR body
read --pr <n> [--repo <r>] parse the block from the PR body through gh (RECEIPT_MISSING / RECEIPT_INVALID)
read --body <file> parse the block from a file
result --pr <n> [--repo <r>] | --body <file> [--head <sha>] [--out <dir>]
/ship's terminal line: binds the receipt to HEAD (QUEUE_RECEIPT_MISSING / QUEUE_RECEIPT_STALE),
writes <state>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md} and prints
GSTACK_RESULT: skill=ship status=complete run=<dir> (refused: status=refused run=-)
census --repo <r> --since <tag|date> [--cohort <file>]
the P7 adoption number over merged PRs (deduplicated by run and thread)
@@ -182,6 +192,73 @@ function cmdRead(rest: string[], json: boolean): number {
return EXIT.ok;
}
/**
* /ship's terminal line (docs/unattended.md#the-terminal-line). The receipt in
* the PR body (or the handoff file) must bind to HEAD: then the run record
* goes under <state>/ship/runs/ with the head, the PR and the receipt, and
* the last stdout line is GSTACK_RESULT: skill=ship status=complete run=<dir>.
* A missing or stale receipt is status=refused run=- (exit 3), so a parent
* never has to parse the PR body to know whether /ship finished at this head.
*/
function cmdResult(rest: string[], json: boolean): number {
const bodyFile = flag(rest, '--body');
const prFlag = flag(rest, '--pr');
if (prFlag !== undefined && !/^[1-9]\d*$/.test(prFlag)) { process.stderr.write(`gstack-ship-receipt result: --pr must be a PR number, got ${JSON.stringify(prFlag.slice(0, 40))}\n`); return EXIT.usage; }
const pr = prFlag;
if (prFlag !== undefined && flag(rest, '--body') !== undefined) { process.stderr.write('gstack-ship-receipt result: pass --pr <n> (the PR body, checked against its pushed head) or --body <file> (the handoff receipt), not both\n'); return EXIT.usage; }
const repoRoot = git(['rev-parse', '--show-toplevel']);
const head = (flag(rest, '--head') ?? (repoRoot ? git(['rev-parse', 'HEAD'], repoRoot) : undefined))?.toLowerCase();
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE', message: string) => {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt result', code, message) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : 'GSTACK_RESULT: skill=ship status=refused run=-\n');
return EXIT.refused;
};
if (!head) { process.stderr.write('gstack-ship-receipt result: --head <sha> is required outside a git repository\n'); return EXIT.usage; }
if (!/^[0-9a-f]{40,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a full hex commit id, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; }
const incomplete = (why: string) => {
process.stderr.write(`gstack-ship-receipt result: ${why}\n`);
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
return EXIT.fail;
};
let body: string;
let source: string;
if (bodyFile) {
if (!fs.existsSync(bodyFile)) return refused('QUEUE_RECEIPT_MISSING', `${bodyFile} does not exist; /ship did not write its receipt`);
try { body = fs.readFileSync(bodyFile, 'utf8'); } catch (e) { return incomplete(`cannot read ${bodyFile}: ${e instanceof Error ? e.message : String(e)}`); }
source = path.resolve(bodyFile);
} else if (pr) {
const repo = flag(rest, '--repo');
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body,headRefOid']);
if (!r.ok) return incomplete(`gh pr view failed: ${r.err.trim()}`);
let pushed: unknown;
try { const v = JSON.parse(r.out); body = v.body ?? ''; pushed = v.headRefOid; } catch { return incomplete('gh returned no JSON body'); }
if (typeof pushed !== 'string' || !/^[0-9a-f]{12,64}$/i.test(pushed)) return incomplete(`gh pr view returned no headRefOid for PR #${pr}`);
if (!pushed.toLowerCase().startsWith(head.toLowerCase())) {
return refused('QUEUE_RECEIPT_STALE', `HEAD ${head.slice(0, 12)} is not PR #${pr}'s pushed head ${pushed.slice(0, 12)}; push it, then rerun gstack-ship-receipt result`);
}
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
} else { process.stderr.write(`gstack-ship-receipt result: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
const bound = bindReceipt(body, pr, [head]);
if (!bound.ok) return refused(bound.code, bound.message);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const out = flag(rest, '--out');
const dir = out ? path.resolve(out) : path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${head.slice(0, 12)}`);
const receiptPath = path.join(dir, 'ship-receipt.md');
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(receiptPath, renderReceiptBlock(bound.receipt));
} catch (e) { return incomplete(`cannot write the run record under ${dir}: ${e instanceof Error ? e.message : String(e)}`); }
const run = {
schema_version: 1, skill: 'ship', status: 'complete', head, pr: bound.receipt.pr ?? (pr ? Number(pr) : null), base: bound.receipt.base, version: bound.receipt.version ?? null,
queue_mode: bound.receipt.queue_mode ?? null, session_kind: bound.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
receipt: receiptPath, receipt_source: source, branch: repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot) ?? null : null, created_at: new Date().toISOString(),
};
try { fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n'); } catch (e) { return incomplete(`cannot write ${path.join(dir, 'ship-run.json')}: ${e instanceof Error ? e.message : String(e)}`); }
process.stderr.write(bound.line + '\n');
process.stdout.write(json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=complete run=${dir}\n`);
return EXIT.ok;
}
function sinceDate(repo: string, since: string): string | undefined {
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since.slice(0, 10);
const local = git(['log', '-1', '--format=%cI', since]);
@@ -214,6 +291,7 @@ function main(argv: string[]): number {
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
if (cmd === 'write') return cmdWrite(rest, json);
if (cmd === 'read') return cmdRead(rest, json);
if (cmd === 'result') return cmdResult(rest, json);
if (cmd === 'census') return cmdCensus(rest, json);
process.stderr.write(`gstack-ship-receipt: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
+40 -16
View File
@@ -132,8 +132,27 @@ find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true
}
_SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
_PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "true")
_PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "core")
# A hand-edited config can hold anything: drop quotes and a trailing comment,
# lowercase, read the legacy true/false and the YAML yes/no family as all/off
# (an opt-out must never fail open), and anything else as core, so STATUS only
# ever says core | all | off.
_PROACTIVE=$(printf '%s' "$_PROACTIVE" | sed -e 's/[[:space:]]*#.*$//' -e "s/^[[:space:]]*[\"']*//" -e "s/[\"']*[[:space:]]*$//" | tr '[:upper:]' '[:lower:]')
case "$_PROACTIVE" in
true|yes|y|on|1) _PROACTIVE="all" ;;
false|no|n|none|0|disabled|never) _PROACTIVE="off" ;;
core|all|off) ;;
*) _PROACTIVE="core" ;;
esac
# off silences every unasked surface below: the tips, the one-time prompt and
# the CLAUDE.md routing offer. A stored off also answers the one-time prompt,
# so the marker is written here, before STATUS reports it.
case "$_PROACTIVE" in off) _PROACTIVE_OFF="yes" ;; *) _PROACTIVE_OFF="no" ;; esac
_PROACTIVE_PROMPTED=$([ -f "$_GH/.proactive-prompted" ] && echo "yes" || echo "no")
if [ "$_PROACTIVE_PROMPTED" = "no" ] && [ "$_PROACTIVE_OFF" = "yes" ]; then
touch "$_GH/.proactive-prompted" 2>/dev/null || true
_PROACTIVE_PROMPTED="yes"
fi
# Branch name is embedded in concatenated JSON below; strip anything outside
# the gstack-slug BRANCH alphabet so a hostile ref name can't forge fields.
_BRANCH=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-')
@@ -322,7 +341,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
# GSTACK_SESSION_KIND=spawned override must light this up too.
[ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true
[ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md" || true
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated (pr-body section, receipt first), else Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md; end with gstack-ship-receipt result" || true
# ---------------------------------------------------------------------------
# Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim
@@ -544,15 +563,15 @@ Always run: \`touch "$_GH/.telemetry-prompted"\`.
EOI
fi
# Proactive-suggestions opt-in (interactive).
# Proactive-suggestions opt-in (interactive; a stored off answered it above).
if [ "$_PROACTIVE_PROMPTED" = "no" ] && [ "$_TEL_PROMPTED" = "yes" ]; then
_emit_block proactive-prompt <<EOI
Ask once:
> Let gstack proactively suggest skills, like /qa for "does this work?" or /investigate for bugs?
> Let gstack proactively suggest skills? By default it suggests only the four core ones: /office-hours for a new idea, /review for a diff, /qa for "does this work?", /ship to land it.
Options: A) Keep it on (recommended) B) Turn it off — I'll type /commands myself
If A: \`$_BIN/gstack-config set proactive true\`. If B: \`$_BIN/gstack-config set proactive false\`.
Options: A) Keep the core four (recommended) B) Suggest every matching skill C) Turn it off — I'll type /commands myself
If A: \`$_BIN/gstack-config set proactive core\`. If B: \`$_BIN/gstack-config set proactive all\`. If C: \`$_BIN/gstack-config set proactive off\`.
Always run: \`touch "$_GH/.proactive-prompted"\`.
EOI
fi
@@ -562,12 +581,14 @@ fi
if [ "$_ACTIVATED" = "no" ]; then
_FT_TIP=""
case "$_FIRST_TASK" in
greenfield) _FT_TIP="Fresh repo — shape it first with \`/spec\` or \`/office-hours\`." ;;
code_node|code_python|code_rust|code_go|code_ruby|code_ios) _FT_TIP="There's code here — \`/qa\` to see it work, or \`/investigate\` if something's off." ;;
greenfield) _FT_TIP="Fresh repo — shape it first with \`/office-hours\`." ;;
code_node|code_python|code_rust|code_go|code_ruby|code_ios) _FT_TIP="There's code here — \`/qa\` to see it work, or \`/review\` on your latest diff." ;;
branch_ahead) _FT_TIP="Unshipped work on this branch — \`/review\` then \`/ship\`." ;;
dirty_default) _FT_TIP="Uncommitted changes — \`/review\` before committing." ;;
clean_default) _FT_TIP="Pick one: \`/spec\`, \`/investigate\`, or \`/qa\`." ;;
clean_default) _FT_TIP="Pick one: \`/office-hours\`, \`/qa\`, or \`/review\`." ;;
esac
# Under off the tips are the suggestions the user turned off.
[ "$_PROACTIVE_OFF" = "yes" ] && _FT_TIP=""
if [ -n "$_FT_TIP" ]; then
_emit_block first-run-tip <<EOI
Show this one project-specific line as a heads-up, then CONTINUE with whatever the user actually asked — do NOT halt their task: $_FT_TIP
@@ -576,16 +597,19 @@ EOI
fi
touch "$_GH/.activated" 2>/dev/null || true
elif [ "$_FIRST_LOOP_SHOWN" = "no" ]; then
_emit_block first-loop-tip <<EOI
Say once as a heads-up (then continue): Tip: gstack pays off when you complete one loop — **plan → review → ship**. A common first loop: \`/office-hours\` or \`/spec\` to shape it, \`/plan-eng-review\` to lock it, then \`/ship\`.
if [ "$_PROACTIVE_OFF" = "no" ]; then
_emit_block first-loop-tip <<EOI
Say once as a heads-up (then continue): Tip: gstack pays off when you complete one loop — **plan → review → ship**. A common first loop: \`/office-hours\` to shape it, \`/review\` to check it, then \`/ship\`.
EOI
touch "$_GH/.first-loop-tip-shown" 2>/dev/null || true
touch "$_GH/.first-loop-tip-shown" 2>/dev/null || true
fi
fi
# Routing injection (interactive: AUQ + CLAUDE.md append + commit). The body
# is a quoted heredoc (markdown backticks must stay literal), so the resolved
# bin path is substituted via sed on the __BIN__ placeholder.
if [ "$_HAS_ROUTING" = "no" ] && [ "$_ROUTING_DECLINED" = "false" ] && [ "$_PROACTIVE_PROMPTED" = "yes" ]; then
# bin path is substituted via sed on the __BIN__ placeholder. Under off the
# routing rules are the auto-invocation the user turned off, so no offer.
if [ "$_HAS_ROUTING" = "no" ] && [ "$_ROUTING_DECLINED" = "false" ] && [ "$_PROACTIVE_PROMPTED" = "yes" ] && [ "$_PROACTIVE_OFF" = "no" ]; then
sed "s|__BIN__|$_BIN|g" <<'EOI' | _emit_block routing-injection
gstack works best when the project CLAUDE.md includes skill routing rules. If no CLAUDE.md exists in the project root, create it. Use AskUserQuestion:
@@ -648,13 +672,13 @@ if [ "$_SESSION_KIND" = "unattended" ]; then
case "$SKILL_NAME" in
autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>\`." ;;
eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>\`, where <outdir> is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in <outdir>." ;;
ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt from .gstack/tmp/ship-receipt.md when nothing was pushed, and from the PR body (\`gstack-ship-receipt read --pr <n> --json\`) only once a PR exists." ;;
ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes <state root>/ship/runs/<utc>-<head12>/ship-run.json (a /ship record, not a run manifest) and prints \`GSTACK_RESULT: skill=ship status=complete run=<dir>\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never \`gh pr ready\`: the draft-state items there concern an early PR this run never opened); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;;
review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
*) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
esac
_emit_block unattended-session <<EOI
You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent; the only exception is the one named in your skill's record rule below. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
EOI
fi
+2 -2
View File
@@ -49,9 +49,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+2 -2
View File
@@ -50,9 +50,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -51,9 +51,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -76,9 +76,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -53,9 +53,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -53,9 +53,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -70,9 +70,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+24 -8
View File
@@ -178,8 +178,11 @@ draft are cut: the artifact path is tool-owned, a child's self-reported
path list is both a pathspec-injection surface and a self-report, and the
only history need is calibration, which `--commit <sha>` covers by scanning
one commit's first-parent delta without the working tree.) Positional
base defaults the way `gstack-diff-scope` does (`origin/HEAD` → `main` →
`master`); an already-qualified ref is used as given. stdout: one
base defaults the way `gstack-diff-scope` does (the remote's `HEAD` target,
origin first, then the other remotes → `main` → `master`); an
already-qualified ref is used as given, and a branch name with no local
branch resolves through its remote-tracking twin (`origin/main` for `main`:
the merge-base is the same commit), noted once on stderr. stdout: one
`GATE_SUMMARY: listed=N read=N tagged={RH-1:2,RH-15:1} unmatched=N
coverage=<patterns> languages_unlisted=<…> candidate=<sha+wtree>` line, then
the read-level listing (bounded; the full listing goes to `--out`).
@@ -207,9 +210,10 @@ hunks and 200 KB; `GATE_SUMMARY` reports `eligible`, `inspected`,
like UNAVAILABLE, and never as `none detected`.
**Exit contract.** 0 with or without hunks; 2 only when the base is
unresolvable (`GATE_ERROR=no_base ref=<ref>` with the concrete recovery:
`git fetch <remote> <branch>` from the detected remote, or "deepen the
shallow history") or the tool itself failed. Unmatched paths are reported
unresolvable locally and as `<remote>/<ref>` (`GATE_ERROR=no_base ref=<ref>`
with the concrete recovery: `git fetch <remote> +<branch>:refs/remotes/<remote>/<branch>`
into the remote-tracking ref, `--unshallow` on a shallow clone, or a plain
fetch/deepen for a sha or relative ref) or the tool itself failed. Unmatched paths are reported
in `GATE_SUMMARY: unmatched=N` and listed, never an exit 2. The template
block that calls it branches on the exit explicitly, never
`2>/dev/null || true`:
@@ -350,9 +354,21 @@ a test count natively.
saved log is capped at 2 MB and would truncate the summary) and parses
it into `tests_ran` for the supported runners only: bun (reusing
`shard-engine`'s classifier; executed = pass + fail, so an all-skip run is
ZERO-RUN), jest/vitest, pytest, rspec, and Go's zero-only case (every
package `[no test files]`); the last summary per runner wins. Everything
else is `unknown`. `check` reports ZERO-RUN as STALE-equivalent and
ZERO-RUN), jest/vitest, pytest, rspec, Go's zero-only case (every
package `[no test files]`), gstack's shard runner (the sum of its per-shard
`[test:free] PASS — N tests` epilogues, or a files-from run's
`FILES-FROM RESULT: … tests=N`), and a `gstack-pregate --remote` lane (its
`PREGATE_REMOTE: tree=… tests=N failed=M` row or the obligation's
`cleared by remote-gate tree=… tests=N` receipt line); when the shard runner
spoke its verdict stands for the whole tail (the tail is stdout then stderr,
so position cannot order bun's stderr counts against the runner's stdout
epilogues), and any summary when it did not speak follows the
last-summary-wins rule. Everything else is `unknown`. The shard runner and the
remote gate print only bun's terminal totals, which count skipped tests, so
for those two `tests_ran` is ran-with-skips rather than executed: the shard
runner refuses a shard whose bun summary counts fewer files than planned, but
a shard whose files all loaded and executed nothing still passes with that
total. `check` reports ZERO-RUN as STALE-equivalent and
unknown as non-blocking. The TRANSPARENCY INVARIANT holds: `run`'s exit is
always the child's; detection lives in the receipt and in `check`.
- After Step 7's test-generation child returns and after Step 9.4's parent
+58
View File
@@ -0,0 +1,58 @@
# Proactive skill suggestions
gstack notices what stage you are in (a new idea, a diff to review, "does this
work?", time to land it) and can invoke the matching skill without you typing
its slash command. The `proactive` config key says how far that goes.
| Value | What auto-invokes | Who it is for |
|-------|-------------------|---------------|
| `core` (default) | `/office-hours`, `/review`, `/qa` and `/ship` only. Every other skill waits for you to type it, unless a skill you ran hands off to it (`/office-hours` offering the next review). | Most people, and anyone running gstack beside another skill pack: the four core skills are the ones users praise, and the others no longer compete with another pack's niche skills. |
| `all` | Any skill whose purpose matches your request. | You use gstack on its own and want the whole sprint suggested. |
| `off` | Nothing. gstack never suggests or auto-invokes a skill, not even by asking "want me to run /X?". | You type every command yourself. |
`true` and `false` still work as the older spellings of `all` and `off`, so a
config written before the `core` default keeps its behavior; `gstack-skill-start`
reports them to the skill as `all` and `off`. A hand edit may be quoted, upper-case
or commented (`proactive: "OFF" # mine`), and the YAML yes/no family (`yes`, `on`,
`1`; `no`, `none`, `0`, `disabled`, `never`) reads as `all` or `off`, in
`gstack-config set` as well, so a typed opt-out never fails open.
```bash
gstack-config get proactive # core, all, off (or a legacy true/false)
gstack-config set proactive all # suggest every matching skill
gstack-config set proactive off # suggest nothing
gstack-config set proactive core # back to the default
```
A skill you name is always invoked, whatever the setting, and so is a skill that
a running skill invokes itself (`/ship` dispatching `/document-release`). The
setting governs unasked suggestions, and an in-skill *offer* to run another skill
is one: under `off`, `/office-hours` recommends the next review in one line
instead of offering to launch it, and the one-time first-run
tips stay quiet. The change takes effect on the next skill run; no `./setup` is
needed, because every skill reads `PROACTIVE` from `gstack-skill-start` at the
start of each run and the router (`/gstack`) applies it to its routing rules. A
value gstack does not know (a hand-edited `proactive: maybe`) reads as `core`.
Two surfaces follow the setting only in part. The first is the `## Skill routing`
section gstack offers to append to your project's CLAUDE.md. Under `off` the offer is
not made, because those rules are the auto-invocation you turned off (a section
already in CLAUDE.md stays; delete it yourself). Under `core` the offered
section still lists every routing rule and tells the host to invoke any
matching skill. If you accepted it before the `core` default, trim that section
to the four core rules (or delete it and let gstack offer it again); making the
offered section follow `core` is tracked in #3446.
The second is the full skill descriptions, which a `--catalog-mode=full` build
and the non-Claude hosts keep. Non-core skills still say "Proactively suggest
when ..." there, and that text does not change with the setting, so a host
that picks skills from those descriptions can still reach them under `core`.
The default Claude build uses one-line descriptions without that sentence.
#3446 tracks that too.
gstack asks once, after the telemetry question, which of the three you want;
if you stored `off` before that, it takes the answer as given and does not ask.
"Say stop suggesting" in a session runs `gstack-config set proactive off` for you.
If collisions with other skill packs keep coming up under `core`, the fallback
Garry approved on 2026-10-25 is `off` by default.
+73 -5
View File
@@ -168,9 +168,25 @@ gate: command="<gate_command>" full_e2e=no gate-ahead=none
SHIP_RECEIPT: pr=42 head=<head12> …
```
CI's Version Gate (`scripts/compare-pr-version.ts`) accepts that head as
unstamped when `VERSION` and `package.json`'s version match its merge base, the
CHANGELOG change is confined to `[Unreleased]`, and any changed release output
is byte for byte what `release_tool` generates at the head's merge base
`VERSION` (the head's own, so a later stamp on main changes nothing), so a
generator change ships with its regenerated output and the slot's restamp
regenerates it again. A release output that differs from that (a hand edit, one generated at a
bumped version, or one the tool never writes), a `VERSION` bump or a
`package.json` version change classifies the head as stamped, and it must then
carry base + bump. The tool runs as head code in a throwaway worktree with a
bare environment and no tokens; one that fails at the head is an error, never
a pass.
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
is not the PR's pushed head, CI at that exact head is not green, or the pregate
result is not a current pass. Send the block as printed.
result is not a current pass, and (`QUEUE_RECEIPT_MISSING`,
`QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that
head: /ship runs before every READY ([details](#ship-before-ready)). Send the
block as printed.
**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have
once the predecessor lands, while it is still open:
@@ -304,13 +320,53 @@ are mirrored to origin as git notes on `refs/notes/gstack-trains` before any
remote effect, so a lost machine loses nothing: `train status` on a clean
state root rebuilds them. `GSTACK_SHIP_QUEUE_NOW` pins the clock for tests.
<a id="ship-before-ready"></a>
**0. Lane: /ship before READY.** Every READY follows a /ship run on the branch
at the head being READY'd, including a re-READY after a push (Garry,
2026-10-25: "we should really be using the gstack ship skill before merging
PRs"). /ship runs the tests, both reviews, the gate-integrity list and the docs
audit, and ends by writing its receipt into the PR body
(`gstack-ship-receipt write --pr <n> … --body <file>`, posted with
`gstack-post pr-body`). Under `queue_mode: stamp-at-merge` it bumps no version
and never merges or merges main into the branch; the train still stamps at the
slot. The queue enforces this at every step from the PR body alone:
- `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid
receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the
head being READY'd (or its `pr` is another PR); the informational
`SHIP_RECEIPT:` line still prints. There is no flag around it.
- `train build` refuses a pin whose PR has no current receipt with the same
codes, naming the PR, before anything is written, pushed or dispatched,
`--dry-run` included.
- `verify --train` refuses the slot with the same codes before printing
`MERGE_CONDITION`.
**The stamp keeps the pin's receipt.** The slot's stamp commit changes only
stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs
on it: the receipt written for the pinned head stays the binding one. `verify
--train` accepts a receipt whose `head` is either the slot's pinned head or
the current head, and the re-READY after the stamp push (`ready` at the stamp
commit) accepts the receipt for the pin when HEAD is the pin plus stamp files,
or (from slot 2 on) the pin merged with main plus stamp files on top of the
merge; any other head is `QUEUE_RECEIPT_STALE`, including a merge of main with
no stamp above it (a lane's own push under rule 14) and a merge whose pin side
carries an edit made after /ship.
This is a process gate, not an authenticity check: the receipt is a block in
the PR body, which the PR author writes, so the codes prove that /ship was run
and recorded for this head, not that nobody typed the block by hand. Fork heads
are already refused (`QUEUE_TRAIN_UNTRUSTED`). The `ship-run.json` that
`gstack-ship-receipt result` writes under the lane's state root is a
normalized copy of that same block bound to the head it was run at, not an
authenticity proof either.
**1. Lane: READY with a screen choice.** Under a train policy READY needs an
explicit choice:
```bash
gstack-ship-queue ready --pr 42 # prints SCREEN: lines and the recommendation, refuses
gstack-ship-queue ready --pr 42 --no-screen # READY receipt; the PR rides unscreened
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (QUEUE_SCREEN_STALE_REF when GitHub's merge ref lags the head)
```
Without a choice it prints the `SCREEN:` selection (with
@@ -326,7 +382,17 @@ dispatches `gate_workflow` with `screen_pr`, `screen_merge_sha` (the
the policy, so a repeat follows the same run and a broad screen never adopts
a narrow one. The READY block gains `screen: …`, and the receipt is written
locally and to the notes ref. `--dry-run` prints the dispatch without sending
it.
it. Before dispatching, `ready` fetches the merge ref and requires its second
parent to be the PR head: a missing or lagging merge ref (GitHub stops
recomputing it while the PR conflicts with the base, CHANGELOG-only conflicts
included) is `QUEUE_SCREEN_STALE_REF`, because a screen dispatched there plans
no cases and reports success. 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
no cases …; counted as not-run` and seats the slot unscreened. A plan the
queue cannot read keeps the slot screened, with `SCREEN: #<n> <reason>;
counted as screened, unverified`.
**2. Coordinator: build.** Name the pins exactly as READY reported them:
@@ -350,8 +416,10 @@ has waited `fill_hours` (`QUEUE_TRAIN_WAITING`); `--urgent` dispatches a
one-PR train at once. 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
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin), `QUEUE_NOT_READY` (no
READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
`QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship
receipt for the pin; see [/ship before READY](#ship-before-ready)),
`QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
`QUEUE_TRAIN_CONFLICT: train <id> stopped at #<pr> (conflicts: <files>) after
merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
+4
View File
@@ -1236,6 +1236,10 @@ Outside-review routing follows the harness, independently of the configured mode
`/claude` was renamed to `/claude-code` without an alias. Run `./setup --host <name>` to migrate managed installations, including installations sharing that checkout. Setup retains a working old installation when replacement generation or installation fails.
## Proactive skill suggestions
gstack can invoke a skill for you when your request matches one. By default (`proactive: core`) only `/office-hours`, `/review`, `/qa` and `/ship` auto-invoke; `all` frees every skill and `off` suggests nothing. See [docs/proactive.md](proactive.md).
## Safety & Guardrails
Four skills that add safety rails to any Claude Code session. They work via Claude Code's PreToolUse hooks — transparent, session-scoped, no configuration required.
+97 -6
View File
@@ -1604,8 +1604,9 @@ message lists.
**Meaning.** On a Capy machine (`GSTACK_CAPY=1`, set by the `GSTACK_LAUNCH`
line), an unattended or spawned workflow starts only with a passing install
verdict (`<state root>/install-verdicts/<sha256 of the checkout path>.json`,
named as `GSTACK_INSTALL_VERDICT`) for this checkout, its current HEAD and a
verdict (`<state root>/install-verdicts/<sha256 of the checkout path>/<sha256
of the project directory, or none>.json`, named as `GSTACK_INSTALL_VERDICT`)
for this checkout, its current HEAD and a
workflow at least as wide as the one starting: a verdict for `--for autoplan`
does not cover `qa`'s browser, and a planning-only pins SKIP does not cover a
workflow that runs the repo's tests. `gstack-skill-start` printed this row and
@@ -1613,8 +1614,12 @@ workflow that runs the repo's tests. `gstack-skill-start` printed this row and
an unattended run whose Bun on PATH is below the supported minimum
(`INSTALL: fail runtime ... (RUNTIME_BELOW_MINIMUM)`) on any host.
**Fix.** Run the Capy skill installer with `--for <workflow>` on this machine
and launch with the `GSTACK_LAUNCH` line it prints.
**Fix.** Run the Capy skill installer (`scripts/install.sh --for <workflow>`)
from the project directory the workflow will run in, since the verdict is
keyed by that directory (GSTA-85), and launch with the `GSTACK_LAUNCH` line it
prints. A verdict file that exists but cannot be read (`the install verdict at
<file> is unreadable or has an unsupported schema_version`) is repaired the
same way, by rerunning the installer.
`GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (printed as
`INSTALL_VERDICT: overridden`); record it as a deviation.
@@ -2239,7 +2244,11 @@ entry that no longer applies; rotate a credential that is real.
<a id="redact-version-as-ip"></a>
### `pii.ip_public` MEDIUM on a four-part version number
**Meaning.** The line does not declare the value as a version.
**Meaning.** The line does not declare the value as a version. When the
text goes through `gstack-post` from inside the repository, four-part versions
equal to the root `VERSION` or to a `## [x.y.z.w]` / `## vx.y.z.w` CHANGELOG.md heading are
exempt wherever they appear, so there the finding means the quad is not a
release of this repository (or the post ran outside the checkout).
**Fix.** Write it as `version: 1.2.3.4`, `"version": "..."` or `v1.2.3.4`.
MEDIUM findings do not block pushes.
@@ -2615,8 +2624,22 @@ read the receipt again.
**Meaning.** Two sections share one version heading, usually a stamp that
re-headed `[Unreleased]` beside an entry that already carried the version.
In `gstack-restamp` only duplicates the PR introduces count: the headings are
compared against the base branch's CHANGELOG as read at `origin/<base>` and at
the commit the branch's own lines are counted from (its fork point, or the
predecessor's head in a gate-ahead stamp); the larger count per heading wins,
so a version the base already heads twice (released history the PR inherited
and must keep) passes as long as the PR adds no further copy of it. When a
reading is unavailable (the PR adds the CHANGELOG itself, or the file moved),
the stage detail and the refusal name it: with no reading left every duplicate
counts, otherwise inherited headings are counted at the readable commit only. Two paths keep the strict rule: the section being re-headed itself
(`[Unreleased]`, or the branch's earlier stamp) may exist only once, and
`gstack-version-bump write --changelog` (the claim-at-open bump) compares the
file with itself.
**Fix.** Merge them into one entry under the new version.
**Fix.** Merge the duplicate sections the PR introduced into one entry under
the new version. A duplicate that is on the base branch itself is not this
PR's to fix in a restamp; leave it and the stamp proceeds.
<a id="changelog-top-mismatch"></a>
### `CHANGELOG_TOP_MISMATCH`
@@ -4021,6 +4044,74 @@ trains and other trains' merges are refused until a repair lands.
<n>@<sha>`). The freeze clears when verify finds main's tree after that merge
in a green record; `NEXT:` then builds a train of the PRs still unmerged.
<a id="queue-receipt-missing"></a>
### `QUEUE_RECEIPT_MISSING`
**Meaning.** The PR body carries no valid ```gstack-ship-receipt``` block, so
/ship never ran on this branch (or its receipt was lost when the body was
rewritten). `gstack-ship-queue ready` refuses to READY it, `train build`
refuses the pin by PR number before any write, push or dispatch (dry run
included), and `verify --train` refuses the slot. The block still prints the
informational `SHIP_RECEIPT: none in the PR body` (or `invalid`) line. There
is no flag that skips this check (Garry, 2026-10-25: use the gstack ship skill
before merging PRs; [ship-policy.md](ship-policy.md#ship-before-ready)).
**Fix.** Run /ship on the branch. Unattended, it ends by writing the receipt
into the PR body (`gstack-ship-receipt write --pr <n> … --body <file>`, then
`gstack-post pr-body`) when an open PR exists for the branch at HEAD;
otherwise it leaves the receipt in `.gstack/tmp/ship-receipt.md` and the
parent posts it ([unattended.md](unattended.md)). Then rerun
`gstack-ship-queue ready --pr <n> --screen|--no-screen` and send the new READY
block.
<a id="queue-receipt-stale"></a>
### `QUEUE_RECEIPT_STALE`
**Meaning.** The PR body's ship receipt is valid but names another head than
the one being READY'd or merged (a push after /ship, a receipt copied from
another PR, or a `pr` field for another PR), so the receipt does not cover
what would merge. The one accepted difference is a train slot's stamp: when
the current head is the pinned head's stamp commit (same tree modulo stamp
files, or the pin with main merged in at slot 2 and later), the receipt
written for the pinned head is the binding one and `verify --train` accepts it.
**Fix.** Rerun /ship on the current head so the receipt names it, then rerun
`gstack-ship-queue ready --pr <n> --screen|--no-screen`. A lane never stamps
to fix this: the stamp comes only when the slot is called.
<a id="queue-screen-stale-ref"></a>
### `QUEUE_SCREEN_STALE_REF`
**Meaning.** `gstack-ship-queue ready --screen` dispatches the screen against
GitHub's `refs/pull/<n>/merge`, and that ref is missing or still merges an
older head than the PR's pushed head. GitHub stops recomputing the merge ref
while the PR conflicts with the base (a CHANGELOG-only conflict after a stamp
counts), so a screen dispatched there checks out the old merge, sees itself
superseded, plans 0 cases and reports success. GSTA-95 hit this on #3431:
`ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares
the merge ref's second parent with the PR head before dispatching, and a
finished screen that planned no cases is never adopted as a screen. A screen
run uploads no train record, so the evidence is its plan: a superseded run
skips `plan-slices` and uploads no `paid-plan` artifact, and a manifest with
nothing planned counts the same. `ready --screen` dispatches a fresh one and
`train build` prints `SCREEN: #<n> run <id> planned no cases (no paid-plan
artifact; …)` or `… planned no cases (an empty plan manifest; …)`, each
ending `; counted as not-run, the slot rides unscreened`. A plan it cannot
read (`… paid-plan artifact has expired`, `… manifest.json is unreadable`, an
unreadable run or artifact list, an unreadable repository slug, or a READY
whose run id is not a number) keeps the slot screened and prints
`SCREEN: #<n> <reason>; counted as screened, unverified`; `ready --screen`
follows such a run. When `ready` cannot list the remote's refs or fetch the
merge ref at all, it refuses with the same code and a `could not list …` or
`could not fetch …` message whose NEXT line says to check the remote, not to
resolve a conflict.
**Fix.** Resolve the conflict with the base branch (or wait for GitHub to
recompute the merge ref), push, and run `gstack-ship-queue ready --pr <n>
--screen` again. Under crew rule 14 a CHANGELOG-only conflict is left to the
train, so a lane that cannot get a fresh merge ref READYs `--no-screen` only
after its own focused paid cases passed (rule 6).
<a id="ready-screen-choice-required"></a>
### `READY_SCREEN_CHOICE_REQUIRED`
+40 -8
View File
@@ -80,7 +80,36 @@ Not every workflow writes this line or a run directory today:
record), not a run directory.
- **A refused launch.** Any skill whose install check refuses it at startup
prints `GSTACK_RESULT: skill=<name> status=refused run=-` and stops.
- **Every other workflow skill** (`/ship`, `/review`, `/qa`, `/design-review`,
- **`/ship`** ends with `gstack-ship-receipt result --pr <n>` (or `--body
.gstack/tmp/ship-receipt.md` after the handoff section). It binds the
receipt to the branch head, writes `<state root>/ship/runs/<utc>-<head12>/`
(`ship-run.json` with `head`, `pr`, `base`, `version`, `queue_mode`,
`session_kind`, `receipt` as the path of the copied `ship-receipt.md`, and
`receipt_source`; it is a /ship record, not the `run` v1 manifest below, so
read it directly: `gstack-artifact validate` does not apply) and prints
`GSTACK_RESULT: skill=ship status=complete run=<dir>`. When gh cannot read
the PR it prints `GSTACK_RESULT: skill=ship status=incomplete run=-` (exit 1). A receipt that is missing or names another head prints
`GSTACK_RESULT: skill=ship status=refused run=-` with `QUEUE_RECEIPT_MISSING`
or `QUEUE_RECEIPT_STALE` on stderr (exit 3); a run that stops before
publication prints the same refused line itself. A parent's merge check is
then mechanical: take `run=` from the last result line, read
`ship-run.json`, and compare its `head` to the PR head. `gstack-ship-queue ready`,
`train build` and `verify --train` make the same comparison from the PR body
([ship-policy.md](ship-policy.md#ship-before-ready)), which is why an
unattended /ship may update the body of a PR that already exists: when an
open PR for the branch exists and HEAD is its pushed head, Step 18 takes the
pr-body section's existing-PR path and posts the body (receipt first) and
title through `gstack-post`. That is the one publication the unattended
rules allow; it never runs `pr-create`, never pushes a commit, never runs
`gh pr ready` (the existing-PR path's draft-state items concern an early PR
an unattended run never opens), and a branch with no open PR, or whose PR
head is not HEAD, takes the handoff section. `result --pr <n>` also refuses
`QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head.
When the run committed anything (a CHANGELOG entry, generated tests), the
parent pushes those commits itself, then reruns the receipt write, the body
post and `result` at the pushed head: a receipt for an unpushed head is
`QUEUE_RECEIPT_STALE` to the queue.
- **Every other workflow skill** (`/review`, `/qa`, `/design-review`,
`/spec`, `/land-and-deploy` and the rest) gets the session rules above (no
prompts, approvals stay pending, nothing published), but has no step that
writes a run directory or prints the line, so a parent should not wait for
@@ -88,14 +117,15 @@ Not every workflow writes this line or a run directory today:
helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate
list; it does not create one). `/cso` skips the shared startup, so these
session rules do not reach it at all. Read each skill's own record instead:
the review-log rows (`gstack-review-read`) for `/review`, the
`qa-report-*.md` file for `/qa`, and the ship receipt for `/ship`. When
the run did not push (an unattended run must not), read the receipt file in
the child's checkout with `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
the review-log rows (`gstack-review-read`) for `/review` and the
`qa-report-*.md` file for `/qa`. For a `/ship` run from before the result
line, read the receipt instead: when the run did not push (an unattended run
must not), read the receipt file in the child's checkout with
`gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
use `gstack-ship-receipt read --pr <n> --json` only once a PR exists. In both
cases compare the receipt's `head` to the branch SHA (`git rev-parse HEAD` in
the child's checkout): `read` does not check that the receipt is current, so
an older PR's receipt reads as valid.
an older PR's receipt reads as valid; `result` does.
Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage,
`3` refused or needs a flag. Error lines end with a result code in parentheses,
@@ -125,8 +155,10 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
## How a parent consumes a run
1. **Decide whether the run finished.** `gstack-artifact validate <dir>/run.json`
is the deterministic completion check. It exits `0` and prints
1. **Decide whether the run finished.** For an `/autoplan` run directory,
`gstack-artifact validate <dir>/run.json`
is the deterministic completion check (a `/ship` `run=` directory holds
`ship-run.json`, read directly as described above). It exits `0` and prints
`ARTIFACT_VALID: run=<id> status=<status>` only when the manifest parses and
matches its schema, every required phase and each reviewer voice has a
terminal outcome, every listed artifact exists inside the run directory with
+2 -2
View File
@@ -56,9 +56,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -55,9 +55,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
@@ -152,8 +152,11 @@ export function directiveFor(kind: SessionKind): string {
'SESSION_KIND=unattended — auto-choose the `(recommended)` option and append it to decisions.jsonl ' +
'with kind "auto"; do not emit prose, do not BLOCK. A question with no recommendation, a consent, ' +
'a destructive or irreversible option, or an approval gate is appended with kind "approval" and status ' +
'"pending" — never chosen, never approved — and the skill ends at its next gate with ' +
'GSTACK_RESULT status=gate_pending (Unattended session block).'
'"pending" — never chosen, never approved — and the skill ends at its next gate. Only /autoplan and ' +
'/eval-plan print `GSTACK_RESULT: skill=<name> status=gate_pending run=<dir>` there, and /ship prints its ' +
'own line at Step 22 (`gstack-ship-receipt result`); every other skill prints no GSTACK_RESULT line of its own ' +
'(only a refused install check prints `status=refused run=-` for any skill), so do not invent one ' +
'(Unattended session block; docs/unattended.md names each skill\'s record).'
);
case 'headless':
// #2733 review (multi-specialist): a spawned-marked subagent under a
+2 -2
View File
@@ -94,9 +94,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -51,9 +51,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -50,9 +50,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -55,9 +55,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -50,9 +50,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -51,9 +51,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+30 -8
View File
@@ -2,8 +2,9 @@
* changelog-check — CHANGELOG re-heading and checks for gstack-restamp (C2)
* and the tree receipt (C3). A restamp moves the PR's section (`[Unreleased]`
* or the version currently stamped on the branch) to the top under the new
* version, above its predecessor's entry. Duplicate version headings and a
* top entry that is not VERSION are errors. The tree receipt uses
* version, above its predecessor's entry. Duplicate version headings beyond
* those the base branch already carries, and a top entry that is not VERSION,
* are errors. The tree receipt uses
* `normalizeChangelog` to accept a heading move as a stamp, never a change.
*/
import type { ResultCodeName } from './result-codes';
@@ -44,15 +45,36 @@ export function parseChangelog(text: string): ParsedChangelog {
return { header, sections };
}
/** Duplicate headings, and (when `expectedTop` is given) the top entry equals VERSION. */
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md'): ChangelogError[] {
/**
* Duplicate headings, and (when `expectedTop` is given) the top entry equals
* VERSION. `base` holds readings of the base branch's CHANGELOG (the base tip,
* and the commit the PR's own lines are counted from): a heading it already carries N
* times may appear N times here (history the PR inherited and may not
* rewrite); only an occurrence beyond that count is a duplicate the PR
* introduced. The count is per heading, not per section body: an inherited
* copy the PR edits or replaces is caught by the train's released-history
* checks (`ownEntryAboveMain`, `changelogDamage`), not here.
*/
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md', base: string[] = []): ChangelogError[] {
const errors: ChangelogError[] = [];
const parsed = parseChangelog(text);
const seen = new Map<string, number>();
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
const inherited = new Map<string, number>();
for (const reading of base) {
const counts = new Map<string, number>();
for (const s of parseChangelog(reading).sections) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
for (const [k, n] of counts) inherited.set(k, Math.max(inherited.get(k) ?? 0, n));
}
const seen = new Map<string, number[]>();
for (const s of parsed.sections) {
const key = s.unreleased ? 'Unreleased' : s.version!;
if (seen.has(key)) errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${key} already headed at line ${seen.get(key)! + 1}` });
else seen.set(key, s.start);
const k = key(s);
const starts = seen.get(k) ?? [];
const allowed = Math.max(1, inherited.get(k) ?? 0);
if (starts.length >= allowed) {
const where = allowed > 1 ? `headed ${starts.length + 1} times (the base carries it ${allowed} times; first at line ${starts[0]! + 1})` : `already headed at line ${starts[0]! + 1}`;
errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${k} ${where}` });
}
seen.set(k, [...starts, s.start]);
}
if (expectedTop) {
const top = parsed.sections[0];
+68 -9
View File
@@ -46,12 +46,35 @@ export function resolvesToCommit(ref: string, cwd: string): string | null {
return gitText(['rev-parse', '--verify', '-q', `${ref}^{commit}`], cwd);
}
/** origin/HEAD's target → `main` → `master` (the gstack-diff-scope chain), as a ref name. */
/**
* A ref that could be a branch name: not a sha, not relative or symbolic
* (`HEAD~1`, `main^2`, `@{u}`, `HEAD`, `FETCH_HEAD`), not a full `refs/` path.
* Only such a ref has a remote-tracking twin (`origin/<ref>`) worth trying, and
* only such a ref goes into a fix-line refspec; `origin/HEAD~1` would resolve
* to a different commit than the `HEAD~1` the caller named.
*/
function isBranchName(ref: string): boolean {
return !/^[0-9a-f]{7,64}$/.test(ref) && !/[~^]|@\{/.test(ref) && !/^(?:[A-Z_]*HEAD)$/.test(ref) && !ref.startsWith('refs/');
}
function remoteNames(cwd: string): string[] {
const remotes = (gitText(['remote'], cwd) ?? '').split('\n').filter(Boolean);
return remotes.includes('origin') ? ['origin', ...remotes.filter((r) => r !== 'origin')] : remotes;
}
/**
* The remote's HEAD target → `main` → `master` (the gstack-diff-scope chain),
* as a ref name; the remotes are walked in resolveBase's order, so the default
* and the fix line come from the same remote.
*/
export function defaultBase(cwd: string): string {
const head = gitText(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'], cwd);
if (head) return head.replace(/^refs\/remotes\//, '');
const remotes = remoteNames(cwd);
for (const remote of remotes) {
const head = gitText(['symbolic-ref', '-q', `refs/remotes/${remote}/HEAD`], cwd);
if (head) return head.replace(/^refs\/remotes\//, '');
}
for (const name of ['main', 'master']) {
if (resolvesToCommit(name, cwd) || resolvesToCommit(`origin/${name}`, cwd)) return name;
if (resolvesToCommit(name, cwd) || remotes.some((remote) => resolvesToCommit(`${remote}/${name}`, cwd))) return name;
}
return 'main';
}
@@ -60,14 +83,50 @@ export function isShallow(cwd: string): boolean {
return gitText(['rev-parse', '--is-shallow-repository'], cwd) === 'true';
}
/** The concrete recovery for an unresolvable base: fetch it from the detected remote, unshallowing when needed. */
/**
* The base as a ref that resolves here and, when `head` is given, shares
* history with it: the ref itself, else its remote-tracking branch
* (`origin/main` for `main`). A clone that checked out only the feature
* branch has no local `main`, and the merge-base against `origin/main` is the
* same commit; a stale local `main` that shares no history with HEAD (a
* fetched-over clone) yields to the twin too, so the fix line's fetch into the
* tracking ref is a fix and not a loop.
*/
export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' } | null {
const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null;
const direct = resolvesToCommit(ref, cwd);
if (direct && related(direct)) return { ref, sha: direct };
if (!isBranchName(ref)) return direct ? { ref, sha: direct } : null;
for (const remote of remoteNames(cwd)) {
const tracking = `${remote}/${ref}`;
const sha = resolvesToCommit(tracking, cwd);
if (sha && related(sha)) return { ref: tracking, sha, local: direct ? 'unrelated' : 'missing' };
}
return direct ? { ref, sha: direct } : null;
}
/** A word for a printed shell line: as is when it is plain, single-quoted otherwise (ref names may hold `;`, `$`, `(`, `{1,2}`). */
function shellWord(s: string): string {
return /^[A-Za-z0-9._\/@^~+:-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
}
/**
* The concrete recovery for an unresolvable base: fetch it from the detected
* remote into its remote-tracking ref (which resolveBase then finds; a bare
* `git fetch origin main` only updates FETCH_HEAD in a single-branch clone),
* unshallowing when needed. The refspec is forced (`+`) so the line also
* repairs a tracking ref that exists but shares no history with HEAD. A sha
* or a relative ref (`HEAD~1`, `main^2`, `@{u}`) is not a refspec: that gets a
* plain fetch (deepened when shallow).
*/
export function noBaseFix(ref: string, cwd: string): string {
const remotes = (gitText(['remote'], cwd) ?? '').split('\n').filter(Boolean);
const remotes = remoteNames(cwd);
const m = /^([^/]+)\/(.+)$/.exec(ref);
const remote = m && remotes.includes(m[1]) ? m[1] : remotes.includes('origin') ? 'origin' : remotes[0] ?? 'origin';
const remote = m && remotes.includes(m[1]) ? m[1] : remotes[0] ?? 'origin';
const branch = m && remotes.includes(m[1]) ? m[2] : ref;
if (/^[0-9a-f]{7,64}\^?$/.test(ref) || ref.endsWith('^')) return isShallow(cwd) ? `git fetch --deepen=50 ${remote}` : `git fetch ${remote}`;
return isShallow(cwd) ? `git fetch --unshallow ${remote} ${branch}` : `git fetch ${remote} ${branch}`;
if (!isBranchName(ref)) return isShallow(cwd) ? `git fetch --deepen=50 ${shellWord(remote)}` : `git fetch ${shellWord(remote)}`;
const refspec = shellWord(`+${branch}:refs/remotes/${remote}/${branch}`);
return isShallow(cwd) ? `git fetch --unshallow ${shellWord(remote)} ${refspec}` : `git fetch ${shellWord(remote)} ${refspec}`;
}
export function mergeBase(a: string, b: string, cwd: string): string | null {
+56 -3
View File
@@ -16,6 +16,8 @@
import { createHash } from "crypto";
import { scan, type Finding, type RepoVisibility } from "./redact-engine";
import { canonicalRemote } from "./remote-identity";
import { parseChangelog } from "./changelog-check";
import { versionWidth } from "./version-source";
export const OPS = ["pr-comment", "issue-comment", "reply", "pr-title", "pr-body", "pr-create", "issue-create"] as const;
export type Op = (typeof OPS)[number];
@@ -198,11 +200,61 @@ function visibility(env: PostEnv, req: Request, kind: HostKind, configBin: strin
type Part = "title" | "body";
interface Located { part: Part; finding: Finding }
function scanParts(parts: Array<[Part, string]>, vis: RepoVisibility, selfEmail: string | undefined) {
/**
* The repository's own four-part release versions: the root VERSION file's
* value and every version in a root CHANGELOG.md release heading, read with
* the same parser gstack-restamp uses (`## [1.2.3.4]`, `## v1.2.3.4`, an
* optional `- date`; a heading that only mentions a quad is not one). A
* four-part version is byte-for-byte a dotted quad, and `1.91.191.0` is a
* public address, so "(1.91.191.0)" in a PR body raised pii.ip_public
* (MEDIUM), which an unattended /ship cannot confirm. The engine's own version
* exemption reads only the declaration right before the span, so prose that
* merely names a release gets no help from it. These exact strings are
* allowlisted for the scan wherever they appear (the allowlist is exact-string
* and context-free, so `host: <released quad>` is exempt too, a trade the
* repo's own release set is small enough to carry); any other dotted quad
* (8.8.8.8, 203.0.113.7, a version this repo never released) still reports.
* The files are read from the checked-out tree, so a branch that adds a
* four-part heading widens the set for posts made from that checkout.
*/
function repoReleaseVersions(env: PostEnv): string[] {
const top = env.run("git", ["rev-parse", "--show-toplevel"]);
const root = top.status === 0 ? top.stdout.trim() : "";
if (!root) return [];
const read = (name: string) => { try { return env.readFile(`${root}/${name}`); } catch { return ""; } };
const versions = new Set<string>();
const current = read("VERSION").trim();
if (versionWidth(current) === 4) versions.add(current);
for (const s of parseChangelog(read("CHANGELOG.md")).sections) if (s.version && versionWidth(s.version) === 4) versions.add(s.version);
return [...versions];
}
/**
* All-digit identifiers a ship receipt or a CI link names, which pii.phone.e164
* reads as phone numbers: a 12-hex receipt sha that happens to be all digits
* (`tree=704992822996`, `tree 704992822996`, and the receipt's `head=`,
* `gated_tree=` and `predecessor=` keys) and an 11-digit GitHub Actions
* run id as prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`;
* the engine already exempts the id inside the URL itself). Only the exact
* tokens found in those positions are allowlisted, so a bare 11-digit number,
* a 10-digit national number and an E.164 `+14155551234` still report.
*/
function receiptDigitTokens(texts: string[]): string[] {
const tokens = new Set<string>();
for (const text of texts) {
for (const m of text.matchAll(/(?:^|[^\w])(?:tree|gated_tree|head|predecessor)[= ](\d{12})\b/g)) tokens.add(m[1]!);
for (const m of text.matchAll(/\brun #?(\d{11})\b/g)) tokens.add(m[1]!);
for (const m of text.matchAll(/\/actions\/runs\/(\d{11})\b/g)) tokens.add(m[1]!);
}
return [...tokens];
}
function scanParts(parts: Array<[Part, string]>, vis: RepoVisibility, selfEmail: string | undefined, releaseVersions: string[]) {
const found: Located[] = [];
let oversize = false;
const allowlist = [...releaseVersions, ...receiptDigitTokens(parts.map(([, text]) => text))];
for (const [part, text] of parts) {
const result = scan(text, { repoVisibility: vis, ...(selfEmail ? { selfEmail } : {}) });
const result = scan(text, { repoVisibility: vis, allowlist, ...(selfEmail ? { selfEmail } : {}) });
oversize ||= result.oversize;
for (const finding of result.findings) found.push({ part, finding });
}
@@ -303,7 +355,8 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
const vis = visibility(env, req, remote.kind, configBin);
const email = env.run("git", ["config", "user.email"]);
const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined);
const hasQuad = parts.some(([, text]) => /\b\d{1,3}(?:\.\d{1,3}){3}\b/.test(text));
const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined, hasQuad ? repoReleaseVersions(env) : []);
env.out(`REPO_VISIBILITY: ${vis}\n`);
const line = (m: Located) => `RULE: ${m.finding.id} LINE: ${m.finding.line} PART: ${m.part}\n`;
const high = found.filter(m => m.finding.severity === "HIGH");
+5 -1
View File
@@ -141,7 +141,11 @@ export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunP
const file = findInstallVerdict(src, project, env);
const v = readInstallVerdict(file);
const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' });
if (!v) return problem(`no install verdict at ${file} for ${src}`);
if (!v && fs.existsSync(file)) return problem(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`);
if (!v) {
return { ...problem(`no install verdict at ${file} for ${src}`),
fix: `run the Capy skill installer (scripts/install.sh --for ${skill}) from ${project ?? 'this directory'}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project ?? 'it'}; then launch with the GSTACK_LAUNCH line it prints` };
}
if (!v.ok) return problem(`the install verdict says: ${v.trailer}`);
if (v.checkout !== src) return problem(`the install verdict is for checkout ${v.checkout}, not ${src}`);
const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? '';
+13 -3
View File
@@ -380,9 +380,19 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
const r = reheadChangelog(fs.readFileSync(clPath, 'utf8'), { from, to: plan.newVersion, date, file: clRel! });
if (r.error) { cl.status = 'failed'; cl.detail = r.error.message; throw new Refusal(r.error.code, `${clRel}: ${r.error.message}; nothing written to the branch`); }
if (r.moved) fs.writeFileSync(clPath, r.text);
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!);
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`;
// Inherited duplicates are allowed at the larger count of two base readings: the base tip and
// `since`, the commit the branch's own lines are counted from (its fork point, or the predecessor's
// head in a gate-ahead stamp), so neither a duplicate main collapsed after the fork nor one it
// gained since refuses the PR. A reading the base cannot show (the PR adds the file) is named.
const readings = [...new Set([plan.baseSha, since])].map(sha => ({ sha, cl: git(o.repoRoot, ['show', `${sha}:${clRel}`]) }));
const readable = readings.filter(x => x.cl.ok);
const unreadable = readings.filter(x => !x.cl.ok).map(x => `${x.sha.slice(0, 12)}:${clRel}`);
const baseNote = unreadable.length === 0 ? '' : readable.length === 0
? `; base ${clRel} unreadable at ${unreadable.join(', ')}, so every duplicate heading counts`
: `; base ${clRel} unreadable at ${unreadable.join(', ')}, inherited headings counted at ${readable.map(x => x.sha.slice(0, 12)).join(', ')} only`;
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!, readable.map(x => x.cl.out + '\n'));
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message + baseNote; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}${baseNote}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + baseNote;
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
injected('changelog');
+16
View File
@@ -132,6 +132,22 @@ export const RELEASE_RESULT_CODES = {
summary: 'a train stopped partway through publication, so main holds a prefix no gate covered on its own; ordinary trains are refused until a repair lands',
fix: 'admit one repair PR at its exact head (train build --repair --prs <n>@<sha>); the freeze clears when verify finds main’s tree after that merge in a green record',
},
// CREW rule 22 (Garry, 2026-10-25): /ship runs before every READY; the queue refuses a PR whose body has no current receipt.
QUEUE_RECEIPT_MISSING: {
anchor: 'queue-receipt-missing',
summary: 'the PR body carries no valid /ship receipt, so the PR was never shipped through gstack and cannot be READY’d, admitted to a train or merged',
fix: 'run /ship on the branch (unattended it writes the receipt into an open PR at HEAD with gstack-ship-receipt write --pr <n> --body …, else leaves it in .gstack/tmp/ship-receipt.md for the parent to post), then gstack-ship-queue ready again; there is no flag to skip this',
},
QUEUE_RECEIPT_STALE: {
anchor: 'queue-receipt-stale',
summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)',
fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head',
},
QUEUE_SCREEN_STALE_REF: {
anchor: 'queue-screen-stale-ref',
summary: 'GitHub’s refs/pull/<n>/merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
fix: 'resolve the conflict with the base branch (or wait for GitHub to recompute the merge ref), push, then gstack-ship-queue ready --pr <n> --screen again; a lane never READYs on a screen that planned nothing',
},
READY_SCREEN_CHOICE_REQUIRED: {
anchor: 'ready-screen-choice-required',
summary: 'the base policy turns trains on, so READY needs an explicit screen choice: --screen (pay for a diff-relevant screen now) or --no-screen',
+2 -2
View File
@@ -226,8 +226,8 @@ export const RESULT_CODES = {
},
CHANGELOG_DUPLICATE_HEADING: {
anchor: 'changelog-duplicate-heading',
summary: 'two CHANGELOG sections carry the same version heading',
fix: 'merge the duplicate sections into one entry under the new version',
summary: 'two CHANGELOG sections carry the same version heading, beyond any duplicate the base branch already carries',
fix: 'merge the duplicate sections the PR introduced into one entry under the new version',
},
CHANGELOG_TOP_MISMATCH: {
anchor: 'changelog-top-mismatch',
+39 -7
View File
@@ -5,11 +5,12 @@
* full-gate check run. Dispatch itself goes through scripts/eval-bg.ts.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { Exec, RunSummary } from '../scripts/eval-bg';
import type { QueueContext } from './ship-queue';
export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string }
export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string; body: string }
export interface RunInfo { databaseId: number; status: string; conclusion: string | null; url: string; headSha: string; event: string; displayTitle: string }
export interface CheckInfo { status: string; conclusion: string | null }
@@ -33,10 +34,10 @@ export function repoSlug(ctx: QueueContext): string | null {
}
export function prInfo(ctx: QueueContext, pr: string): PrInfo | { error: string } {
const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName']);
const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName,body']);
const p = json<Partial<PrInfo>>(r);
if (!p?.headRefOid) return { error: `cannot read PR #${pr} through gh (${(r.err ?? r.out).trim().slice(0, 120) || 'no headRefOid'})` };
return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '' };
return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '', body: p.body ?? '' };
}
const RUN_FIELDS = 'databaseId,status,conclusion,url,headSha,event,displayTitle';
@@ -56,18 +57,49 @@ export function asRun(summary: RunSummary): { id: number; url: string } {
return { id: summary.databaseId, url: summary.url };
}
/** One JSON artifact of a run (the first .json file it holds); null when it is missing or unreadable. */
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string): unknown {
/** One JSON artifact of a run (the named file, else the first .json file it holds); null when it is missing or unreadable. */
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string, file?: string): unknown {
const dir = path.join(scratch, `${run}-${name}`);
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
try {
if (!ctx.gh(['run', 'download', String(run), '--repo', slug, '-n', name, '-D', dir]).ok) return null;
const file = fs.readdirSync(dir).find(f => f.endsWith('.json'));
return file ? JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) : null;
const pick = file ?? fs.readdirSync(dir).find(f => f.endsWith('.json'));
return pick && fs.existsSync(path.join(dir, pick)) ? JSON.parse(fs.readFileSync(path.join(dir, pick), 'utf8')) : null;
} catch { return null; } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
/**
* A finished screen run that planned no cases is not a screen: when the merge
* ref it was dispatched for was superseded, evals.yml skips plan-slices and
* concludes success (GSTA-95 on #3431). A screen run uploads no train record,
* so the evidence is the plan itself: no `paid-plan` artifact, or a manifest
* with nothing planned. `hollow` carries the reason; `live` is a screen that
* planned cases or is still running; `unknown` names what could not be read
* (the caller decides, and says, how it counts).
*/
export type ScreenPlan = { kind: 'hollow'; reason: string } | { kind: 'live' } | { kind: 'unknown'; reason: string };
export function hollowScreen(ctx: QueueContext, slug: string, run: number): ScreenPlan {
if (!Number.isSafeInteger(run) || run <= 0) return { kind: 'unknown', reason: `screen run id ${String(run).slice(0, 40)} is not a run number` };
const view = runView(ctx, slug, run);
if (!view) return { kind: 'unknown', reason: `run ${run} is unreadable` };
if (view.status !== 'completed') return { kind: 'live' };
const list = json<{ artifacts?: Array<{ name?: string; expired?: boolean }> }>(ctx.gh(['api', `repos/${slug}/actions/runs/${run}/artifacts?name=${PLAN_ARTIFACT}&per_page=10`]));
if (!Array.isArray(list?.artifacts)) return { kind: 'unknown', reason: `run ${run}'s artifact list is unreadable` };
const plan = list.artifacts.find(a => a.name === PLAN_ARTIFACT);
if (!plan) return { kind: 'hollow', reason: `run ${run} planned no cases (no ${PLAN_ARTIFACT} artifact; concluded ${view.conclusion ?? 'unknown'})` };
if (plan.expired) return { kind: 'unknown', reason: `run ${run}'s ${PLAN_ARTIFACT} artifact has expired` };
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-screen-'));
let manifest: { entries?: Array<{ status?: unknown }> } | null;
try { manifest = downloadJson(ctx, slug, run, PLAN_ARTIFACT, scratch, 'manifest.json') as typeof manifest; } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
if (!Array.isArray(manifest?.entries)) return { kind: 'unknown', reason: `run ${run}'s ${PLAN_ARTIFACT} manifest.json is unreadable` };
const planned = manifest.entries.filter(e => e?.status === 'planned').length;
return planned === 0 ? { kind: 'hollow', reason: `run ${run} planned no cases (an empty plan manifest; concluded ${view.conclusion ?? 'unknown'})` } : { kind: 'live' };
}
/** The plan manifest every evals.yml run that planned anything uploads (plan-slices); a superseded screen never reaches that job. */
const PLAN_ARTIFACT = 'paid-plan';
/** The named check run on a commit (the newest when several); null when absent or unreadable. */
export function checkRun(ctx: QueueContext, sha: string, name: string): CheckInfo | null {
const r = json<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string | null }> }>(
+13 -5
View File
@@ -16,7 +16,7 @@ import { EXIT } from './headless-artifacts';
import { ready, type QueueContext } from './ship-queue';
import { classifyTrainHead } from './version-gate-train';
import { git } from './ship-queue-gate';
import { execFor, repoSlug } from './ship-queue-train-github';
import { execFor, hollowScreen, repoSlug } from './ship-queue-train-github';
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
import { outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
@@ -85,10 +85,12 @@ async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string,
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} ready --pr ${o.pr} --screen`, 'none');
const exec = execFor(env.ctx);
const existing = findDuplicateRun(listDispatchRuns(dispatch.workflow, exec, slug) ?? [], dedupeKey('pr', mergeSha, baseSha, scope));
if (existing) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
const plan = existing ? hollowScreen(env.ctx, slug, existing.databaseId) : null;
const hollow = plan?.kind === 'hollow' ? plan.reason : null;
if (existing && !hollow) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
const found = dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null;
if (!found) return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${dispatch.workflow} run named ${nonce} appeared after the screen dispatch` }, [], `${Q} ready --pr ${o.pr} --screen${o.broad ? ' --broad' : ''} (adopts the run by its key once it appears)`, 'none');
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}` };
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}${hollow ? ` (not following ${hollow}: a screen that planned nothing is not a screen)` : ''}` };
}
export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<TrainOut> {
@@ -113,8 +115,14 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
let choice: ScreenChoice = { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null };
if (o.screen === 'screen') {
const ls = git(ctx.repoRoot, ['ls-remote', env.remote, `refs/pull/${o.pr}/merge`]);
const mergeSha = ls.ok ? ls.out.split(/\s/)[0] ?? '' : '';
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_NOT_READY', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, `merge ${ctx.base} into the branch, push, then ${Q} ready --pr ${o.pr} --screen`, 'none');
if (!ls.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not list ${env.remote}'s refs: ${(ls.err || ls.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git ls-remote failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
const mergeSha = ls.out.split(/\s/)[0] ?? '';
const again = `resolve the conflict with ${ctx.base} (or wait for GitHub to recompute refs/pull/${o.pr}/merge), push, then ${Q} ready --pr ${o.pr} --screen; a screen dispatched on a stale merge ref plans no cases`;
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, again, 'none');
const fetched = git(ctx.repoRoot, ['fetch', '-q', env.remote, `refs/pull/${o.pr}/merge`]);
if (!fetched.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not fetch refs/pull/${o.pr}/merge from ${env.remote}: ${(fetched.err || fetched.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git fetch failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
const mergeParent = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${mergeSha}^2`]).out;
if (mergeParent !== head) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote}'s refs/pull/${o.pr}/merge (${mergeSha.slice(0, 12)}) merges ${mergeParent ? mergeParent.slice(0, 12) : 'no PR head'}, not the PR head ${head.slice(0, 12)}: GitHub has not recomputed it, which is what a conflicting PR looks like (CHANGELOG-only conflicts included)` }, lines, again, 'none');
const d = await dispatchScreen(env, o, mergeSha, baseSha);
if ('exit' in d) return { ...d, lines: [...lines, ...d.lines] };
choice = d.choice;
+7 -2
View File
@@ -17,7 +17,8 @@ import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, makeNonce } from '../scripts/eval-bg';
import type { QueueContext } from './ship-queue';
import { git } from './ship-queue-gate';
import { checkRun, execFor, repoSlug } from './ship-queue-train-github';
import { bindReceipt } from './ship-receipt';
import { checkRun, execFor, prInfo, repoSlug } from './ship-queue-train-github';
import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record';
import {
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut,
@@ -132,7 +133,11 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
const stampOnly = pinned.same ? pinned : mergedIn(env, t, i, head, main);
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${stampOnly.detail}; against the pin it ${pinned.detail})`, main, lines);
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
const info = prInfo(ctx, o.pr);
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
const shipped = bindReceipt(info.body, o.pr, [t.slots[i]!.head, head]);
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [...lines, shipped.line], `#${o.pr}'s lane runs /ship and READYs again; then ${Q} train build --prs <pins>`, run);
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `${shipped.line} (binds ${shipped.head === t.slots[i]!.head ? 'the pinned head' : 'the current head'})`,
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
}
+14 -3
View File
@@ -21,8 +21,9 @@ import type { ResultStatus } from './headless-artifacts';
import { versionAtRef, versionFiles } from './restamp';
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
import { loadBasePolicy, type QueueContext } from './ship-queue';
import { bindReceipt } from './ship-receipt';
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
import { asRun, execFor, hollowScreen, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
import {
TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile,
type AttemptPurpose, type AttemptRecord, type TrainRecord, type TrainSlot,
@@ -284,6 +285,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, ...specs]);
if (!f.ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `git fetch of the PR heads failed: ${f.err.slice(0, 160)}` }, [], 'none', 'none');
const slots: TrainSlot[] = [];
const lines: string[] = [];
let slug: string | null | undefined;
for (const p of pins) {
const info = prInfo(ctx, p.pr);
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, [], 'none', 'none');
@@ -293,11 +296,18 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
}
const shipped = bindReceipt(info.body, p.pr, [info.headRefOid]);
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
let screen = ready.screen;
if (screen.choice === 'screen' && screen.run && slug === undefined) slug = repoSlug(ctx);
const plan = screen.choice === 'screen' && screen.run ? (slug ? hollowScreen(ctx, slug, screen.run) : { kind: 'unknown' as const, reason: 'the repository slug is unreadable' }) : null;
if (plan?.kind === 'hollow') { lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; }
else if (plan?.kind === 'unknown') lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as screened, unverified`);
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen });
}
return { slots, lines: [] };
return { slots, lines };
}
/** Start a new train from --prs, or find the live train with the same pins and base. */
@@ -315,6 +325,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
if ('exit' in admitted) return admitted;
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
const lines = [
...admitted.lines,
`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}`})`] : []),
];
+38 -5
View File
@@ -5,8 +5,9 @@
* so every step ends in a line a coordinator can act on:
*
* ready READY #<pr> <head> STAMP_VERSION=<X|unstamped> plus head, base,
* CI at that exact head, the PREGATE line and the gate state;
* refused when the tree is not the pushed PR head.
* CI at that exact head, the PREGATE line, the gate state and the
* /ship receipt; refused when the tree is not the pushed PR head
* or the PR body carries no receipt for it (QUEUE_RECEIPT_*).
* stamp preflight (gated head equals HEAD modulo stamps, base, policy,
* gate record under full_e2e), restamp, commit, tree receipt,
* each stage journaled; QUEUE_STAMP: <pr> <version> head= gated=
@@ -23,7 +24,7 @@ import * as path from 'node:path';
import { readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
import type { ResultCodeName } from './result-codes';
import { extractReceipt, receiptSummaryLine } from './ship-receipt';
import { bindReceipt, extractReceipt } from './ship-receipt';
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
import {
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
@@ -59,6 +60,37 @@ function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, cur
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
}
/**
* The heads a READY accepts a /ship receipt for: HEAD, plus the receipt's own
* head when HEAD is that commit with only stamp files changed on top (the
* slot's stamp commit, docs/ship-policy.md#ship-before-ready), or, from slot
* 2 on, the stamp over a merge of the base into it: /ship ran on the pin, the
* stamp changes nothing it checked, and verify --train binds the same pair
* against the train's own prefix. A merge with no stamp on top is a lane's
* own push (rule 14), so it reruns /ship like any other head. Any other
* receipt head stays QUEUE_RECEIPT_STALE.
*/
function acceptedReceiptHeads(ctx: QueueContext, policy: LoadedPolicy, head: string, body: string): string[] {
const ex = extractReceipt(body);
const r = ex.receipt;
if (!r || ex.errors.length || typeof r.head !== 'string' || !/^[0-9a-f]{12,64}$/i.test(r.head) || head.startsWith(r.head)) return [head];
const root = ctx.repoRoot;
const full = git(root, ['rev-parse', '--verify', '--quiet', `${r.head}^{commit}`]);
if (!full.ok || !git(root, ['merge-base', '--is-ancestor', full.out, head]).ok) return [head];
const stampOnly = (from: string, to: string) => { const t = from === to ? null : treeReceipt(ctx, policy, from, to); return !t || (!('message' in t) && t.tree === 'same-modulo-stamps'); };
if (stampOnly(full.out, head)) return [full.out, head];
const baseSha = git(root, ['rev-parse', `origin/${ctx.base}`]).out;
const onBase = (sha: string) => !!sha && git(root, ['merge-base', '--is-ancestor', sha, baseSha]).ok;
const merge = git(root, ['rev-parse', '--verify', '--quiet', `${head}^1`]).out;
const stamp = merge && merge !== head ? treeReceipt(ctx, policy, merge, head) : null;
if (!stamp || 'message' in stamp || stamp.tree !== 'same-modulo-stamps' || stamp.files.length === 0) return [head];
const parents = git(root, ['rev-list', '--parents', '-n', '1', merge]).out.split(' ').slice(1);
if (parents.length !== 2) return [head];
const pinSide = onBase(parents[0]!) ? parents[1]! : onBase(parents[1]!) ? parents[0]! : '';
if (pinSide && git(root, ['merge-base', '--is-ancestor', full.out, pinSide]).ok && stampOnly(full.out, pinSide)) return [full.out, head];
return [head];
}
const CI_PAGE = 100;
const CI_MAX_PAGES = 10;
@@ -141,7 +173,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`;
} else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`;
}
const receipt = extractReceipt(info.body ?? '');
const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const lines = [
`READY #${o.pr} ${head} STAMP_VERSION=${version}`,
@@ -152,13 +184,14 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
`ci: ${ci.line}`,
pregateLine,
gateLine(policy, attempt),
receipt.found && receipt.receipt && !receipt.errors.length ? receiptSummaryLine(receipt.receipt) : `SHIP_RECEIPT: ${receipt.found ? 'invalid' : 'none'} in the PR body`,
receipt.line,
];
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
if (gate) lines.push(gate.line);
if (!ci.green) return fail({ code: 'QUEUE_NOT_READY', message: `CI is not green at ${head.slice(0, 12)}: ${ci.line}` }, lines, run);
if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run);
if (gate && !gate.ok) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no passing gate is bound to ${head.slice(0, 12)}: ${gate.line}` }, lines, run);
if (!receipt.ok) return fail({ code: receipt.code, message: receipt.message }, lines, run);
return { ok: true, lines, run };
}
+37 -1
View File
@@ -49,6 +49,39 @@ export function extractReceipt(body: string): { found: boolean; receipt?: ShipRe
}
}
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE';
export type ReceiptBinding =
| { ok: true; receipt: ShipReceipt; head: string; line: string }
| { ok: false; code: ReceiptBindingCode; message: string; line: string };
/**
* The /ship receipt a PR body must carry before READY, train build or a train
* merge (CREW rule 22; docs/ship-policy.md#ship-before-ready): a valid block
* whose `head` is one of the accepted heads. `accepted` is the head being
* READY'd, or the slot's pinned head beside the current head when the current
* head is the pin's stamp commit (the stamp changes only stamp files, so the
* receipt written for the pin stays the binding one). `line` is the
* informational `SHIP_RECEIPT:` line the READY block prints either way.
*/
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
const ex = extractReceipt(body);
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
if (!ex.receipt || ex.errors.length) {
const why = ex.errors[0] ? oneLine(`${ex.errors[0].message} at ${ex.errors[0].path}`).slice(0, 200) : 'invalid';
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
}
const r = ex.receipt;
const line = receiptSummaryLine(r);
const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head)));
if (!head) {
const want = accepted.map(h => h.slice(0, 12)).join(' or ');
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${oneLine(r.head.slice(0, 12))}, not the head ${want} being merged`, line };
}
if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line };
return { ok: true, receipt: r, head, line };
}
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows. */
export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
const block = renderReceiptBlock(receipt);
@@ -70,6 +103,9 @@ export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt[
return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `)));
}
/** Receipt fields come from the PR body, so anything printed from one stays on its own line, control characters included. */
const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f\u0080-\u009f\u2028\u2029]+/g, ' ');
/** The first lines of the completion message to the coordinator: identifiers only. */
export function receiptSummaryLine(r: ShipReceipt): string {
const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`];
@@ -80,7 +116,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
return `SHIP_RECEIPT: ${parts.join(' ')}`;
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
}
// ---------------------------------------------------------------------------
+70 -2
View File
@@ -8,6 +8,9 @@
* stamped one to the version the restamp allocates (base + the policy bump).
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseChangelog } from './changelog-check';
import { matchesAny } from './ship-policy';
@@ -30,6 +33,62 @@ export function bumpVersion(version: string, level: string): string | null {
return v.map((n, i) => (i < at ? n : i === at ? n + 1 : 0)).join('.');
}
/**
* An unstamped head may regenerate a release output (a generator change, for
* example) as long as what it committed is exactly what the policy's
* release_tool produces at its merge base's VERSION (the head's own, since
* VERSION is unchanged on this path; the base tip may have stamped since):
* the slot's restamp regenerates it anyway. Runs the head's tool in a
* throwaway worktree with that VERSION and compares each changed output byte
* for byte, reading the tool's output from a file (first 4 KB) so a lingering
* child cannot hold the gate open, and killing a tool that outlives the limit
* with SIGKILL so one that ignores TERM cannot either. A stamp (new
* VERSION inside the output) differs, so it still classifies as stamped, and
* so does an output the tool never writes (it is removed before the run).
* The tool is head code: it runs with a bare environment (no tokens), no
* node_modules link, and a short timeout, and its outputs are read only from
* the staging worktree.
*/
const TOOL_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'BUN_INSTALL', 'SystemRoot', 'TEMP', 'TMP'];
export function regeneratedAtBase(o: { repoRoot: string; head: string; baseVersion: string; tool: string; outputs: string[]; timeoutMs?: number }): { kind: 'same' } | { kind: 'differs'; files: string[] } | { kind: 'error'; reason: string } {
const timeoutMs = o.timeoutMs ?? 120_000;
if (!o.baseVersion.trim()) return { kind: 'error', reason: 'the merge base has no VERSION to regenerate the release outputs at' };
let staging: string;
try { staging = fs.mkdtempSync(path.join(os.tmpdir(), 'version-gate-regen-')); } catch (e) { return { kind: 'error', reason: `cannot create a staging directory to regenerate the release outputs: ${e instanceof Error ? e.message : String(e)}` }; }
try {
const add = spawnSync('git', ['worktree', 'add', '--detach', '-q', staging, o.head], { cwd: o.repoRoot, encoding: 'utf8', timeout: 30_000 });
if (add.status !== 0) return { kind: 'error', reason: `cannot stage ${o.head.slice(0, 12)} to regenerate its release outputs: ${(add.stderr || '').trim().slice(0, 200)}` };
fs.rmSync(path.join(staging, 'VERSION'), { force: true });
fs.writeFileSync(path.join(staging, 'VERSION'), o.baseVersion);
for (const f of o.outputs) fs.rmSync(path.join(staging, f), { force: true });
const env: NodeJS.ProcessEnv = { GSTACK_RESTAMP_VERSION: o.baseVersion.trim(), GSTACK_RESTAMP_STAGING: '1' };
for (const key of TOOL_ENV_KEYS) if (process.env[key] !== undefined) env[key] = process.env[key];
const logPath = path.join(os.tmpdir(), path.basename(staging) + '.log');
const log = fs.openSync(logPath, 'w');
let run: ReturnType<typeof spawnSync>;
try { run = spawnSync('sh', ['-c', o.tool], { cwd: staging, stdio: ['ignore', log, log], timeout: timeoutMs, killSignal: 'SIGKILL', env }); } finally { fs.closeSync(log); }
const output = (() => {
try { const fd = fs.openSync(logPath, 'r'); try { const buf = Buffer.alloc(4096); return buf.subarray(0, fs.readSync(fd, buf)).toString('utf8'); } finally { fs.closeSync(fd); } } catch { return ''; } finally { fs.rmSync(logPath, { force: true }); }
})();
if (run.status !== 0) {
const timedOut = run.signal !== null || (run.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT';
const how = run.status !== null ? `exited ${run.status}` : timedOut ? `was killed at the ${Math.round(timeoutMs / 1000)}s limit` : (run.error?.message ?? 'did not exit');
return { kind: 'error', reason: `release_tool \`${o.tool}\` ${how} at ${o.head.slice(0, 12)} with its merge base's VERSION, so its release outputs cannot be told from a stamp: ${output.trim().slice(0, 200)}` };
}
const files = o.outputs.filter(f => {
const committed = spawnSync('git', ['show', `${o.head}:${f}`], { cwd: o.repoRoot, timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
let generated: Buffer | null = null;
try { generated = fs.readFileSync(path.join(staging, f)); } catch { generated = null; }
return committed.status !== 0 || generated === null || !committed.stdout.equals(generated);
});
return files.length ? { kind: 'differs', files } : { kind: 'same' };
} finally {
spawnSync('git', ['worktree', 'remove', '--force', staging], { cwd: o.repoRoot, timeout: 30_000 });
fs.rmSync(staging, { recursive: true, force: true });
spawnSync('git', ['worktree', 'prune'], { cwd: o.repoRoot, timeout: 30_000 });
}
}
export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: string }): TrainHead {
const base = `origin/${o.baseRef.replace(/^origin\//, '')}`;
const show = (rev: string, file: string) => git(o.repoRoot, ['show', `${rev}:${file}`]);
@@ -42,10 +101,19 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head:
const npmVersion = (rev: string) => { try { return JSON.parse(show(rev, 'package.json') ?? '{}').version ?? null; } catch { return null; } };
const changed = (git(o.repoRoot, ['diff', '--name-only', mergeBase, o.head]) ?? '').split('\n').filter(Boolean);
const releaseOutputs: string[] = Array.isArray(policy.release_outputs) ? policy.release_outputs : [];
const touched = changed.filter(f => f === 'VERSION' || matchesAny(releaseOutputs, f));
// touched: what marks the head as stamped (the labels only feed the length check below)
const touched: string[] = changed.filter(f => f === 'VERSION');
if (npmVersion(mergeBase) !== npmVersion(o.head)) touched.push('package.json version');
const outputs = changed.filter(f => matchesAny(releaseOutputs, f));
const baseVersion = show(base, 'VERSION') ?? '';
if (!touched.length && outputs.length && typeof policy.release_tool === 'string') {
// the head's VERSION is its merge base's (unchanged on this path); the base tip may have stamped since
const regen = regeneratedAtBase({ repoRoot: o.repoRoot, head: o.head, baseVersion: show(mergeBase, 'VERSION') ?? '', tool: policy.release_tool, outputs });
if (regen.kind === 'error') return { kind: 'invalid', reason: regen.reason };
if (regen.kind === 'differs') touched.push(...regen.files);
} else touched.push(...outputs);
if (touched.length) {
const expected = bumpVersion(show(base, 'VERSION') ?? '', policy.bump ?? 'patch');
const expected = bumpVersion(baseVersion, policy.bump ?? 'patch');
return expected ? { kind: 'stamped', expected } : { kind: 'invalid', reason: `${base}:VERSION is not a 4-part version` };
}
+2 -2
View File
@@ -87,9 +87,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+2 -2
View File
@@ -80,9 +80,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -659,8 +659,8 @@ straight into a structured review. Map the design-doc mode to the recommended op
(default `/plan-eng-review` when ambiguous — it has the broadest real-world use and the
strongest retention).
**If `PROACTIVE` is `false` OR `CONDUCTOR_SESSION: true`:** do NOT auto-launch. Recommend
in one line and stop, letting the user invoke:
**If `PROACTIVE` is `false` or `off`, OR `CONDUCTOR_SESSION: true`:** do NOT auto-launch.
Recommend in one line and stop; the user invokes:
- EXPANSION / ambitious → "Next: `/plan-ceo-review` to pressure-test scope and find the 10-star product."
- well-scoped → "Next: `/plan-eng-review` to lock architecture, tests, and edge cases."
- visual/UX-heavy → "Next: `/plan-design-review` for a visual/UX pass."
@@ -500,8 +500,8 @@ straight into a structured review. Map the design-doc mode to the recommended op
(default `/plan-eng-review` when ambiguous — it has the broadest real-world use and the
strongest retention).
**If `PROACTIVE` is `false` OR `CONDUCTOR_SESSION: true`:** do NOT auto-launch. Recommend
in one line and stop, letting the user invoke:
**If `PROACTIVE` is `false` or `off`, OR `CONDUCTOR_SESSION: true`:** do NOT auto-launch.
Recommend in one line and stop; the user invokes:
- EXPANSION / ambitious → "Next: `/plan-ceo-review` to pressure-test scope and find the 10-star product."
- well-scoped → "Next: `/plan-eng-review` to lock architecture, tests, and edge cases."
- visual/UX-heavy → "Next: `/plan-design-review` for a visual/UX pass."
+2 -2
View File
@@ -49,9 +49,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.235",
"version": "1.91.241",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+2 -2
View File
@@ -49,9 +49,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -82,9 +82,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -84,9 +84,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
Run the invoked skill within the host's plan-mode limits. **Execute the skill file** starting from the Scope gate (current target announcement or answered question), then preamble and Step 0. Skill questions are valid; skip only questions the skill resolves. Any AskUserQuestion variant satisfies end-of-turn; use AskUserQuestion Format's tool resolution. If unavailable/failed: `headless` → BLOCKED; `interactive` → prose fallback (satisfies end-of-turn). At STOP, stop: no continuation or ExitPlanMode. Run "PLAN MODE EXCEPTION — ALWAYS RUN" commands only where the host permits. ExitPlanMode only after skill completion or if the user cancels the skill or tells you to leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -96,9 +96,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
Run the invoked skill within the host's plan-mode limits. **Execute the skill file** starting from the Scope gate, then follow its Startup sequence. Skill questions are valid; skip only questions the skill resolves. Any AskUserQuestion variant satisfies end-of-turn; use AskUserQuestion Format's tool resolution. If unavailable/failed: `headless` → BLOCKED; `interactive` → prose fallback (satisfies end-of-turn). At STOP, stop: no continuation or ExitPlanMode. Run "PLAN MODE EXCEPTION — ALWAYS RUN" commands only where the host permits. ExitPlanMode only after skill completion or if the user cancels the skill or tells you to leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -56,9 +56,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -50,9 +50,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -53,9 +53,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -70,9 +70,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -55,9 +55,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -50,9 +50,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+1 -1
View File
@@ -41,7 +41,7 @@ const prVersionEnv = (process.env.PR_VERSION ?? "").trim();
if (process.env.BASE_REF && process.env.HEAD_SHA) {
const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA });
if (train.kind === "unstamped") {
console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION, package.json and the release outputs match its merge base, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`);
console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the merge base's VERSION, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`);
process.exit(0);
}
if (train.kind === "invalid") {
+2 -2
View File
@@ -548,8 +548,8 @@ export const QUESTIONS = {
skill: 'preamble',
category: 'approval',
door_type: 'two-way',
options: ['on', 'off'],
description: "Let gstack proactively suggest skills based on conversation context?",
options: ['core', 'all', 'off'],
description: "Let gstack proactively suggest skills based on conversation context? core (the four core skills, default) / all / off",
},
'preamble-routing-injection': {
id: 'preamble-routing-injection',
@@ -8,7 +8,7 @@ import type { TemplateContext } from '../types';
* the fence prose scopes and the model follows.
*/
export function generateUpgradeCheck(ctx: TemplateContext): string {
return `If \`PROACTIVE\` is \`false\`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
return `If \`PROACTIVE\` is \`off\`: suggest no skills unasked, nor ask. \`core\`: only /office-hours, /review, /qa, /ship. \`all\`: any. Skills the user names or this one invokes run.
If \`SKILL_PREFIX\` is \`"true"\`, suggest/invoke \`/gstack-*\` names. Disk paths stay \`${ctx.paths.skillRoot}/[skill-name]/SKILL.md\`.`;
If \`SKILL_PREFIX\` is \`true\` use \`/gstack-*\` names; paths stay \`${ctx.paths.skillRoot}/[skill-name]/SKILL.md\`.`;
}
+2 -2
View File
@@ -48,9 +48,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## Artifacts Sync (skill start)
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -54,9 +54,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+26 -14
View File
@@ -55,9 +55,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
@@ -1154,7 +1154,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
The pr-body section prepares the title from that result, then publishes;
`--no-pr` or an automation lane takes the handoff section.
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
`pr-create`, no push); else handoff.
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -1172,13 +1174,12 @@ JSON validation, storage and sync and takes **no path argument**.
```
Substitute from earlier steps:
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
The shell supplies the branch. Run it without confirmation.
@@ -1194,10 +1195,7 @@ GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
echo ""
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
echo "auto-decides your never-ask preferences."
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
fi
```
@@ -1206,6 +1204,20 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
---
## Step 22: Terminal line (every run)
The last line printed is the result line a parent greps (docs/unattended.md):
```bash
~/.claude/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
```
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
---
## Section self-check (before you finish)
List the applicable Section index entries and confirm each Read. If you worked from
+24 -12
View File
@@ -624,7 +624,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
The pr-body section prepares the title from that result, then publishes;
`--no-pr` or an automation lane takes the handoff section.
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
`pr-create`, no push); else handoff.
{{SECTION:pr-body}}
@@ -640,13 +642,12 @@ JSON validation, storage and sync and takes **no path argument**.
```
Substitute from earlier steps:
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
The shell supplies the branch. Run it without confirmation.
@@ -662,10 +663,7 @@ GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
echo ""
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
echo "auto-decides your never-ask preferences."
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
fi
```
@@ -674,6 +672,20 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
---
## Step 22: Terminal line (every run)
The last line printed is the result line a parent greps (docs/unattended.md):
```bash
~/.claude/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
```
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
---
## Section self-check (before you finish)
List the applicable Section index entries and confirm each Read. If you worked from
+2 -1
View File
@@ -20,7 +20,8 @@ automation lane, an unattended parent said so). Nothing here publishes.
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
`issues:` its lines, unpromoted candidates and lookup failures.
Full thread ids (`jam_…`) cross-thread.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied, then
Step 22 with `--body .gstack/tmp/ship-receipt.md`.
### Multi-lane runs
+2 -1
View File
@@ -18,7 +18,8 @@ automation lane, an unattended parent said so). Nothing here publishes.
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
`issues:` its lines, unpromoted candidates and lookup failures.
Full thread ids (`jam_…`) cross-thread.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied, then
Step 22 with `--body .gstack/tmp/ship-receipt.md`.
### Multi-lane runs
+4 -1
View File
@@ -206,7 +206,10 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead).
New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body.
Its `SHIP_RECEIPT:` line opens the completion message. `RECEIPT_INVALID` names the bad
Its `SHIP_RECEIPT:` line opens the completion message. The posted receipt is what
`gstack-ship-queue ready`, `train build` and `verify --train` require (`QUEUE_RECEIPT_MISSING`,
`QUEUE_RECEIPT_STALE`, no flag around them), so /ship runs before every READY and after any
push. `RECEIPT_INVALID` names the bad
field: fix that input file and rerun; nothing was written. `ISSUE_LINKS_MISSING`: add
the named line under `## Issues` and rerun. `PREGATE_STALE` means rerun
`gstack-pregate` on the stamped tree; `PREGATE_REMOTE_UNCLEARED` needs
+4 -1
View File
@@ -189,7 +189,10 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead).
New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body.
Its `SHIP_RECEIPT:` line opens the completion message. `RECEIPT_INVALID` names the bad
Its `SHIP_RECEIPT:` line opens the completion message. The posted receipt is what
`gstack-ship-queue ready`, `train build` and `verify --train` require (`QUEUE_RECEIPT_MISSING`,
`QUEUE_RECEIPT_STALE`, no flag around them), so /ship runs before every READY and after any
push. `RECEIPT_INVALID` names the bad
field: fix that input file and rerun; nothing was written. `ISSUE_LINKS_MISSING`: add
the named line under `## Issues` and rerun. `PREGATE_STALE` means rerun
`gstack-pregate` on the stamped tree; `PREGATE_REMOTE_UNCLEARED` needs
+2 -2
View File
@@ -50,9 +50,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -53,9 +53,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -53,9 +53,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+2 -2
View File
@@ -52,9 +52,9 @@ Host and system plan-mode restrictions and the user's current scope take precede
If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `false`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
If `PROACTIVE` is `off`: suggest no skills unasked, nor ask. `core`: only /office-hours, /review, /qa, /ship. `all`: any. Skills the user names or this one invokes run.
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
## AskUserQuestion Format
+13
View File
@@ -15,6 +15,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { readFileSync } from 'fs';
import * as path from 'path';
import { isErrorResponse, directiveFor } from '../hosts/claude/hooks/auq-error-fallback-hook.ts';
import { SPAWNED_ESCAPE_SENTENCE } from '../hosts/claude/hooks/spawned-directive.ts';
@@ -85,6 +86,18 @@ describe('directiveFor — per-session-kind instruction', () => {
expect(d).not.toMatch(/BLOCKED/);
});
test('unattended directive names the same GSTACK_RESULT writers as docs/unattended.md and gstack-skill-start (GSTA-91)', () => {
const d = directiveFor('unattended');
expect(d).toMatch(/Only \/autoplan and \/eval-plan print `GSTACK_RESULT: skill=<name> status=gate_pending run=<dir>`/);
expect(d).toMatch(/\/ship prints its own line at Step 22/);
expect(d).toMatch(/every other skill prints no GSTACK_RESULT line/);
const doc = readFileSync(path.join(import.meta.dir, '..', 'docs', 'unattended.md'), 'utf8');
expect(doc).toContain('**`/autoplan`**');
expect(doc).toContain('**`/ship`**');
expect(doc).toMatch(/`\/eval-plan` asks for a `gate_pending` line/);
expect(doc).toMatch(/\*\*Every other workflow skill\*\*[\s\S]*prints the line/);
});
test('spawned directive carries a self-contained destructive carve-out (#2733 review)', () => {
// The "Spawned session block" it defers to exists only when a gstack
// preamble ran; an AUQ error outside a skill still needs the exception.
+131 -1
View File
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
import { bumpVersion } from '../lib/version-gate-train';
import { bumpVersion, regeneratedAtBase } from '../lib/version-gate-train';
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts');
@@ -168,6 +168,136 @@ describe('version gate: unstamped heads under a train policy', () => {
}
});
describe('a release output regenerated at the base VERSION is not a stamp (GSTA-96: an unstamped PR could not reword the agents digest)', () => {
const gen = (r: FixtureRepo, body: string) => write(r, 'gen.js', `const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\n${body}\\n');\n`);
test('a generator change with its regenerated output at the base VERSION passes as unstamped', () => {
const r = fixture();
branch(r);
gen(r, 'reworded body');
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
write(r, 'CHANGELOG.md', withUnreleased(r));
const res = run(r, commit(r, 'reword the digest'));
expect(res.code).toBe(0);
expect(res.out).toContain('is unstamped under the base');
expect(r.git('worktree', 'list').trim().split('\n')).toHaveLength(1);
});
test('a regenerated output stays unstamped after main stamps again (the regeneration is at the merge base VERSION, not the base tip)', () => {
const r = fixture();
branch(r);
gen(r, 'reworded body');
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
write(r, 'CHANGELOG.md', withUnreleased(r));
const head = commit(r, 'reword the digest');
r.git('checkout', '-q', 'main');
write(r, 'VERSION', '1.2.4.0\n');
write(r, 'generated/digest.md', '# digest v1.2.4.0\nbody\n');
write(r, 'CHANGELOG.md', read(r, 'CHANGELOG.md').replace('# Changelog\n\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-02\n\n- another release\n\n'));
commit(r, 'release 1.2.4.0');
r.git('push', '-q', 'origin', 'main');
r.git('fetch', '-q', 'origin');
const res = run(r, head, '1.2.3.0');
expect(res.code).toBe(0);
expect(res.out).toContain('is unstamped under the base');
});
test('a tool whose background child outlives it does not hold the gate open', () => {
const r = fixture();
branch(r);
write(r, 'gen.js', "const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\nreworded body\\n');require('child_process').spawn('sleep',['20'],{stdio:'inherit'}).unref();\n");
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
write(r, 'CHANGELOG.md', withUnreleased(r));
const head = commit(r, 'tool with a lingering child');
const started = Date.now();
const res = run(r, head);
expect(Date.now() - started).toBeLessThan(15_000);
expect(res.code).toBe(0);
expect(res.out).toContain('is unstamped under the base');
});
test('a tool that ignores TERM is killed at the limit and reported, never waited for', () => {
const r = fixture();
branch(r);
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
const head = commit(r, 'output without a working tool');
const started = Date.now();
const res = regeneratedAtBase({ repoRoot: r.work, head, baseVersion: '1.2.3.0\n', tool: "trap '' TERM; echo stubborn; sleep 30", outputs: ['generated/digest.md'], timeoutMs: 2_000 });
expect(Date.now() - started).toBeLessThan(10_000);
expect(res.kind).toBe('error');
expect((res as { reason: string }).reason).toContain('was killed at the 2s limit');
expect((res as { reason: string }).reason).toContain('stubborn');
expect(r.git('worktree', 'list').trim().split('\n')).toHaveLength(1);
});
test('the tool runs without the gate\'s tokens, and an output the tool never writes still counts as a stamp', () => {
const r = fixture();
branch(r);
write(r, 'gen.js', "const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\nreworded body\\n'+(process.env.GH_TOKEN??''));\n");
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
write(r, 'CHANGELOG.md', withUnreleased(r));
const head = commit(r, 'tool that would echo a token');
const next = path.join(r.root, 'next.json');
fs.writeFileSync(next, JSON.stringify({ version: '1.2.4.0', base_version: '1.2.3.0', claimed: [] }));
const leak = Bun.spawnSync([process.execPath, 'run', SCRIPT, next, '3320'], {
cwd: r.work, env: { ...process.env, ...r.env, PR_VERSION: '1.2.3.0', BASE_REF: 'main', HEAD_SHA: head, GH_TOKEN: 'ghs_secret' }, timeout: 30_000, stdout: 'pipe', stderr: 'pipe',
});
expect(leak.exitCode).toBe(0);
expect(leak.stdout.toString()).toContain('is unstamped under the base');
write(r, 'generated/extra.md', 'not generated\n');
const res = run(r, commit(r, 'second output by hand'));
expect(res.code).toBe(1);
expect(res.out).toContain('must carry v1.2.4.0');
});
test('an output that is not what the generator produces at the base VERSION still counts as a stamp', () => {
for (const edit of [
(r: FixtureRepo) => write(r, 'generated/digest.md', '# digest v1.2.3.0\nhand-edited body\n'),
(r: FixtureRepo) => { gen(r, 'reworded body'); write(r, 'generated/digest.md', '# digest v1.2.4.0\nreworded body\n'); },
]) {
const r = fixture();
branch(r);
write(r, 'CHANGELOG.md', withUnreleased(r));
edit(r);
const res = run(r, commit(r, 'not a regeneration'));
expect(res.code).toBe(1);
expect(res.out).toContain('must carry v1.2.4.0');
}
});
test('a VERSION bump without the regenerated output is a stamp, never an unstamped head', () => {
const r = fixture();
branch(r);
write(r, 'VERSION', '1.2.4.0\n');
write(r, 'CHANGELOG.md', withUnreleased(r));
const res = run(r, commit(r, 'bump without digest'), '1.2.3.0');
expect(res.code).toBe(1);
expect(res.out).toContain('must carry v1.2.4.0');
expect(res.out).not.toContain('is unstamped under the base');
});
test('a release tool that fails at the head is an error, not a pass', () => {
const r = fixture();
branch(r);
write(r, 'gen.js', 'process.stderr.write("gen broke\\n");process.exit(3);\n');
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
write(r, 'CHANGELOG.md', withUnreleased(r));
const res = run(r, commit(r, 'broken generator'));
expect(res.code).toBe(1);
expect(res.out).toContain('release_tool `node gen.js` exited 3');
expect(res.out).toContain('gen broke');
});
test('without a release_tool a changed output keeps counting as a stamp', () => {
const r = fixture({ ...BASE_POLICY, release_tool: null, train: TRAIN });
branch(r);
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
write(r, 'CHANGELOG.md', withUnreleased(r));
const res = run(r, commit(r, 'no tool'));
expect(res.code).toBe(1);
expect(res.out).toContain('must carry v1.2.4.0');
});
});
test('negative controls: CHANGELOG edits outside [Unreleased] fail an unstamped head', () => {
const cases: Array<(r: FixtureRepo) => string> = [
r => withUnreleased(r).replace('- older', '- older, rewritten'),
+168
View File
@@ -404,6 +404,174 @@ describe('gstack-evidence tests_ran — zero-run green is a third state, never m
}
});
test('a gstack-pregate --remote lane: the remote gate\'s tests= count (ran, skips included) lands in the receipt (captured output)', () => {
// test/fixtures/evidence/pregate-remote-failed.out is the stdout of
// `gstack-pregate --stage tests --remote` captured under gstack-evidence
// on 2026-10-26 (GSTA-102): the Ubicloud gate ran 9658 tests with 1
// failure, and the lane came back incomplete. Before, the receipt said
// tests_ran=unknown for every remote lane, so the ship receipt and the
// Review Readiness Dashboard never carried the real number.
const fixture = path.join(ROOT, 'test', 'fixtures', 'evidence', 'pregate-remote-failed.out');
expect(fs.readFileSync(fixture, 'utf-8')).toContain('PREGATE_REMOTE: tree=279e658e29bec511739dd00510828dc433fb94fa tests=9658 failed=1 spent=0.04');
const r = run(['run', '--label', 'tests', '--', `cat '${fixture}'; exit 1`]);
expect(r.status).toBe(1);
expect(records().pop()).toMatchObject({ exit: 1, tests_ran: 9658 });
expect(r.stderr).toContain('recorded label=tests exit=1 tests_ran=9658');
expect(run(['check', '--label', 'tests']).stdout).toContain('EVIDENCE: STALE label=tests exit=1 tests_ran=9658');
});
test('a cleared remote lane (captured output): the gate line and the receipt line carry the same count, and the lane is FRESH', () => {
// pregate-remote-cleared.out is the same lane's stdout+stderr on the next
// tree, after the fix: 9660 tests, 0 failed, the obligation cleared. It
// carries both the gate's PREGATE_REMOTE line and the "cleared by
// remote-gate" receipt, plus bun's own "Ran 23 tests" line indented under
// "lanes:", which must not be mistaken for a bun summary.
const fixture = path.join(ROOT, 'test', 'fixtures', 'evidence', 'pregate-remote-cleared.out');
const text = fs.readFileSync(fixture, 'utf-8');
expect(text).toContain('PREGATE_REMOTE: tree=46b75c23704fb4ae1f22677258c16b880ecd20c5 tests=9660 failed=0 spent=0.04');
expect(text).toContain('cleared by remote-gate tree=46b75c23704f tests=9660');
expect(text).toContain('lanes: Ran 23 tests across 1 file.');
expect(run(['run', '--label', 'tests', '--', `cat '${fixture}'`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9660);
expect(run(['check', '--label', 'tests']).stdout).toContain('EVIDENCE: FRESH label=tests exit=0 tests_ran=9660');
// A paid-only selection reports tests=0: that is ZERO-RUN, not a pass.
expect(run(['run', '--label', 'tests', '--', "printf ' lanes: PREGATE_REMOTE: tree=279e658e29bec511739dd00510828dc433fb94fa tests=0 failed=0 spent=0\\n'"]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
expect(run(['check', '--label', 'tests']).stdout).toContain('EVIDENCE: ZERO-RUN label=tests exit=0 tests_ran=0');
});
test('the cleared receipt line alone is parsed, the later of the two lines wins, and an echoed occurrence does not count', () => {
// renderTable prints the obligation's receipt line beside the lane row; it is
// parsed on its own (here in the `clear --receipt` form lib/pregate/index.ts
// builds, with receipt= and sha256=) so a table that carries the receipt
// without the row still records the count.
const receipt = 'requires-remote: pregate.json/remote_gate (remote) 362 test(s) — cleared by remote-gate tree=46b75c23704f tests=9660 receipt=/x/gate.out sha256=0123456789ab';
const gate = ' lanes: PREGATE_REMOTE: tree=46b75c23704fb4ae1f22677258c16b880ecd20c5 tests=9658 failed=0 spent=0.04';
const print = (...ls: string[]) => `printf '%s\\n' ${ls.map((l) => `'${l}'`).join(' ')}`;
expect(run(['run', '--label', 'tests', '--', print(receipt)]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9660);
expect(run(['run', '--label', 'tests', '--', print(gate, receipt)]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9660);
expect(run(['run', '--label', 'tests', '--', print(receipt, gate)]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9658);
// Only a line that is the lane row or the receipt counts: a log line that
// prefixes or quotes one is not a run (indentation is accepted: the row
// itself is indented under "lanes:").
expect(run(['run', '--label', 'tests', '--', print('expected PREGATE_REMOTE: tree=x tests=9999 failed=0', 'note: saw cleared by remote-gate tree=x tests=9999 in a log')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
});
test("gstack's shard runner (captured output): the FILES-FROM RESULT total, the per-shard PASS sum without it, and a forwarded bun line on stderr", () => {
// test/fixtures/evidence/shard-runner-files-from.out is the stream of the
// remote gate's `scripts/test-free-shards.ts --files-from` run on the
// Ubicloud VM for GSTA-102's tree d47be9b6 (275 files, 16 shards, 10138
// tests): 16 "[test:free] PASS — N tests" epilogues whose sum is the
// "FILES-FROM RESULT: … tests=10138" total, nested bun children's own
// "Ran N tests" lines among them. Before, every full-suite run through the
// shard runner (`bun run test:ubicloud`, GSTA-94 on #3424) recorded
// tests_ran=0: the quiet shards forward bun's "Ran N tests" line without
// its " N pass" counts, and the bun branch read that as zero.
const fixture = path.join(ROOT, 'test', 'fixtures', 'evidence', 'shard-runner-files-from.out');
const text = fs.readFileSync(fixture, 'utf-8');
expect(text).toContain('[test:free] FILES-FROM RESULT: files=275 tests=10138 failed=0 exit=0');
expect(text.match(/\[test:free\] PASS — (\d+) tests/g)).toHaveLength(16);
expect(run(['run', '--label', 'tests', '--', `cat '${fixture}'`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(10138);
// A full-suite run has no FILES-FROM RESULT line: the per-shard epilogues sum to the same total.
expect(run(['run', '--label', 'tests', '--', `grep -v 'FILES-FROM RESULT' '${fixture}'`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(10138);
// The live shape: epilogues on stdout, the forwarded "Ran N tests" line on stderr (which the
// tail appends after stdout). A bun terminal line with no counts block is not a bun summary.
expect(run(['run', '--label', 'tests', '--', "printf '[test:free] full suite: 1 files across 1 shard processes (duration-packed)\\n[test:free] PASS — 37 tests, 1 files, 38s. Full log: /x\\n'; printf 'Ran 37 tests across 1 file. [38.05s]\\n' >&2"]).status).toBe(0);
expect(records().pop().tests_ran).toBe(37);
// A real bun all-skip run still grades ZERO-RUN: its counts block is present.
expect(run(['run', '--label', 'tests', '--', "printf ' 0 pass\\n 2 skip\\n 0 fail\\nRan 2 tests across 1 file. [4.00ms]\\n'"]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
});
test('shard runner shapes: a forwarded count-less line never beats the epilogue sum, no bun block does once the runner spoke, a flaky-pass run is unknown, only the last run counts, and a count-less "Ran 0 tests" is ZERO-RUN', () => {
const start = '[test:free] full suite: 3 files across 2 shard processes (duration-packed)';
const pass = (n: number) => `[test:free] PASS — ${n} tests, 1 files, 1s. Full log: /x`;
const out = (...ls: string[]) => `printf '%s\\n' ${ls.map((l) => `'${l}'`).join(' ')}`;
const err = (...ls: string[]) => `${out(...ls)} >&2`;
// The quiet runner forwards " N fail" (never " N pass") and the terminal line on stderr.
expect(run(['run', '--label', 'tests', '--', `${out(start, pass(100), pass(37))}; ${err(' 1 fail', 'Ran 600 tests across 18 files. [1s]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// Once the runner spoke its sum stands over any bun counts block, wherever that block sits: the tail
// is stdout then stderr, so a bun block on stderr always sorts after the runner's stdout epilogues
// whether it ran before the suite (a focused file first) or after it (a trailing all-skip run).
// Value: protects=a 10k-test shard-runner run is never recorded as a focused file's count or ZERO-RUN
// because bun's stderr block out-positions it; fails_when=parseTestsRan lets a counted bun block win
// on position once the runner spoke; why_new=every earlier row put the counted block after the runner;
// seam=none; no_claim=a bun run that genuinely followed the suite is not counted separately.
expect(run(['run', '--label', 'tests', '--', `${out(start, pass(100), pass(37))}; ${err(' 0 pass', ' 2 skip', ' 0 fail', 'Ran 2 tests across 1 file. [4.00ms]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), pass(37), ' 0 pass', ' 2 skip', ' 0 fail', 'Ran 2 tests across 1 file. [4.00ms]')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', `${err(' 5 pass', ' 0 fail', 'Ran 5 tests across 1 file. [9.00ms]')}; ${out(start, pass(100), pass(37))}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', out(' 5 pass', ' 0 fail', 'Ran 5 tests across 1 file. [9.00ms]', start, pass(100), pass(37))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// A verdict line whose start line fell out of the tail: the runner spoke but the sum is incomplete, so
// unknown, never a forwarded shard block's count and never bun's ZERO-RUN for a run that was not bun's.
expect(run(['run', '--label', 'tests', '--', `${out(pass(100), pass(37))}; ${err(' 0 pass', ' 2 skip', ' 0 fail', 'Ran 2 tests across 1 file. [4.00ms]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
expect(run(['run', '--label', 'tests', '--', out('[test:free] FILES-FROM RESULT: files=1 tests=9 failed=0 exit=0')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
// A full-suite start line names its shard count: fewer PASS epilogues than that is a killed run, not a total.
const planned = '[test:free] full suite: 300 files across 3 shard processes (duration-packed), then 2 exclusive host-state file(s) serially';
expect(run(['run', '--label', 'tests', '--', out(planned, pass(100), pass(37), pass(5), pass(2))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(144);
expect(run(['run', '--label', 'tests', '--', out(planned, pass(100), pass(37), pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
expect(run(['run', '--label', 'tests', '--', out('[test:free] full suite: 300 files across 3 shard processes', pass(100), pass(37), pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(142);
// An empty shard prints no PASS epilogue, only its "0 files" line: it counts as a zero shard, not a missing one.
expect(run(['run', '--label', 'tests', '--', out('[test:free] full suite: 2 files across 3 shard processes', pass(100), '[test:free] shard 2/3: 0 files, 0s, pass', pass(37))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// A FAIL epilogue plus a FLAKY-PASS retry exits 0 but has lost the failed shard's count.
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), '[test:free] FAIL — 1 failing test(s) in 1 file(s), 0 crashed worker(s). Full log: /y', '[test:free] FLAKY-PASS — 1 file(s) failed once and passed on serial retry: a.test.ts', pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
// Two runs in one command: the last run's sum, not both.
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), start, pass(7), pass(8))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(15);
// A forwarded "Ran 0 tests" line with no counts block is still ZERO-RUN.
expect(run(['run', '--label', 'tests', '--', err('Ran 0 tests across 0 files. [1ms]')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
// A files-from run's total stands even when a shard failed; a shard with no count is unknown.
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), '[test:free] FAIL — 1 failing test(s) in 1 file(s), 0 crashed worker(s). Full log: /y', '[test:free] FILES-FROM RESULT: files=3 tests=137 failed=1 exit=1')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), '[test:free] PASS — ? tests, ? files, 1s. Full log: /z')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
});
test('shard runner degrade paths: no start line, a forwarded empty-file line, a look-alike epilogue, and a run that printed no epilogue', () => {
const start = '[test:free] full suite: 3 files across 2 shard processes (duration-packed)';
const pass = (n: number) => `[test:free] PASS — ${n} tests, 1 files, 1s. Full log: /x`;
const out = (...ls: string[]) => `printf '%s\\n' ${ls.map((l) => `'${l}'`).join(' ')}`;
const err = (...ls: string[]) => `${out(...ls)} >&2`;
// Without the runner's start line in the tail an epilogue still means the runner spoke, but the sum is
// incomplete: unknown, not bun's count (the block may be one forwarded shard's) and not a partial sum.
expect(run(['run', '--label', 'tests', '--', out(' 0 pass', ' 0 fail', 'Ran 0 tests across 0 files. [1ms]', pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
expect(run(['run', '--label', 'tests', '--', `${out(pass(100), pass(37))}; ${err('Ran 137 tests across 2 files. [1s]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
// A shard's forwarded count-less "Ran 0 tests across 1 file" (an empty file, or a nested child) is not the run.
expect(run(['run', '--label', 'tests', '--', `${out(start, pass(100), pass(37))}; ${err('Ran 23 tests across 1 file. [1s]', 'Ran 0 tests across 1 file. [1ms]', 'Ran 37 tests across 1 file. [1s]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// Only the runner's whole epilogue shape counts (a look-alike without the "Ns. Full log:" tail does not), and a
// checkout path with spaces still matches. A forged whole epilogue is the child's own stdout, trusted like any
// runner summary (W3's trust model), so it is not a control here.
const oneShard = '[test:free] full suite: 1 files across 1 shard processes';
expect(run(['run', '--label', 'tests', '--', out(oneShard, pass(100), '[test:free] PASS — 99999 tests, 1 files, 1s. error: spoof')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(100);
expect(run(['run', '--label', 'tests', '--', out(oneShard, '[test:free] PASS — 100 tests, 2 files, 9s. Full log: /Users/garry tan/gstack/.context/free-test-logs/x.log')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(100);
// The runner spoke but printed no countable epilogue (killed mid-shards): unknown.
expect(run(['run', '--label', 'tests', '--', out(start, '[test:free] shard 1/2 (1 files)')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
});
test('the last summary wins when a command runs a suite twice', () => {
expect(run(['run', '--label', 'tests', '--', `${bunOutput(9, 0, 0)}; ${bunOutput(0, 3, 0)}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more