Merge main into gsta147-squash-parent

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-29 05:01:10 +00:00
commit 3412e43390
62 files changed
+2961 -215

No files matched your search

+1 -1
View File
@@ -20,5 +20,5 @@
"windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list"
},
"warn": [],
"strays": { "allow": [] }
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
}
+90 -3
View File
@@ -2,6 +2,14 @@
## [Unreleased]
### Changed
- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base <base>` compares HEAD's product diff against the base with the range the receipt reviewed against the parent branch. That is the recorded `product_patch_id`, or the range against `origin/<parent>` for an older receipt. HEAD's side is its whole product diff against the base, so a parent's `package.json` change that never reached the base refuses too. Equal carries with the reason `stack parent <branch> landed` and rebinds the receipt's `base` to the base; anything else refuses. Until now every stacked child paid a full /ship once its parent merged (gstack-evals#19).
### Tests
- `test/ship-receipt-carry-squash.test.ts`: a child carries after its parent squash-merges and main is merged in, names main as its base, and carries again across a later main merge. A test-only child carries. A child edited after the merge, a parent that never landed, a parent `package.json` change that never reached main, and a merge that keeps the child's tree over the landed parent refuse. An older receipt falls back to `origin/<parent>` and refuses once that branch is deleted.
## [1.91.307.0] - 2026-10-29
**/ship now reviews in proportion to what the diff can break: light, standard or full depth.** A diff with 50 or fewer runtime lines gets one batched specialist plus the native adversarial pass, a larger runtime diff gets the class's specialists scoped to their hunks, and gate code or any classifier error gets the full review as before. The receipt records the depth and the queue refuses a receipt below what the head needs. Before this, a 12-line fix paid for the same reviewer fan-out as a change to the merge gate. The backtest that would have let light depth drop the adversarial pass was inconclusive (9 light-eligible PRs, 5 with attributable findings, below the 15 the plan required), so light keeps it.
### Added
@@ -19,6 +27,8 @@
- `test/review-depth.test.ts`, `test/review-collect.test.ts`, `test/review-plan-cli.test.ts`: the depth matrix, the fixed light set, fail-closed errors and the registry read at the base commit. A `package.json` version line that also carries other keys is not stamp-only.
- `test/diff-scope.test.ts`: `--head` reads the committed range and ignores working-tree edits, and refuses an unknown sha.
## [1.91.306.0] - 2026-10-29
**A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it.
### Changed
@@ -27,13 +37,90 @@
- **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`.
- **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules.
- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base <base>` compares HEAD's product diff against the base with the range the receipt reviewed against the parent branch. That is the recorded `product_patch_id`, or the range against `origin/<parent>` for an older receipt. HEAD's side is its whole product diff against the base, so a parent's `package.json` change that never reached the base refuses too. Equal carries with the reason `stack parent <branch> landed` and rebinds the receipt's `base` to the base; anything else refuses. Until now every stacked child paid a full /ship once its parent merged (gstack-evals#19).
### Tests
- `test/ship-receipt-carry-squash.test.ts`: a child carries after its parent squash-merges and main is merged in, names main as its base, and carries again across a later main merge. A test-only child carries. A child edited after the merge, a parent that never landed, a parent `package.json` change that never reached main, and a merge that keeps the child's tree over the landed parent refuse. An older receipt falls back to `origin/<parent>` and refuses once that branch is deleted.
- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product.
- `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`.
## [1.91.305.0] - 2026-10-29
**You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it.
### Added
- **`bin/gstack-modules`**: `list` prints every module id (`skill:`, `section:`, `overlay:`, `preamble:`) with its source and pin; `validate` checks `modules/loadouts.json`; `render --host <h> (--loadout <name> | --modules full,-<id>,+<id>) --out-dir <d>` renders one loadout with the same `--model`, `--install-root` and `--plan-mode` setup uses, and writes `loadout.lock.json` (spec sha256, excluded ids, per-file sha256 and bytes).
- **`modules/loadouts.json`** (with a JSON schema): rungs `frontier`, `strong`, `mid`, `light`, the model-id table, pinned modules and named loadouts. Every rung and `unknown` map to `full`.
- **Safety pins**: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade` can never be excluded, directly or by excluding the skill that holds them (`MODULE_PINNED`).
- **Renders refuse instead of guessing**: an unknown loadout or module, a malformed spec, a rendered file that still points at an excluded skill or section (`MODULE_EXCLUSION_UNSAFE`), or an `--out-dir` that an earlier render did not write (`RENDER_DEST_NOT_OWNED`) each exit 2 with a fix line and a `docs/troubleshooting.md` anchor, and never fall back to `full`. A failed render leaves the earlier one in place.
- **`docs/loadouts.md`**: terms, commands, how to build an eval arm, and how a loadout changes (only with a cited GSTA-2 run).
### Changed
- `gen:skill-docs` accepts `--loadout` and `--modules` with an `--out-dir` outside the checkout; a selection with exclusions aimed at the committed tree is refused. Excluded sections render a one-line `Not loaded in the <loadout> loadout` stub, and `{{IF_SECTION:<id>}}` guards remove skeleton text that belongs to a section (office-hours' second-opinion offer and visual-design phase, plan-design-review's review-sections self-check). `gstack/llms.txt` lists only the skills a render includes.
### For contributors
- New `test/module-loadouts.test.ts` covers the manifest, selection, pins, the render filter, the CLI and the byte-identical `full` render. `.gstack/pregate.json` allows the two new `modules/` files as strays. `test/gen-skill-docs-prune-stale.test.ts`'s copied fixture now links `scripts/modules.ts`; no assertion, threshold or budget changed.
## [1.91.304.0] - 2026-10-29
**Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13).
### Fixed
- **`bin/gstack-capy-install`**: an ok run keeps what an earlier ok install certified for the same checkout, project, HEAD and Bun. The verdict's `required` list becomes the union, pins count as planning-only only when both runs skipped them, and `for` lists every workflow (`for=ship,document-release`). The installer says so in one line. A failing run, a new HEAD or another Bun still replaces the verdict, and the review-voice profile still never narrows a fuller one.
### Tests
- `test/gstack-capy-install.test.ts`: `--for ship` then `--for document-release` leaves one ok verdict that admits both workflows through `verdictProblem`; a `--check --for ship` keeps it; after a new commit, `--for document-release` replaces it and ship is refused again. It failed before this change.
## [1.91.303.0] - 2026-10-29
**`gstack-pr-shots` no longer edits your `.gitignore` in the middle of /ship, so the pregate stays fresh and the ship receipt gets written.** When a repository tracks files under `.gstack/` (`pregate.json`, `ship-policy.json`), git does not read `.gstack/` as ignored, and the browse daemon that `gstack-pr-shots` starts appended `.gstack/` to the tracked `.gitignore`. The tree changed after pregate and the receipt writer refused with PREGATE_STALE (#3560, seen on gstack-evals#13).
### Fixed
- **`browse/src/config.ts`**: the browse state dir still writes its own `.gstack/.gitignore` (`*`), and when git does not already ignore `.gstack/` the line goes to the repository's `info/exclude` (resolved with `git rev-parse --git-path`, so linked worktrees share it). The project's `.gitignore` is never written. Outside a git repository nothing is written beyond the state dir.
### Tests
- `test/pr-shots.test.ts`: a repository that tracks `.gstack/pregate.json` runs `gstack-pr-shots` with a browse stub that runs the daemon's real state-dir setup; the `gstack-wtree` fingerprint and the `.gitignore` are unchanged afterwards. It failed before this change.
- `browse/test/config.test.ts`: the cases that pinned the `.gitignore` append now pin `info/exclude`: written once, no duplicate, a missing trailing newline added, `info/` created when absent, a warning in `browse-server.log` when it cannot be written, and nothing written outside a repository.
## [1.91.302.0] - 2026-10-29
**Three flaky gate cases from the 2026-10-28 daily flaky pass are repaired: shared-libs-review-index-flags, ship-exploratory-late-input (with ship-exploratory-plan-checks, which failed the same way), and the real-Chromium cookie-auth positive case.** Each red was diagnosed from its captured run before the fix, and each repair has a free test that fails without it.
### Fixed
- **Shared-code reuse no longer lets a review rewrite the index flags it checks.** In train gate 40619718717, the reviewer ran `git update-index --really-refresh` while gathering evidence. That cleared the assume-unchanged flag the checker reads, so the trial's prerequisites went stale. Step 1 of the /review and /ship shared-code reuse section now says evidence gathering is read-only and names that command. Nearby wording was tightened so the section keeps its exact size.
- **The /ship exploratory-QA cases follow Step 9's review plan again.** Since #3527, Step 9 runs `gstack-review-plan --base main`. The fixture refused that command (paid screen 40707142223, both ship-exploratory-plan-checks trials) and never said that the plan's adversarial-native and outside-voice lines belong to Step 11, which is outside the fixture's Steps 8–9 excerpt. In gate 40664336141, ship-exploratory-late-input reported pass while listing that Step 11 pass as remaining work. Once the plan ran, it dispatched the state-machines specialist, and review-army tells the parent to write the merge-base sha into that specialist's diff command. The fixture refused `git diff <merge-base sha>`, which failed both ship-exploratory-unavailable trials in this PR's screen 40737741454.
- **The real-browser cookie-auth checks no longer time out under load.** The positive case and the rejection cases that return early now use the product's default 15 s limit instead of a 400 ms test-only value. That value timed out twice on Windows and once on a Ubicloud VM.
### Gate integrity
- `test/helpers/qa-callers-fixture.ts`: the caller command interface now admits the `gstack-review-plan` forms ship Step 9 names (`--base main` with its `--pass`, `--last` and finding flags, or `--fix-check-prompt` with literal file arguments), only at the fixture's installed helper path. Pipes, other arguments, other binaries and calls without the fixture's runtime are refused, and `test/qa-caller-review-plan.test.ts` checks these as negative controls. The ship prompt names the Step 11 boundary. The interface also admits the fixture's own merge base (at least 7 hex characters) as a literal diff base, bare or in review-army's `DIFF_BASE=<sha> && git diff "$DIFF_BASE"` specialist form, and the prompt declares it. Any other sha, a shorter prefix, other Git options and composition are still refused. No threshold, trial count, case kind or timeout changed.
- `browse/test/cookie-auth-verification.test.ts`: GStack Minder approved dropping the shared `timeoutMs: 400`, which came from a84b0b5b6 (#2964) as a fixture value no product path uses (`--verify-auth` callers run under `MAX_TIMEOUT_MS` 15000). The identity mismatch, missing and ambiguous cases keep 400 ms because they resolve only at the deadline, and the 15 s default would exceed bun's 5 s test timeout. The explicit 100 ms, 30 ms, 60 s-cap and invalid-timeout tests are unchanged.
- New free tests: `test/qa-caller-review-plan.test.ts` (replays both 40707142223 captures and both 40737741454 ship-exploratory-unavailable captures, and runs the real plan for all four ship cases) and `test/shared-libs-index-flags-readonly.test.ts` (replays the captured refresh against the assume-unchanged fixture, with a read-only control).
- `scripts/lib/windows-curation.ts`: `test/shared-libs-index-flags-readonly.test.ts` is curated off the Windows subset. Its fixture starts the review through `bin/gstack-review-log`, a bash script, which windows-latest cannot launch (Windows Free Tests run 40727825859, shard 5).
## [1.91.301.0] - 2026-10-29
**Four paid gate cases that went red on 2026-10-28 without a product defect now pass on the runs that failed them.** Each repair replays the failing run as a free test that fails without it.
### Fixed
- **plan-design-with-ui-scope**: the Design focus question counts when it puts "review all 7 areas or focus" in its rated title and letters its options ("A) Review all 7 areas"), as gate 40664336141 asked it.
- **plan-review-report**: the case turns the default-on outside voice off with `codex_reviews: disabled`, the skill's documented switch. In gate 40660626349 both reds ran out of the 20-turn cap before writing the report, and the outside voice had used 4 of their 21 turns. The cap is unchanged.
- **shared-libs-review-path-eligibility**: a Bash read that printed a whole evidence file counts even when a later command in the same call failed, as one did in run 40617200857.
- **office-hours-brain-writeback**: the fake `gbrain` saves a page piped on stdin, the form the SAVE_RESULTS template shows, as well as `--content`.
### Gate integrity
- `designFocusBoundary` (test/skill-e2e-plan-design-with-ui.test.ts) accepts the all-7 offer in the rated title line and rejects issue-numbered option sets. Seven new negative controls cover a missing rating, a /7 rating, no focus, another count, an offer below the title, issue-numbered options and a single option.
- `hasTrustedSharedLibsCheck` (test/helpers/shared-libs-review-start-evidence.ts) accepts an errored Bash read only in a multi-command call whose output holds the exact evidence source. Start, check and finish calls must still succeed. The existing "errored caller read" control still fails, and five new controls do too.
- The plan-review-report case input now disables the outside voice. Outside-voice coverage is not part of this case's assertions.
## [1.91.300.0] - 2026-10-28
**Three free tests that went red at random now fail only when the code under test is wrong.** Today's flaky-test pass (GSTA-139) traced two Linux reds to their fixtures and fixed a third that was certain to fail under load. No product code changed.
### Fixed
- **`test/ubicloud-runner.test.ts`**: the watchdog cases expected exit 124 but got 2 or 0, about one run in 62. The cause was not timing. The fake `ssh` had a `*-O*` case arm, meant for `ssh -O exit` but never matched by it, and it caught any remote command whose checkout path held `-O`. That happened whenever the fixture's temporary directory name started with `O`. The arm is gone, and a new test runs a checkout named `ubi-run-O1x2y3`.
- **`test/ship-queue-train.test.ts`, `test/gstack-ship-queue.test.ts`**: `coordinatorClone` now clones the fixture origin with `--no-local`, through git's transport like a real clone of GitHub, instead of copying `origin.git/objects` file by file. Every push into the fixture origin leaves a detached `git maintenance run --auto` there. The local transport drops the shard's `GIT_CONFIG_COUNT` maintenance-off setting, so that process still creates and deletes `objects/maintenance.lock` after the push returns. Two runs failed with `failed to copy file … No such file or directory` on a loose object. The process that removed that object was not identified; the transport clone never lists the directory at all.
- **`test/qa-fix-loop-fixture.test.ts` late-timeout**: the scenario ran its real setup inside a 1000 ms mocked work budget under a 1160 ms deadline, so on a loaded machine the attempt never started (0 of 10 runs under 24 busy loops). It now uses #3413's qa-only fix: the clock stands still from the test body's start until the capture begins, and the deadline is the registered 6080 ms outer budget (20 of 20 under the same load).
### Gate integrity
- `test/qa-fix-loop-fixture.test.ts` late-timeout asserts that the capture gets exactly 1000 ms, where it used to accept anything between 0 and 1000, and its test deadline is the registered outer budget rather than budget − 5000 + 80 ms. If the workflow's outer budget drops back to the bare work budget, the scenario still fails. A new `slow-setup` scenario keeps the check the old range assertion gave. A controlled clock spends 400 ms in setup, and the capture must get exactly 600 ms. If the workflow passed the whole work budget to the capture instead of what setup left, it would fail.
## [1.91.299.0] - 2026-10-28
**The Captain brief now says what `rec` does when a gate carries earlier decisions.** At a gate after the owner already answered some items, such as a round-cap gate, `gstack-owner-brief render --captain` listed each item's recommendation ("keep the plan as written", `rec resolves to d2a`) while `gstack-gate render` and `gstack-autoplan answer --reply rec` keep the carried answer (`d2b`). A Captain relaying the brief told the owner the opposite of what their one-word reply would record (#3546). The brief now takes its choices from the same resolver `gstack-gate render` uses.
+1
View File
@@ -205,6 +205,7 @@ the new defaults.
| Validate periodic cases on a branch in CI | `gh workflow run evals-periodic.yml --ref <branch>` (periodic lane only) | CI runners + API spend | Pushed branch, `gh` with workflow rights |
| Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref <branch> -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights |
| Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref <branch>` | Free CI runners | Pushed branch, `gh` with workflow rights |
| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:<skill>/<id> --out-dir <dir>` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 |
| Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history |
| See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case <case-id>` | Free | `gh` with repo read access |
| Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case <case-id> --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage |
+1 -1
View File
@@ -1 +1 @@
1.91.299.0
1.91.307.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.299.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.307.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,
+19 -2
View File
@@ -407,15 +407,32 @@ verdict_kept() {
[ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)"
}
# An ok run keeps what an earlier ok install certified for this checkout, project, HEAD and Bun,
# so one machine serving /ship and its docs step stays admitted for both (#3561): required is
# the union, pins count as planning-only only when both runs skipped them, and for= lists every
# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict.
record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded)
local v_for="${FOR:-}" v_req v_plan e_for id
if verdict_kept; then
say "kept the install verdict for=$(gstack_install_verdict_field "$VERDICT" for) at $VERDICT (it covers more than $FOR; the trailer below is this check's)"
return 0
fi
if [ "$CHECK_ONLY" -eq 1 ] && [ -n "$REC_BUN" ]; then set -- "$1" "$2" "$REC_BUN" "$REC_VER" "$REC_RULE" "$REC_BUN_INSTALL"
else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi
gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "${FOR:-}" "$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)" \
"$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \
v_req=$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)
v_plan=$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)
if [ "$2" = 1 ] && [ "$PROFILE" -eq 0 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && [ "$(gstack_install_verdict_field "$VERDICT" checkout)" = "$ROOT_REAL" ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" bun_path)" = "$3" ] && e_for=$(gstack_install_verdict_field "$VERDICT" for) \
&& ! gstack_doctor_install_profile "$e_for"; then
for id in $(gstack_install_verdict_field "$VERDICT" required); do
case " $v_req " in *" $id "*) ;; *) v_req="$v_req $id" ;; esac
done
[ "$(gstack_install_verdict_field "$VERDICT" pins_planning_only)" = true ] || v_plan=0
case ",$e_for," in *",$v_for,"*) v_for="$e_for" ;; *) v_for="${e_for:+$e_for${v_for:+,}}$v_for" ;; esac
[ "$v_for" = "$e_for" ] || [ -z "$e_for" ] || say "the install verdict at $VERDICT keeps for=$e_for and adds ${FOR:-$REQUIRE} (for=$v_for)"
fi
gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "$v_for" "$v_req" "$v_plan" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \
"$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" \
|| say "could not write the install verdict $VERDICT"
}
+3 -1
View File
@@ -8,7 +8,9 @@
# one checkout and one project (the caller's git toplevel, or none):
# <state root>/install-verdicts/<sha256(checkout realpath)>/<sha256(project realpath | "none")>.json
# written atomically by bin/gstack-capy-install (install and --check) with
# schema_version 2, checkout, head, for, required[], pins_planning_only, ok,
# schema_version 2, checkout, head, for (comma-separated: an ok run at the same
# HEAD and Bun keeps the workflows an earlier ok install certified, #3561),
# required[] (their union), pins_planning_only, ok,
# trailer, min_revision, launch_path, time, project, pin (the exact Bun pin),
# bun_path (realpath), bun_version, bun_rule (gstack_bun_resolve), bun_install,
# node_path (realpath), node_version, node_rule (gstack_node_resolve; empty
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
import { modulesMain } from '../scripts/modules-cli.ts';
void modulesMain(process.argv.slice(2)).then(result => {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exitCode = result.code;
});
+34 -23
View File
@@ -100,6 +100,21 @@ function isIgnoredByGit(projectDir: string, relPath: string): boolean {
}
}
function gitExcludePath(projectDir: string): string | null {
try {
const proc = Bun.spawnSync(['git', 'rev-parse', '--git-path', 'info/exclude'], {
windowsHide: true,
cwd: projectDir, stdout: 'pipe', stderr: 'pipe',
timeout: 2_000,
});
if (proc.exitCode !== 0) return null;
const rel = proc.stdout.toString().trim();
return rel ? path.resolve(projectDir, rel) : null;
} catch {
return null;
}
}
/**
* Create the .gstack/ state directory if it doesn't exist.
* Throws with a clear message on permission errors.
@@ -119,40 +134,36 @@ export function ensureStateDir(config: BrowseConfig): void {
// Load-bearing guard: a self-contained ignore INSIDE the state dir so its
// contents can NEVER be `git add`-ed, regardless of the project's own
// .gitignore (which may be absent, or the append below may silently fail).
// The state dir holds session-state.json (live cookies + localStorage/
// sessionStorage tokens) and browse-network.log / browse-audit.jsonl
// (captured request headers can carry bearer tokens). Written unconditionally,
// synchronously, before return — the project-.gitignore dance below is now
// redundant safety, kept so `.gstack/` still reads as ignored in git status.
// ignore rules. The state dir holds session-state.json (live cookies +
// localStorage/sessionStorage tokens) and browse-network.log /
// browse-audit.jsonl (captured request headers can carry bearer tokens).
// Written unconditionally, synchronously, before return.
try {
fs.writeFileSync(path.join(config.stateDir, '.gitignore'), '*\n');
} catch {
// Best-effort; the project-.gitignore path below is the fallback.
// Best-effort; the repository's info/exclude below is the fallback.
}
// Ensure .gstack/ is in the project's .gitignore
// First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore)
// Ensure git reads .gstack/ as ignored through the repository's own
// info/exclude, never the project's .gitignore: that file is tracked, and
// editing it mid-/ship changes the tree after pregate (PREGATE_STALE, #3560).
if (isIgnoredByGit(config.projectDir, '.gstack/')) return;
const gitignorePath = path.join(config.projectDir, '.gitignore');
const excludePath = gitExcludePath(config.projectDir);
if (!excludePath) return;
try {
const content = fs.readFileSync(gitignorePath, 'utf-8');
const content = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf-8') : '';
if (!content.match(/^\.gstack\/?$/m)) {
const separator = content.endsWith('\n') ? '' : '\n';
fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`);
const separator = content === '' || content.endsWith('\n') ? '' : '\n';
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
fs.appendFileSync(excludePath, `${separator}.gstack/\n`);
}
} catch (err: any) {
if (err.code !== 'ENOENT') {
// Write warning to server log (visible even in daemon mode)
const logPath = path.join(config.stateDir, 'browse-server.log');
try {
fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update .gitignore at ${gitignorePath}: ${err.message}\n`);
} catch {
// stateDir write failed too — nothing more we can do
}
const logPath = path.join(config.stateDir, 'browse-server.log');
try {
fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update ${excludePath}: ${err.message}\n`);
} catch {
// stateDir write failed too — nothing more we can do
}
// ENOENT (no .gitignore) — skip silently
}
}
+48 -44
View File
@@ -95,68 +95,72 @@ describe('config', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('adds .gstack/ to .gitignore if not present', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
// A repository that tracks files under .gstack/ (pregate.json, ship-policy.json) is the case
// where git does not already read .gstack/ as ignored, so the ignore line must land somewhere.
const trackedGstackRepo = (prefix: string) => {
const { spawnSync } = require('child_process');
const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
const git = (...args: string[]) => spawnSync('git', args, { cwd: tmpDir, timeout: 30_000, encoding: 'utf-8' });
git('init', '-q');
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/tmp/\n');
fs.mkdirSync(path.join(tmpDir, '.gstack'));
fs.writeFileSync(path.join(tmpDir, '.gstack', 'pregate.json'), '{}\n');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
const exclude = path.resolve(tmpDir, git('rev-parse', '--git-path', 'info/exclude').stdout.trim());
return { tmpDir, git, exclude };
};
test('records .gstack/ in .git/info/exclude and leaves the tracked .gitignore unchanged (#3560)', () => {
const { tmpDir, git, exclude } = trackedGstackRepo('browse-exclude-test-');
fs.writeFileSync(exclude, '# local\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toContain('.gstack/');
expect(content).toBe('node_modules/\n.gstack/\n');
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n');
expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n.gstack/\n');
expect(git('status', '--porcelain').stdout).toBe('');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('does not duplicate .gstack/ in .gitignore', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
test('does not duplicate .gstack/ in info/exclude, adds a missing trailing newline, and creates info/ when absent', () => {
const dup = trackedGstackRepo('browse-exclude-dup-');
fs.writeFileSync(dup.exclude, 'build/');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(dup.tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules/\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
ensureStateDir(config);
expect(fs.readFileSync(dup.exclude, 'utf-8')).toBe('build/\n.gstack/\n');
fs.rmSync(dup.tmpDir, { recursive: true, force: true });
const bare = trackedGstackRepo('browse-exclude-noinfo-');
fs.rmSync(path.dirname(bare.exclude), { recursive: true, force: true });
ensureStateDir(resolveConfig({ BROWSE_STATE_FILE: path.join(bare.tmpDir, '.gstack', 'browse.json') }));
expect(fs.readFileSync(bare.exclude, 'utf-8')).toBe('.gstack/\n');
fs.rmSync(bare.tmpDir, { recursive: true, force: true });
});
test('handles .gitignore without trailing newline', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('logs warning to browse-server.log on non-ENOENT gitignore error', () => {
test('logs warning to browse-server.log when info/exclude cannot be written', () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Create a read-only .gitignore (no .gstack/ entry → would try to append)
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o444);
const { tmpDir, exclude } = trackedGstackRepo('browse-exclude-ro-');
fs.writeFileSync(exclude, '# local\n');
fs.chmodSync(exclude, 0o444);
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config); // should not throw
// Verify warning was written to server log
const logPath = path.join(config.stateDir, 'browse-server.log');
expect(fs.existsSync(logPath)).toBe(true);
const logContent = fs.readFileSync(logPath, 'utf-8');
expect(logContent).toContain('Warning: could not update .gitignore');
// .gitignore should remain unchanged
const gitignoreContent = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(gitignoreContent).toBe('node_modules/\n');
// Cleanup
fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o644);
const logContent = fs.readFileSync(path.join(config.stateDir, 'browse-server.log'), 'utf-8');
expect(logContent).toContain(`Warning: could not update ${exclude}`);
expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n');
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n');
fs.chmodSync(exclude, 0o644);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('skips if no .gitignore exists', () => {
test('outside a git repository nothing is written beyond the state dir', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
expect(fs.readdirSync(tmpDir).sort()).toEqual(['.gitignore', '.gstack']);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
+13 -11
View File
@@ -423,6 +423,7 @@ describe('cookie auth configuration and bounded operations', () => {
});
describe('cookie authentication and storage with isolated Chromium profiles', () => {
const browserOptions = { identitySelector: options.identitySelector, expectedIdentity: options.expectedIdentity };
let browser: Browser;
let context: BrowserContext;
let page: Page;
@@ -486,13 +487,13 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
test('verifies a reloaded protected page only with an exact visible identity and synthetic session', async () => {
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
await page.goto(`${origin}/protected`);
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: true, reason: 'verified', status: 200 });
expect(await verifyCookieAuthentication(page, browserOptions, origin)).toEqual({ verified: true, reason: 'verified', status: 200 });
});
for (const path of ['/normalized', '/one-visible', '/hidden-child', '/delayed']) {
test(`accepts one normalized visible identity at ${path}`, async () => {
await page.goto(`${origin}${path}`);
const result = await verifyCookieAuthentication(page, { ...options, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin);
const result = await verifyCookieAuthentication(page, { ...browserOptions, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin);
expect(result).toEqual({ verified: true, reason: 'verified', status: 200 });
expect(JSON.stringify(result)).not.toContain(identity);
});
@@ -506,7 +507,8 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
test(`rejects ${path} with a safe ${reason} result`, async () => {
await page.goto(`${origin}${path === '/login-hop' ? '/identity' : path}`);
if (path === '/login-hop') await page.evaluate(() => history.replaceState({}, '', '/login-hop'));
const result = await verifyCookieAuthentication(page, options, origin);
// identity_mismatch/missing/ambiguous keep the 400 ms options (GStack Minder, 2026-10-28): they resolve only when the deadline fires (browse/src/cookie-auth-verification.ts:105-120), so the 15 s default would exceed bun's 5 s test timeout.
const result = await verifyCookieAuthentication(page, reason.startsWith('identity_') ? options : browserOptions, origin);
expect(result.verified).toBe(false);
expect(result.reason).toBe(reason);
expect(JSON.stringify(result)).not.toMatch(/Synthetic Account|127\.0\.0\.1/);
@@ -517,13 +519,13 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
await page.goto(`${origin}/protected`);
await context.clearCookies();
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('login_redirect');
expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('login_redirect');
});
for (const status of [401, 403, 500, 503]) {
test(`rejects HTTP ${status} even with a positive identity`, async () => {
await page.goto(`${origin}/status/${status}`);
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: false, reason: 'http_error', status });
expect(await verifyCookieAuthentication(page, browserOptions, origin)).toEqual({ verified: false, reason: 'http_error', status });
});
}
@@ -531,7 +533,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
for (const destination of ['/redirect-cross', '/roundtrip']) {
await page.goto(`${origin}/identity`);
await page.evaluate(path => history.replaceState({}, '', path), destination);
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_changed');
}
});
@@ -553,7 +555,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
return value;
});
try {
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_changed');
} finally {
locator.mockRestore();
}
@@ -563,7 +565,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
await page.goto(`${origin}/identity`);
await page.evaluate(() => history.replaceState({}, '', '/slow'));
try {
expect(await verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, origin))
expect(await verifyCookieAuthentication(page, { ...browserOptions, timeoutMs: 100 }, origin))
.toEqual({ verified: false, reason: 'timeout' });
} finally {
releaseSlowResponse?.();
@@ -572,11 +574,11 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
test('sanitizes invalid selector errors and handles a closed target', async () => {
await page.goto(`${origin}/identity`);
const result = await verifyCookieAuthentication(page, { ...options, identitySelector: '[synthetic-token' }, origin);
const result = await verifyCookieAuthentication(page, { ...browserOptions, identitySelector: '[synthetic-token' }, origin);
expect(result.reason).toBe('verification_failed');
expect(JSON.stringify(result)).not.toContain('synthetic-token');
await page.close();
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_closed');
expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_closed');
expect(await clearCookieTargetStorage(page, origin).then(() => null, error => error)).toMatchObject({ code: 'target_closed' });
});
@@ -586,7 +588,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', ()
localStorage.setItem('local', 'original');
sessionStorage.setItem('session', 'original');
});
expect((await verifyCookieAuthentication(page, options, origin)).verified).toBe(true);
expect((await verifyCookieAuthentication(page, browserOptions, origin)).verified).toBe(true);
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')]))
.toEqual(['original', 'original']);
});
+1
View File
@@ -92,6 +92,7 @@ gstack/
│ ├── test/ # Integration tests
│ └── dist/ # Compiled binary
├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts
├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, pinned modules and named loadouts; read by scripts/modules.ts, rendered by bin/gstack-modules (docs/loadouts.md)
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/)
│ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads
+7 -1
View File
@@ -1427,7 +1427,13 @@ Free-suite flake rules:
every shard child's `GIT_CONFIG_*` pairs as a backstop, so it covers fixtures
that inherit the environment, and `bun run scripts/ship-measure.ts free
--backend local` measures with that environment; a fixture with its own
replacement env never sees the backstop.
replacement env never sees the backstop. Neither does the receiving side of
a push to a local path: git's local transport drops `GIT_CONFIG_*` from the
`receive-pack` it starts, so every push into a fixture's bare origin still
leaves a detached maintenance run in that origin. A fixture that clones such
an origin passes `--no-local` (`coordinatorClone` in
`test/helpers/ship-queue-fixture.ts`), so the clone reads it through git's
transport instead of copying `objects/` entry by entry.
To reproduce a ledger entry, take the shard's exact file list from the CI run's
`free-plan` artifact, then rerun it with the flaky retry off on a CI-mirrored VM:
+1 -1
View File
@@ -60,7 +60,7 @@ depends on measured duration or an explicit `QUICK_CORE` entry, not this table.
| CLI/API/webhook QA without browser setup | `qa-functional-fixture`, `qa-functional-evidence`, `qa-lazy-sections` | `skill-e2e-qa-functional`: CLI and webhook report sessions | Linux/macOS free; selected PR gate; Windows only where curated |
| Report-only preserves local and remote authority | `qa-only-capability`, `qa-functional-observer`, `qa-functional-observer-atomic`, `qa-caller-authority` | Independent report-only sessions with synthetic owned endpoints/auth | Linux kernel observation; free callback controls plus selected PR gate |
| Repair reproduces the defect, adds a failing regression and rechecks adjacent behavior | `qa-fix-loop-fixture`, `qa-functional-evidence` | `skill-e2e-qa-functional-fix`: CLI and webhook repair sessions | Free controls plus selected PR gate |
| Review and Ship actually explore | `qa-exploratory-callers`, `qa-caller-report-observer`, `qa-checkpoint-evidence` | `skill-e2e-qa-callers`: actual Review/Ship callers | Free captures plus selected PR gate |
| Review and Ship actually explore | `qa-exploratory-callers`, `qa-caller-report-observer`, `qa-caller-review-plan`, `qa-checkpoint-evidence` | `skill-e2e-qa-callers`: actual Review/Ship callers | Free captures plus selected PR gate |
| Smoke expiry preserves required plan checks | `qa-deadline`, `qa-deadline-selection`, `qa-browser-deadline-evidence` | `ship-exploratory-plan-checks` | Free deadline/dispatch controls plus selected PR gate |
| Late changes invalidate affected results | `qa-caller-freshness-order`, `qa-deadline-publication-observer`, `shared-libs-revalidation-prompt` | `ship-exploratory-late-input` and the existing late-input documentation handoff | Free stale-input controls plus selected PR gate |
| Documentation completes before publication and respects protected files | `docsync-authority`, `docsync-atomic-writes`, `docsync-report-interface`, `docsync-lifecycle-interface` | `skill-e2e-ship-docsync`, `skill-e2e-docsync-spawned` | Free state/permission controls; registered gate/periodic scenarios retain their tiers |
+10 -1
View File
@@ -274,7 +274,16 @@ keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
honoured, the resolved Bun (`bun_path`, `bun_version`, `bun_rule`, and
`bun_install` when this run downloaded it), the resolved Node when the
project pins one (`node_path`, `node_version`, `node_rule`), the trailer, the remote
minimum, the launch PATH and the time. Admission refuses a workflow whose
minimum, the launch PATH and the time. An ok run (install or `--check`) at
the same checkout, project, HEAD and Bun as an earlier ok verdict keeps what
that verdict certified, so one machine stays admitted for /ship and its
`--for document-release` docs step (#3561): `required` becomes the union,
pins count as planning-only only when both runs skipped them, and the
verdict's `for` lists every workflow, comma-separated (`for=ship,document-release`;
the installer prints `the install verdict at <file> keeps for=<old> and adds
<new> (for=<list>)`, and the trailer still names this run's workflow). A
failing run, a new HEAD or another Bun replaces the verdict, and so does any
workflow install over a review-voice verdict. Admission refuses a workflow whose
running Bun is not the one the verdict recorded
([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so
do the pregate's lanes and guards, which also refuse an invalid verdict
+2 -2
View File
@@ -63,7 +63,7 @@ The plan-ceo-review-plan-mode (:89, :108), plan-devex-review-plan-mode (:181-222
| auq-format-gate | test/skill-e2e-ask-user-question-format-compliance.test.ts:58 | question ~ `options differ in kind` | AUQ tool input | yes | (c) |
| auq-format-gate | test/skill-e2e-ask-user-question-format-compliance.test.ts:61, :68 | recommendation substance ≥ 4 | — | — | judge |
| plan-tune-inspect | test/skill-e2e-plan-tune.test.ts:167-169, :185 | output names ≥2 of 3 seeded question IDs or aliases | question-log is seeded fixture input | yes (inspection report) | (c) |
| plan-review-report | test/skill-e2e-plan.test.ts:463-491 | plan.md `## GSTACK REVIEW REPORT`, CEO/Eng/Design rows, ends with `NO UNRESOLVED DECISIONS` or a bullet | rows come from the seeded review log; the table is rendered | yes | (c) |
| plan-review-report | test/skill-e2e-plan.test.ts:472-500 | plan.md `## GSTACK REVIEW REPORT`, CEO/Eng/Design rows, ends with `NO UNRESOLVED DECISIONS` or a bullet | rows come from the seeded review log; the table is rendered | yes | (c) |
### test/skill-e2e-design.test.ts (gate cases)
@@ -89,7 +89,7 @@ The plan-ceo-review-plan-mode (:89, :108), plan-devex-review-plan-mode (:181-222
|---|---|---|---|---|---|
| shared-libs-read-only | test/skill-e2e-shared-libs.test.ts:113-120 | report ~ uncommitted/overlay/raw, tip and branch SHAs, retry-worker/route, no blob link for branch-only file | none (read-only by contract) | yes | (c) |
| shared-libs-unsupported-git | test/skill-e2e-shared-libs.test.ts:136, :144 | report ~ unavailable/unsupported/limited; not "no commits/PRs found" | none | yes | (c) |
| shared-libs-review-path-eligibility | test/skill-e2e-shared-libs-paths.test.ts:133-134 | `trace + output` ~ submodule/160000, check-ignore/ignored (output alone satisfies) | Bash command trace (first operand) | no | (b) |
| shared-libs-review-path-eligibility | test/skill-e2e-shared-libs-paths.test.ts:134-135 | `trace + output` ~ submodule/160000, check-ignore/ignored (output alone satisfies) | Bash command trace (first operand) | no | (b) |
### test/skill-e2e-ship-docsync.test.ts, test/skill-e2e-docsync-spawned.test.ts
+40
View File
@@ -0,0 +1,40 @@
# Module loadouts
gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Today every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when the shared skill eval (GSTA-2) measures a smaller one that matches `full` within its preregistered margin.
Install-time model detection, which picks a loadout for the model you run, ships with the first loadout that is not `full`. Until then, loadouts are an eval tool: `bin/gstack-modules render` produces any loadout as a byte-exact tree with a lock file.
## Terms
| Term | Meaning |
|---|---|
| Module | One of four kinds. `skill:<dir>` is a whole skill. `section:<skill>/<id>` is one entry of `<skill>/sections/manifest.json`. `overlay:<name>` is one `model-overlays/<name>.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:<id>` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). |
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. |
| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. |
## Commands
```bash
bin/gstack-modules list [--json] [--pinned] # every module id, kind, source, pin, source bytes and sha256
bin/gstack-modules validate # check modules/loadouts.json against the catalog
bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control
bin/gstack-modules render --host claude \
--modules 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design' \
--out-dir ~/arms/oh-lean
```
`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`.
`bun run gen:skill-docs --loadout <name> --out-dir <dir>` and `--modules <spec> --out-dir <dir>` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `<!-- loadout: <name> -->` to each SKILL.md header.
## Building an eval arm
1. Render the control with `--loadout full` and each arm with `--modules full,-<id>,…`. Every refusal names its code and fix ([troubleshooting](troubleshooting.md#module-loadouts-gstack-modules)).
2. Preregister the lock files' `spec_sha256` and per-file sha256 values with the eval.
3. Excluding a section removes its STOP pointer, its Section-index row and its file. The step becomes `> Not loaded in the <loadout> loadout: skip <trigger>. Do not offer it, and treat it as not run.` Skeleton text that belongs to the section, such as office-hours' Phase 3.5 offer, sits inside `{{IF_SECTION:<id>}}…{{/IF_SECTION}}` in the template and goes with it.
4. The autoplan runner (`gstack-autoplan`) reads review methodology from the source tree, not from a rendered loadout, so autoplan-runner arms always measure `full` methodology.
## Changing a loadout
Add or edit an entry under `loadouts` and cite the GSTA-2 run (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`.
+44
View File
@@ -5225,3 +5225,47 @@ QM's admin API refused or did not answer. gstack lists packs before it creates o
QM registration does not work yet. QM's skill-pack import clones the whole repository and reads every file before it picks the skills, and by default it refuses a pack over 32 MiB or 5,000 files. The gstack repository is larger (about 73 MiB in 4,300 files), so gstack checks the size first and sends nothing. When QM itself refuses (a QM with other limits), the pack it created stays in the org without skills: `gstack-harness unregister qm --remote` archives it. Meanwhile, give a QM agent gstack by pasting the output of `gstack-harness register generic --print` into its instructions.
## Module loadouts (gstack-modules)
These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control.
<a id="loadouts-invalid"></a>
### `LOADOUTS_INVALID: modules/loadouts.json …`
**Meaning.** `modules/loadouts.json` is unreadable, or names a module, base loadout, rung or default that does not exist, excludes a pinned module, or maps one model id to two rungs. **Fix.** Correct the named entries, then run `bin/gstack-modules validate` until it prints `LOADOUTS: ok`; `git checkout -- modules/loadouts.json` restores the committed file.
<a id="loadout-unknown"></a>
### `LOADOUT_UNKNOWN: "<name>" is not a loadout (valid: …)`
**Meaning.** `--loadout` or the first token of `--modules` names no loadout in `modules/loadouts.json`. **Fix.** Use a name from the `valid:` list (`bin/gstack-modules list` prints them); `full` is always present.
<a id="module-spec-invalid"></a>
### `MODULE_SPEC_INVALID: …`
**Meaning.** A `--modules` token is not `-<module id>` or `+<module id>`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `<loadout>,-<id>,+<id>` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists.
<a id="module-unknown"></a>
### `MODULE_UNKNOWN: "<id>" is not a module`
**Meaning.** The id names no skill, section, overlay or preamble piece. **Fix.** Copy the id from `bin/gstack-modules list`.
<a id="module-pinned"></a>
### `MODULE_PINNED: <id> is pinned and can never be excluded`
**Meaning.** The spec removes a module the safety contract needs: the start command (`preamble:bash`), plan-mode rules, the question format, completion status, the unattended profile, /ship's review gates, the router or `/gstack-upgrade`. **Fix.** Drop `-<id>` from the spec; `bin/gstack-modules list --pinned` prints every pinned id.
<a id="module-exclusion-unsafe"></a>
### `MODULE_EXCLUSION_UNSAFE: <id>: referenced by <file>:<line>`
**Meaning.** The rendered tree still points at a module the loadout left out, so an agent following it would read a file that is not there. **Fix.** Exclude the referencing module as well, or wrap the referencing skeleton text in `{{IF_SECTION:<id>}}…{{/IF_SECTION}}` in its template (the text renders unchanged when the section loads). Nothing was published; rerun after the change.
<a id="render-failed"></a>
### `RENDER_FAILED: gen-skill-docs failed: …`
**Meaning.** Generation itself failed for this loadout (an unresolved placeholder, an oversized skill, a missing section). **Fix.** Fix the named template or module and rerun. The staging directory was removed and `--out-dir` is unchanged.
<a id="render-dest-not-owned"></a>
### `RENDER_DEST_NOT_OWNED: <dir> exists and holds no loadout.lock.json`
**Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render.
+62
View File
@@ -0,0 +1,62 @@
{
"$schema": "./loadouts.schema.json",
"version": 1,
"note": "Per-model loadouts (GSTA-140). Every rung loads full until a GSTA-2 measurement shows a smaller loadout matches full within its preregistered margin; each change cites that run in the loadout's note. Module ids: bin/gstack-modules list. Guide: docs/loadouts.md.",
"aliases": {
"opus": "strong",
"sonnet": "strong",
"haiku": "mid"
},
"rungs": {
"frontier": [
"claude-fable-5*",
"gpt-6-astra*"
],
"strong": [
"claude-opus-5*",
"claude-sonnet-5*",
"gpt-6.1-sol*",
"gpt-6-sol*",
"gpt-5.6-sol*",
"gemini-4-argon*"
],
"mid": [
"claude-haiku-5*",
"glm-5.3",
"gpt-6.1-terra*",
"gemini-3.8-flash*",
"grok-4*",
"kimi-k3*",
"deepseek-v4-pro*",
"qwen3.8-max*"
],
"light": [
"deepseek-v4.1-flash*",
"glm-5.3-flash*",
"gpt-6-luna*",
"qwen3.8-flash*",
"minimax-m3*"
]
},
"pinned": [
"preamble:plan-mode-info",
"preamble:ask-user-format",
"preamble:completion-status",
"section:autoplan/unattended",
"section:ship/review-army",
"section:ship/adversarial"
],
"loadouts": {
"full": {
"include": "*",
"note": "Every module: today's render."
}
},
"default": {
"frontier": "full",
"strong": "full",
"mid": "full",
"light": "full",
"unknown": "full"
}
}
+159
View File
@@ -0,0 +1,159 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "gstack module loadouts",
"description": "Rungs, the model-id to rung table, pinned module ids and named loadouts. Validated by scripts/modules.ts validateLoadouts (bin/gstack-modules validate, test/module-loadouts.test.ts).",
"type": "object",
"required": [
"version",
"aliases",
"rungs",
"pinned",
"loadouts",
"default"
],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"version": {
"const": 1
},
"note": {
"type": "string"
},
"aliases": {
"type": "object",
"additionalProperties": {
"enum": [
"frontier",
"strong",
"mid",
"light"
]
}
},
"rungs": {
"type": "object",
"additionalProperties": false,
"required": [
"frontier",
"strong",
"mid",
"light"
],
"properties": {
"frontier": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9.-]+\\*?$"
}
},
"strong": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9.-]+\\*?$"
}
},
"mid": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9.-]+\\*?$"
}
},
"light": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9.-]+\\*?$"
}
}
}
},
"pinned": {
"type": "array",
"items": {
"type": "string"
}
},
"loadouts": {
"type": "object",
"required": [
"full"
],
"propertyNames": {
"pattern": "^[a-z0-9-]+$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"base": {
"type": "string"
},
"include": {
"oneOf": [
{
"const": "*"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"exclude": {
"type": "array",
"items": {
"type": "string"
}
},
"preamble_tier_max": {
"enum": [
1,
2,
3,
4
]
},
"note": {
"type": "string"
}
}
}
},
"default": {
"type": "object",
"additionalProperties": false,
"required": [
"frontier",
"strong",
"mid",
"light",
"unknown"
],
"properties": {
"frontier": {
"type": "string"
},
"strong": {
"type": "string"
},
"mid": {
"type": "string"
},
"light": {
"type": "string"
},
"unknown": {
"type": "string"
}
}
}
}
}
+4 -4
View File
@@ -228,13 +228,13 @@ Use AskUserQuestion to confirm. If the user disagrees with a premise, revise und
---
## Phase 3.5: Cross-Model Second Opinion (optional)
{{IF_SECTION:second-opinion}}## Phase 3.5: Cross-Model Second Opinion (optional)
Use AskUserQuestion: "Want a second opinion from an independent AI perspective? It reviews your problem statement, key answers, premises and landscape findings from a structured summary, without this conversation. Usually 2-5 minutes." A) Yes, get a second opinion. B) No, proceed to alternatives.
If B: skip Phase 3.5 and remember the second opinion did NOT run (the design doc, founder signals and Phase 4 depend on it).
If A:
{{SECTION:second-opinion}}
{{/IF_SECTION}}{{SECTION:second-opinion}}
---
@@ -274,10 +274,10 @@ Emit ONE AskUserQuestion that lists every alternative (A/B and optionally C) as
---
## Phase 4.25: Visual design (UI ideas only)
{{IF_SECTION:visual-design}}## Phase 4.25: Visual design (UI ideas only)
If the approach approved in Phase 4 has no user-facing UI (screens, pages, forms, dashboards, interactive elements), skip to Phase 4.5.
{{SECTION:visual-design}}
{{/IF_SECTION}}{{SECTION:visual-design}}
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.299",
"version": "1.91.307",
"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
@@ -288,10 +288,10 @@ Exit 0: show the image at the printed `outputPath` via the Read tool. Exit 2 or
{{SECTION:review-sections}}
## Section self-check (before you finish)
{{IF_SECTION:review-sections}}## Section self-check (before you finish)
Confirm you Read the review section the Section index named, and executed all 7 design passes, the required outputs, and the review report in full. If you produced findings or the review report from memory without Reading `sections/review-sections.md`, stop and Read it now.
Before summaries, review logs or next-step menus, run approval check 0 below.
{{/IF_SECTION}}Before summaries, review logs or next-step menus, run approval check 0 below.
{{EXIT_PLAN_MODE_GATE}}
+11 -10
View File
@@ -2,11 +2,12 @@
<!-- Regenerate: bun run gen:skill-docs -->
**Reuse a skipped shared-code advisory only with complete structural evidence:**
1. **Read the evidence.** Read all supporting callers and the helper destination.
Establish first-party authored provenance and whether the current extraction
is worthwhile; the checker cannot decide that. Retain `evidence_paths`/`helper_target`.
1. **Read the evidence.** Read all supporting callers and the helper target
read-only: `git update-index`, even `--really-refresh`, clears the index flags
the checker reads. Establish first-party authored provenance and whether this
extraction is worthwhile; the checker cannot. Retain `evidence_paths`/`helper_target`.
2. **Run the checker.** From the repository root, pass the current finding as
literal JSON on stdin. Replace REVIEW_START with this pass's captured token
literal JSON on stdin. Replace REVIEW_START with this pass's token
and the example paths/symbol with actual evidence. Keep the quoted delimiter.
```bash
@@ -15,17 +16,17 @@
GSTACK_SHARED_LIBS_REUSE_JSON
```
3. **Act on its result.** Read the JSON. Only `reusable: true` permits suppression.
3. **Act on its result.** Only `reusable: true` permits suppression.
False, command failure or unreadable output requires fresh source review and a
new decision, never suppression. Do not supply your own snapshot, prior record or coverage.
4. **Persist through the logger.** The logger recomputes final coverage; never
supply proof yourself. Real defects retain normal Fix-First handling independently.
4. **Persist through the logger.** The logger recomputes coverage; never supply
proof. Real defects retain normal Fix-First handling.
**What a reusable result proves (do not reconstruct these checks yourself):**
- Identity: `sharedLibsFingerprint` plus the actual repo, raw branch and current snapshot.
**What a reusable result proves (never reconstruct these checks):**
- Identity: `sharedLibsFingerprint` plus the repo, raw branch and current snapshot.
The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity.
- Prior decision: completed/converged review, verified binding, explicit Skip and
logger-versioned `snapshot_covered_paths`; older unversioned coverage needs a fresh decision.
logger-versioned `snapshot_covered_paths`; unversioned coverage needs a fresh decision.
- Source: `canReuseSharedLibsAdvisory` requires every supporting path's raw file
byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index
entries; symlinks/ancestors, submodules, ignored/outside or unreadable files;
+3
View File
@@ -125,6 +125,8 @@ interface GenerateOptions {
root?: string;
/** When true, missing skill description should fail the build. */
strict?: boolean;
/** Skill directories a module loadout leaves out (scripts/modules.ts). */
excludedSkills?: string[];
}
export interface GenerateResult {
@@ -143,6 +145,7 @@ export async function generateLlmsTxt(opts: GenerateOptions = {}): Promise<Gener
const skills: SkillEntry[] = [];
for (const t of templates) {
if (!includesSkill(claudeHost, path.dirname(t.tmpl))) continue;
if (opts.excludedSkills?.includes(path.dirname(t.tmpl))) continue;
const filePath = path.join(root, t.tmpl);
const entry = parseSkillFrontmatter(filePath);
if (!entry) {
+34 -5
View File
@@ -23,7 +23,9 @@ import * as path from 'path';
import type { Host, TemplateContext } from './resolvers/types';
import { HOST_PATHS } from './resolvers/types';
import { RESOLVERS } from './resolvers/index';
import { rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections';
import { applySectionGuards, rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections';
import { ModuleError, excludedSkillDirs, selectModules } from './modules';
import type { ModuleFilter } from './resolvers/types';
import { insertRuntimePreludes } from './resolvers/runtime-root';
import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist';
import { extractVoiceTriggers } from './resolvers/voice-triggers';
@@ -54,6 +56,8 @@ export interface GenerationOptions {
respectDetection?: boolean;
log?: (message: string) => void;
planMode?: boolean;
/** Module loadout (scripts/modules.ts selectModules); null/undefined = `full`. */
modules?: ModuleFilter | null;
}
interface RenderOptions {
@@ -66,6 +70,7 @@ interface RenderOptions {
explainLevel: 'default' | 'terse';
gbrainDetected: boolean;
planMode: boolean;
modules: ModuleFilter | null;
}
export interface GeneratedArtifact {
@@ -144,6 +149,10 @@ function parseGenerationArgs(args: string[]): GenerationOptions {
const disabledSkills = (disabledValue ?? '').split(/[\s,]+/).filter(Boolean);
const badDisabled = disabledSkills.find(n => !/^\/?[a-z0-9-]+$/.test(n));
if (badDisabled) throw new Error(`--disabled-skills takes skill names (got ${JSON.stringify(badDisabled)})`);
const modules = selectModules({ loadout: value('--loadout'), modules: value('--modules') });
if (modules && (outDir === undefined || path.resolve(outDir) === ROOT)) {
throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs an --out-dir outside the checkout root; the committed tree is always the full render`, 'bin/gstack-modules render --host <host> --modules <spec> --out-dir <dir>');
}
const installRoot = value('--install-root');
if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) {
throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`);
@@ -159,9 +168,21 @@ function parseGenerationArgs(args: string[]): GenerationOptions {
: outDir !== undefined ? path.resolve(outDir) : null,
installRoot: installRoot ?? null,
disabledSkills,
modules,
};
}
/** A section file is left out when its skill or its manifest entry is excluded. */
function excludesSection(filter: ModuleFilter | null, skillDir: string, tmpl: string): boolean {
if (!filter) return false;
if (filter.excluded.has(`skill:${skillDir}`)) return true;
const file = path.basename(tmpl).replace(/\.tmpl$/, '');
const manifest = path.join(ROOT, skillDir, 'sections', 'manifest.json');
if (!fs.existsSync(manifest)) return false;
const entry = (JSON.parse(fs.readFileSync(manifest, 'utf-8')).sections ?? []).find((s: { file: string }) => s.file === file);
return entry ? filter.excluded.has(`section:${skillDir}/${entry.id}`) : false;
}
/** Install roots are spliced into shell lines unquoted, so only plain absolute paths. */
const INSTALL_ROOT_PATTERN = /^\/[A-Za-z0-9_.@+\/-]*$/;
@@ -657,7 +678,7 @@ function resolvePlaceholders(
// section generation so both paths get the same gbrain-aware behavior.
const suppressed = effectiveSuppressedResolvers(hostConfig, options);
const onePass = (input: string): string =>
input.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
applySectionGuards(input, ctx).replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
const parts = fullKey.split(':');
const resolverName = parts[0];
const args = parts.slice(1);
@@ -713,6 +734,7 @@ function buildContext(
return {
skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host],
preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, installRoot: options.installRoot, planMode: options.planMode,
...(options.modules ? { modules: options.modules } : {}),
};
}
@@ -842,7 +864,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions):
}
// Prepend generated header (after frontmatter)
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath)) + (options.modules ? `<!-- loadout: ${options.modules.name} -->\n` : '');
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
if (fmEnd !== -1) {
const insertAt = content.indexOf('\n', fmEnd) + 1;
@@ -859,7 +881,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions):
// --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot);
if (skillDir === '' || skillDir === '.') content = omitDisabledSkills(content, options.disabledSkills);
if (skillDir === '' || skillDir === '.') content = omitDisabledSkills(content, [...options.disabledSkills, ...excludedSkillDirs(options.modules)]);
content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), currentHostConfig, options.installRoot);
const preamble = hoistsPreamble(ctx) && tmplContent.includes('{{PREAMBLE}}') && !effectiveSuppressedResolvers(currentHostConfig, options).has('PREAMBLE')
@@ -958,6 +980,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
explainLevel: settings.explainLevel ?? 'default',
gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false),
planMode: settings.planMode ?? true,
modules: settings.modules ?? null,
};
const hosts = settings.host === 'all' ? ALL_HOST_NAMES as Host[] : [settings.host ?? 'claude'];
const log = settings.log ?? (() => {});
@@ -1023,6 +1046,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
for (const template of templates) {
const skillDir = path.dirname(template.tmpl);
if (!includesSkill(hostConfig, skillDir)) continue;
if (options.modules?.excluded.has(`skill:${skillDir === '.' ? 'gstack' : skillDir}`)) continue;
const result = processTemplate(path.join(ROOT, template.tmpl), host, options);
const relativePath = rel(result.outputPath);
if (host !== 'claude') renderedNames.add(path.basename(path.dirname(result.outputPath)));
@@ -1050,6 +1074,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
for (const section of sections) {
if (!includesSkill(hostConfig, section.skillDir) || !usesLazySections(host, section.skillDir)) continue;
if (excludesSection(options.modules, section.skillDir, section.tmpl)) continue;
const result = processSectionTemplate(path.join(ROOT, section.tmpl), section.skillDir, host, options);
emit(result.outputPath, result.content, 'section', host);
tokenBudget.push({ skill: rel(result.outputPath), lines: result.content.split('\n').length, tokens: Math.round(result.content.length / 4) });
@@ -1126,7 +1151,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
// Shared artifacts are awaited in both modes. A failure must reach the CLI
// exit code, never disappear in a fire-and-forget auxiliary writer.
try {
const index = await generateLlmsTxt();
const index = await generateLlmsTxt({ excludedSkills: excludedSkillDirs(options.modules) });
emit(path.join(options.outputRoot, 'gstack', 'llms.txt'), index.content, 'index');
for (const warning of index.warnings) diagnostics.push({ kind: 'warning', message: `[gen-llms-txt] ${warning}` });
} catch (error) {
@@ -1171,6 +1196,10 @@ export async function main(args = process.argv.slice(2)): Promise<number> {
}
return result.exitCode;
} catch (error) {
if (error instanceof ModuleError) {
console.error(`${error.code}: ${error.message}`);
return 2;
}
console.error(`ERROR: ${(error as Error).message}`);
return 1;
}
+1
View File
@@ -52,6 +52,7 @@ const WINDOWS_FRAGILE_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
// the failure mode is structural rather than detectable via source-file scan.
export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [
// Like test/paid-rerun.test.ts (excluded by pattern), it replays a gate capture through the paid lane's --report and --rerun-plan CLI.
{ file: 'test/shared-libs-index-flags-readonly.test.ts', reason: 'its assume-unchanged replay seeds the review through test/helpers/shared-libs-path-fixture.ts, which launches bin/gstack-review-log (a bash script) directly; windows-latest refuses it as not found in $PATH (windows-free-shard 5, run 40727825859)' },
{ file: 'test/credential-rejection.test.ts', reason: 'replays run 39696775546 through the --report and --rerun-plan CLI of the paid lane, which runs only on Linux CI; on windows-latest that CLI exits 1 with EINVAL in open() (windows-free-shard run 39982914852)' },
// Field list 2, W11: the review-voice install is a POSIX launcher contract, exercised end to end on Linux and macOS.
{ file: 'test/capy-review-voice.test.ts', reason: 'copies the checkout with tar -C into the temp root (GNU tar reads a D: path as a remote host) and launches the bash --for review-voice installer with a colon-joined GSTACK_LAUNCH PATH' },
+86
View File
@@ -0,0 +1,86 @@
/**
* bin/gstack-modules (GSTA-140, PR 1a): the module catalog and loadout renders.
*
* gstack-modules list [--json] [--pinned]
* gstack-modules validate
* gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
* [--install-root <abs>] [--model <id>] [--plan-mode on|off]
*
* Exit 0 on success, 2 on a refused input (ModuleError, printed with its code,
* fix and docs anchor), 1 on an unexpected failure.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveHostArg } from '../hosts/index';
import { resolveModel } from './models';
import { LOADOUTS_FILE, ModuleError, loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from './modules';
import { renderLoadout } from './modules-render';
import type { Host } from './resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
const USAGE = `Usage:
gstack-modules list [--json] [--pinned] every module id with its kind, source and pin
gstack-modules validate check ${LOADOUTS_FILE} against the catalog
gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
[--install-root <abs>] [--model <id>] [--plan-mode on|off]
render one loadout and write loadout.lock.json
Guide: docs/loadouts.md`;
export interface CliResult { code: number; stdout: string; stderr: string }
function flag(args: string[], name: string): string | undefined {
const index = args.findIndex(a => a === name || a.startsWith(`${name}=`));
if (index < 0) return undefined;
const value = args[index].startsWith(`${name}=`) ? args[index].slice(name.length + 1) : args[index + 1];
if (!value || value.startsWith('--')) throw new ModuleError('MODULE_SPEC_INVALID', `${name} requires a value`, `gstack-modules --help`);
return value;
}
export async function modulesMain(args: string[]): Promise<CliResult> {
const [command, ...rest] = args;
try {
if (!command || command === '--help' || command === '-h' || command === 'help') return { code: 0, stdout: USAGE + '\n', stderr: '' };
if (command === 'list') {
const manifest = loadLoadouts();
const catalog = moduleCatalog(manifest).filter(e => !rest.includes('--pinned') || e.pinned);
const rows = catalog.map(e => {
const bytes = fs.readFileSync(path.join(ROOT, e.source));
return { ...e, source_bytes: bytes.length, source_sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
});
if (rest.includes('--json')) return { code: 0, stdout: JSON.stringify({ loadouts: Object.keys(manifest.loadouts), modules: rows }, null, 2) + '\n', stderr: '' };
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : ' '} ${r.source}`);
return { code: 0, stdout: [`loadouts: ${Object.keys(manifest.loadouts).join(', ')}`, ...lines].join('\n') + '\n', stderr: '' };
}
if (command === 'validate') {
const manifest = loadLoadouts();
const problems = validateLoadouts(manifest, moduleCatalog(manifest));
if (problems.length) return { code: 2, stdout: '', stderr: problems.map(p => `LOADOUTS_INVALID: ${p}`).join('\n') + '\n' };
return { code: 0, stdout: `LOADOUTS: ok (${Object.keys(manifest.loadouts).length} loadouts)\n`, stderr: '' };
}
if (command === 'render') {
const hostValue = flag(rest, '--host');
const outDir = flag(rest, '--out-dir');
if (!hostValue || !outDir) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --host and --out-dir', 'gstack-modules render --host claude --loadout full --out-dir <dir>');
let host: Host;
try { host = resolveHostArg(hostValue) as Host; } catch (error) { throw new ModuleError('MODULE_SPEC_INVALID', (error as Error).message, 'pass a host from docs/hosts.md, for example --host claude'); }
const loadout = flag(rest, '--loadout');
const modules = flag(rest, '--modules');
if (loadout === undefined && modules === undefined) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --loadout or --modules', 'add --loadout full for the control arm');
const modelValue = flag(rest, '--model');
const model = modelValue === undefined ? null : resolveModel(modelValue);
if (modelValue !== undefined && !model) throw new ModuleError('MODULE_SPEC_INVALID', `--model ${JSON.stringify(modelValue.slice(0, 80))} is not a model gstack knows`, 'pass the same --model setup uses, for example gpt-6-astra');
const planMode = flag(rest, '--plan-mode');
if (planMode !== undefined && planMode !== 'on' && planMode !== 'off') throw new ModuleError('MODULE_SPEC_INVALID', '--plan-mode takes on or off', 'drop it, or pass --plan-mode off');
const installRoot = flag(rest, '--install-root');
if (installRoot !== undefined && !/^\/[A-Za-z0-9_.@+\/-]*$/.test(installRoot)) throw new ModuleError('MODULE_SPEC_INVALID', '--install-root must be a plain absolute path', 'pass the absolute install root setup would use');
const selection = selectModules({ loadout, modules });
const lock = await renderLoadout({ host, selection, outDir, installRoot: installRoot ?? null, model, planMode: planMode !== 'off' });
return { code: 0, stdout: `RENDERED: loadout=${lock.loadout} host=${host} files=${lock.files.length} bytes=${lock.total_bytes} excluded=${lock.excluded.length} lock=${path.join(path.resolve(outDir), 'loadout.lock.json')}\n`, stderr: '' };
}
return { code: 2, stdout: '', stderr: `Unknown command ${JSON.stringify(command.slice(0, 40))}.\n${USAGE}\n` };
} catch (error) {
if (error instanceof ModuleError) return { code: 2, stdout: '', stderr: `${error.code}: ${error.message}\n` };
return { code: 1, stdout: '', stderr: `gstack-modules: unexpected failure: ${(error as Error).message}\n` };
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* gstack-modules render (GSTA-140, PR 1a): one loadout rendered into a
* directory GSTA-2 can preregister, published only when complete.
*
* Order: render into an owned staging directory beside --out-dir (Claude
* section links already name the final path), refuse dangling references to
* an excluded module, hash every file into loadout.lock.json (written last),
* then swap the staging directory into place. Any failure leaves the previous
* --out-dir untouched and removes the staging directory.
*/
import { createHash, randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { runGeneration } from './gen-skill-docs';
import { ModuleError, type ModuleSelection } from './modules';
import type { Host } from './resolvers/types';
import { getHostConfig } from '../hosts/index';
import type { Model } from './models';
export const LOCK_FILE = 'loadout.lock.json';
const ROOT = path.resolve(import.meta.dir, '..');
export interface RenderRequest {
host: Host;
/** null = the full loadout. */
selection: ModuleSelection | null;
outDir: string;
installRoot?: string | null;
model?: Model | null;
planMode?: boolean;
}
export interface LoadoutLock {
schema_version: 1;
loadout: string;
spec: string;
spec_sha256: string | null;
host: Host;
model: string | null;
install_root: string | null;
plan_mode: boolean;
gstack_version: string;
excluded: string[];
total_bytes: number;
files: Array<{ path: string; bytes: number; sha256: string }>;
}
function walk(dir: string, base = dir): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full, base);
return entry.isFile() ? [path.relative(base, full).split(path.sep).join('/')] : [];
}).sort();
}
/**
* References an excluded module leaves behind in the rendered text. A section
* counts as referenced by: its absolute path (`/<skill>/sections/<file>`, any
* file, so resolver-inlined cross-skill pointers count), an installed-relative
* pointer naming its skill, or, inside its own skill's directory, a bare
* `sections/<file>` or the inline-host "section inlined" phrase. An excluded
* skill counts as referenced by another skill naming its SKILL.md or one of
* its section files.
*/
export function danglingReferences(stage: string, selection: ModuleSelection): string[] {
const checks: Array<{ id: string; scope: string | null; test: (line: string) => boolean }> = [];
for (const id of selection.excluded) {
if (id.startsWith('section:')) {
const [skill, sectionId] = id.slice('section:'.length).split('/');
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections', 'manifest.json'), 'utf-8'));
const file = (manifest.sections as Array<{ id: string; file: string }>).find(entry => entry.id === sectionId)!.file;
const stem = file.replace(/\.md$/, '');
checks.push(
{ id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) },
{ id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) },
{ id, scope: skill, test: line => line.includes(`sections/${file}`) || line.includes(`the \`${stem}\` section inlined`) },
);
} else if (id.startsWith('skill:')) {
const skill = id.slice('skill:'.length);
checks.push({ id, scope: null, test: line => line.includes(`/${skill}/SKILL.md`) || line.includes(`gstack-${skill}/SKILL.md`) });
const manifestPath = path.join(ROOT, skill, 'sections', 'manifest.json');
if (fs.existsSync(manifestPath)) {
for (const { file } of JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections as Array<{ file: string }>) {
checks.push(
{ id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) },
{ id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) },
);
}
}
}
}
if (!checks.length) return [];
const found = new Set<string>();
for (const rel of walk(stage)) {
if (!rel.endsWith('.md')) continue;
const dirs = rel.split('/');
const lines = fs.readFileSync(path.join(stage, rel), 'utf-8').split('\n');
lines.forEach((line, index) => {
for (const check of checks) {
if (check.scope && !dirs.includes(check.scope) && !dirs.includes(`gstack-${check.scope}`)) continue;
if (check.test(line)) found.add(`${check.id}: referenced by ${rel}:${index + 1}`);
}
});
}
return [...found];
}
function owned(dir: string): boolean {
if (!fs.existsSync(dir)) return true;
return fs.readdirSync(dir).length === 0 || fs.existsSync(path.join(dir, LOCK_FILE));
}
export async function renderLoadout(request: RenderRequest): Promise<LoadoutLock> {
const final = path.resolve(request.outDir);
if (!owned(final)) {
throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} exists and holds no ${LOCK_FILE}, so it is not a gstack-modules render`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote');
}
const tag = `${process.pid}-${randomBytes(4).toString('hex')}`;
const stage = `${final}.stage-${tag}`;
const retired = `${final}.old-${tag}`;
fs.mkdirSync(path.dirname(final), { recursive: true });
try {
const result = await runGeneration({
host: request.host,
outputRoot: stage,
contentLinkRoot: final,
installRoot: request.installRoot ?? null,
model: request.model ?? null,
planMode: request.planMode ?? true,
modules: request.selection,
});
const errors = result.diagnostics.filter(d => d.kind === 'error').map(d => d.message);
if (result.exitCode !== 0 || errors.length) {
throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry');
}
if (request.selection) {
const dangling = danglingReferences(stage, request.selection);
if (dangling.length) {
throw new ModuleError('MODULE_EXCLUSION_UNSAFE', dangling.slice(0, 5).join('; ') + (dangling.length > 5 ? ` (+${dangling.length - 5} more)` : ''), 'exclude the referencing module too, or guard the reference with {{IF_SECTION:id}} in its template; side effects: none, safe to retry');
}
}
const files = walk(stage).map(rel => {
const bytes = fs.readFileSync(path.join(stage, rel));
return { path: rel, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
});
const lock: LoadoutLock = {
schema_version: 1,
loadout: request.selection?.name ?? 'full',
spec: request.selection?.spec ?? 'full',
spec_sha256: request.selection?.specSha256 ?? null,
host: request.host,
model: request.model ?? getHostConfig(request.host).defaultModel ?? null,
install_root: request.installRoot ?? null,
plan_mode: request.planMode ?? true,
gstack_version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf-8').trim(),
excluded: [...(request.selection?.excluded ?? [])].sort(),
total_bytes: files.reduce((sum, f) => sum + f.bytes, 0),
files,
};
fs.writeFileSync(path.join(stage, LOCK_FILE), JSON.stringify(lock, null, 2) + '\n');
if (!owned(final)) {
throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} changed during the render and now holds no ${LOCK_FILE}`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote');
}
if (fs.existsSync(final)) fs.renameSync(final, retired);
try {
fs.renameSync(stage, final);
} catch (error) {
if (fs.existsSync(retired)) fs.renameSync(retired, final);
throw error;
}
fs.rmSync(retired, { recursive: true, force: true });
return lock;
} finally {
fs.rmSync(stage, { recursive: true, force: true });
}
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Module loadouts (GSTA-140, PR 1a): the toggleable units of gstack and the
* named sets of them a render can load.
*
* A module is a skill (`skill:<dir>`), one carved section
* (`section:<skill>/<id>`, from <skill>/sections/manifest.json), one model
* overlay (`overlay:<name>`, from model-overlays/) or one preamble piece
* (`preamble:<id>`, from PREAMBLE_PIECES). The catalog is derived from those
* sources; modules/loadouts.json is the only hand-written file and holds the
* rungs, the model-id to rung table, the pinned ids and the loadouts.
*
* Every error is a ModuleError with a stable code, a fix line and a
* docs/troubleshooting.md anchor. Nothing here ever falls back to `full` on
* bad input: an eval arm that silently rendered `full` would measure the
* control.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { discoverTemplates } from './discover-skills';
import { PREAMBLE_MODULE_IDS } from './resolvers/preamble';
import type { ModuleFilter } from './resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
export const LOADOUTS_FILE = 'modules/loadouts.json';
export const RUNGS = ['frontier', 'strong', 'mid', 'light'] as const;
export type Rung = (typeof RUNGS)[number] | 'unknown';
export const FULL = 'full';
/** The router and the way back can never be left out (same rule as --disabled-skills). */
export const IMPLICIT_PINS = ['skill:gstack', 'skill:gstack-upgrade', 'preamble:bash'];
const NAME_RE = /^[a-z0-9-]+$/;
export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble';
export interface ModuleEntry {
id: string;
kind: ModuleKind;
/** Repository-relative source file. */
source: string;
pinned: boolean;
}
export interface LoadoutDef {
base?: string;
include?: '*' | string[];
exclude?: string[];
preamble_tier_max?: number;
note?: string;
}
export interface LoadoutsManifest {
version: 1;
aliases: Record<string, Rung>;
rungs: Record<(typeof RUNGS)[number], string[]>;
pinned: string[];
loadouts: Record<string, LoadoutDef>;
default: Record<Rung, string>;
}
export type ModuleErrorCode =
| 'LOADOUTS_INVALID'
| 'LOADOUT_UNKNOWN'
| 'MODULE_SPEC_INVALID'
| 'MODULE_UNKNOWN'
| 'MODULE_PINNED'
| 'MODULE_EXCLUSION_UNSAFE'
| 'RENDER_FAILED'
| 'RENDER_DEST_NOT_OWNED';
export class ModuleError extends Error {
constructor(readonly code: ModuleErrorCode, problem: string, readonly fix: string) {
super(`${problem}; fix: ${fix} (${code}; docs/troubleshooting.md#${code.toLowerCase().replaceAll('_', '-')})`);
}
}
const clamp = (value: string) => JSON.stringify(value.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 120));
export function loadLoadouts(root = ROOT): LoadoutsManifest {
const file = path.join(root, LOADOUTS_FILE);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8')) as LoadoutsManifest;
} catch (error) {
throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE} is unreadable or not JSON (${(error as Error).message})`, `restore it with git checkout -- ${LOADOUTS_FILE}`);
}
}
/** Every module id with its kind and source, derived from the files that define them. */
export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEntry[] {
const pinned = new Set([...manifest.pinned, ...IMPLICIT_PINS]);
const entries: Omit<ModuleEntry, 'pinned'>[] = [];
for (const { tmpl } of discoverTemplates(root)) {
const dir = path.dirname(tmpl);
entries.push({ id: `skill:${dir === '.' ? 'gstack' : dir}`, kind: 'skill', source: tmpl });
const manifestPath = path.join(root, dir, 'sections', 'manifest.json');
if (dir === '.' || !fs.existsSync(manifestPath)) continue;
const sections = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections ?? []) as Array<{ id: string; file: string }>;
for (const s of sections) entries.push({ id: `section:${dir}/${s.id}`, kind: 'section', source: `${dir}/sections/${s.file}.tmpl` });
}
for (const file of fs.readdirSync(path.join(root, 'model-overlays')).filter(f => f.endsWith('.md')).sort()) {
entries.push({ id: `overlay:${file.slice(0, -3)}`, kind: 'overlay', source: `model-overlays/${file}` });
}
for (const id of PREAMBLE_MODULE_IDS) entries.push({ id, kind: 'preamble', source: 'scripts/resolvers/preamble.ts' });
return entries.map(e => ({ ...e, pinned: pinned.has(e.id) }));
}
export function normalizeModelId(raw: string): string {
return raw.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim().replace(/^[a-z0-9-]+\//, '');
}
const matches = (pattern: string, id: string) => (pattern.endsWith('*') ? id.startsWith(pattern.slice(0, -1)) : id === pattern);
/** The rung a model id belongs to: aliases first, then the first rung whose pattern matches. */
export function rungFor(manifest: LoadoutsManifest, raw: string): Rung {
const id = normalizeModelId(raw);
if (Object.hasOwn(manifest.aliases, id)) return manifest.aliases[id];
for (const rung of RUNGS) if (manifest.rungs[rung].some(p => matches(p, id))) return rung;
return 'unknown';
}
/** Every problem with the manifest, or [] (free test and gstack-modules validate). */
export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntry[], corpus: string[] = []): string[] {
const problems: string[] = [];
const ids = new Set(catalog.map(e => e.id));
const pinned = new Set([...(manifest.pinned ?? []), ...IMPLICIT_PINS]);
if (manifest.version !== 1) problems.push(`version must be 1 (got ${clamp(String(manifest.version))})`);
for (const rung of RUNGS) if (!Array.isArray(manifest.rungs?.[rung])) problems.push(`rungs.${rung} must be a list of model-id patterns`);
for (const [alias, rung] of Object.entries(manifest.aliases ?? {})) {
if (!(RUNGS as readonly string[]).includes(rung)) problems.push(`aliases.${alias} names no rung (${clamp(rung)})`);
}
for (const id of manifest.pinned ?? []) if (!ids.has(id)) problems.push(`pinned id ${clamp(id)} is not a module`);
const loadouts = manifest.loadouts ?? {};
const has = (name: string | undefined) => name !== undefined && Object.hasOwn(loadouts, name);
if (!has(FULL) || loadouts[FULL].include !== '*') problems.push('loadouts.full must exist with include "*"');
for (const [name, def] of Object.entries(loadouts)) {
if (!NAME_RE.test(name)) problems.push(`loadout name ${clamp(name)} must match ^[a-z0-9-]+$`);
if (def.base !== undefined && !has(def.base)) problems.push(`loadouts.${name}.base names no loadout (${clamp(def.base)})`);
for (const id of [...(def.exclude ?? []), ...(Array.isArray(def.include) ? def.include : [])]) {
if (!ids.has(id)) problems.push(`loadouts.${name} names ${clamp(id)}, which is not a module`);
}
for (const id of def.exclude ?? []) {
if (pinned.has(id)) problems.push(`loadouts.${name} excludes pinned ${id}`);
const child = id.startsWith('skill:') ? [...pinned].find(p => p.startsWith(`section:${id.slice('skill:'.length)}/`)) : undefined;
if (child) problems.push(`loadouts.${name} excludes ${id}, which holds pinned ${child}`);
}
if (def.preamble_tier_max !== undefined && ![1, 2, 3, 4].includes(def.preamble_tier_max)) problems.push(`loadouts.${name}.preamble_tier_max must be 1-4`);
const seen = new Set<string>();
for (let at: string | undefined = name; at && has(at); at = loadouts[at].base) {
if (seen.has(at)) { problems.push(`loadouts.${name} has a base cycle through ${at}`); break; }
seen.add(at);
}
}
for (const rung of [...RUNGS, 'unknown'] as const) {
if (!has(manifest.default?.[rung])) problems.push(`default.${rung} names no loadout (${clamp(String(manifest.default?.[rung]))})`);
}
for (const raw of corpus) {
const id = normalizeModelId(raw);
const hits = RUNGS.filter(rung => manifest.rungs[rung]?.some(p => matches(p, id)));
if (hits.length > 1) problems.push(`model ${id} matches more than one rung (${hits.join(', ')})`);
}
return problems;
}
function assertValid(manifest: LoadoutsManifest, catalog: ModuleEntry[]): void {
const problems = validateLoadouts(manifest, catalog);
if (problems.length) throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE}: ${problems.join('; ')}`, 'fix the named entries, then run gstack-modules validate');
}
function excludedBy(manifest: LoadoutsManifest, name: string): { excluded: Set<string>; tierMax?: number } {
const def = manifest.loadouts[name];
const inherited = def.base ? excludedBy(manifest, def.base) : { excluded: new Set<string>() };
const excluded = new Set([...inherited.excluded, ...(def.exclude ?? [])]);
if (Array.isArray(def.include)) for (const id of def.include) excluded.delete(id);
return { excluded, tierMax: def.preamble_tier_max ?? inherited.tierMax };
}
export interface ModuleSelection extends ModuleFilter {
/** The normalized spec (`<loadout>` or `<base>,-id,+id`) and its sha256, for the lock file. */
spec: string;
specSha256: string;
}
/**
* Resolve `--loadout <name>` or `--modules <base>,-id,+id` to a filter.
* Returns null for the plain `full` loadout, which renders today's bytes.
*/
export function selectModules(input: { loadout?: string; modules?: string }, root = ROOT): ModuleSelection | null {
if (input.loadout !== undefined && input.modules !== undefined) {
throw new ModuleError('MODULE_SPEC_INVALID', '--loadout and --modules were both given', 'pass one: --loadout <name> or --modules <base>,-id,+id');
}
const text = (input.modules ?? input.loadout ?? '').trim();
if (!text) return null;
const manifest = loadLoadouts(root);
const catalog = moduleCatalog(manifest, root);
assertValid(manifest, catalog);
const [base, ...edits] = text.split(',').map(t => t.trim());
const names = Object.keys(manifest.loadouts).sort().join(', ');
if (!Object.hasOwn(manifest.loadouts, base)) {
throw new ModuleError('LOADOUT_UNKNOWN', `${clamp(base)} is not a loadout (valid: ${names})`, 'gstack-modules list');
}
if (input.loadout !== undefined && edits.length) {
throw new ModuleError('MODULE_SPEC_INVALID', `--loadout takes one name (got ${clamp(text)})`, 'use --modules <base>,-id,+id for edits');
}
const { excluded, tierMax } = excludedBy(manifest, base);
const ids = new Map(catalog.map(e => [e.id, e]));
for (const edit of edits) {
const op = edit[0];
const id = edit.slice(1);
if ((op !== '-' && op !== '+') || !id) {
throw new ModuleError('MODULE_SPEC_INVALID', `${clamp(edit)} is not -<module id> or +<module id>`, 'write the spec as <loadout>,-<id>,+<id>; list ids with gstack-modules list');
}
const entry = ids.get(id);
if (!entry) throw new ModuleError('MODULE_UNKNOWN', `${clamp(id)} is not a module`, 'gstack-modules list');
if (op === '-' && entry.pinned) {
throw new ModuleError('MODULE_PINNED', `${id} is pinned and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`);
}
const pinnedChild = op === '-' && id.startsWith('skill:') ? catalog.find(e => e.pinned && e.id.startsWith(`section:${id.slice('skill:'.length)}/`)) : undefined;
if (pinnedChild) {
throw new ModuleError('MODULE_PINNED', `${id} holds pinned ${pinnedChild.id} and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`);
}
if (op === '-') excluded.add(id); else excluded.delete(id);
}
if (excluded.size === 0 && tierMax === undefined && base === FULL) return null;
const spec = edits.length ? [base, ...edits].join(',') : base;
return {
name: edits.length ? 'adhoc' : base,
excluded,
pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)),
preambleTierMax: tierMax,
spec,
specSha256: createHash('sha256').update(spec).digest('hex'),
};
}
/** Skill directories a selection leaves out (`skill:gstack` is the root template). */
export function excludedSkillDirs(filter: ModuleFilter | null | undefined): string[] {
return [...(filter?.excluded ?? [])].filter(id => id.startsWith('skill:')).map(id => id.slice('skill:'.length));
}
+4 -3
View File
@@ -24,9 +24,10 @@ const OVERLAY_DIR = path.resolve(import.meta.dir, '../../model-overlays');
const INHERIT_RE = /^\s*\{\{INHERIT:([a-z0-9-]+(?:\.[0-9]+)*)\}\}\s*\n/;
export function readOverlay(model: string, seen: Set<string> = new Set()): string {
export function readOverlay(model: string, seen: Set<string> = new Set(), excluded: ReadonlySet<string> = new Set()): string {
if (seen.has(model)) return ''; // cycle guard
seen.add(model);
if (excluded.has(`overlay:${model}`)) return '';
const filePath = path.join(OVERLAY_DIR, `${model}.md`);
if (!fs.existsSync(filePath)) return '';
@@ -36,7 +37,7 @@ export function readOverlay(model: string, seen: Set<string> = new Set()): strin
if (!match) return raw.trim();
const baseModel = match[1];
const base = readOverlay(baseModel, seen);
const base = readOverlay(baseModel, seen, excluded);
const rest = raw.replace(INHERIT_RE, '').trim();
if (!base) return rest;
@@ -46,7 +47,7 @@ export function readOverlay(model: string, seen: Set<string> = new Set()): strin
export function generateModelOverlay(ctx: TemplateContext): string {
if (!ctx.model) return '';
const content = readOverlay(ctx.model);
const content = readOverlay(ctx.model, new Set(), ctx.modules?.excluded);
if (!content) return '';
const precedence = ctx.model === 'gpt-5.6-sol'
+52 -37
View File
@@ -72,6 +72,47 @@ export function generatePreamble(ctx: TemplateContext): string {
return preambleSections(ctx).join('\n\n');
}
interface PreamblePiece {
/** Module id suffix (`preamble:<id>`); null for the model overlay, whose module id is `overlay:<model>`. */
id: string | null;
minTier: number;
when?: (ctx: TemplateContext) => boolean;
render: (ctx: TemplateContext, tier: number) => string;
}
/**
* The preamble's pieces in render order. AskUserQuestion Format renders BEFORE
* the model overlay so the pacing rule is the ambient default; the overlay's
* nudges land as subordinate patches (Opus 4.7 absorbs the first pacing
* directive it hits; reversing this regresses plan-review cadence, v1.6.4.0).
* Plan-mode semantics stays right after bash (so _SESSION_ID / _BRANCH / _TEL
* are live) and before every onboarding gate. The 8 one-time onboarding
* generators live in bin/gstack-skill-start's instruction layer (plan Q2/OV6/F5).
*/
export const PREAMBLE_PIECES: readonly PreamblePiece[] = [
{ id: 'bash', minTier: 1, render: generatePreambleBash },
{ id: 'make-pdf-setup', minTier: 1, when: ctx => ctx.skillName === 'make-pdf', render: generateMakePdfSetup },
{ id: 'plan-mode-info', minTier: 1, render: generatePlanModeInfo },
{ id: 'upgrade-check', minTier: 1, render: generateUpgradeCheck },
{ id: 'brain-health', minTier: 1, render: generateBrainHealthInstruction },
{ id: 'ask-user-format', minTier: 2, render: generateAskUserFormat },
{ id: 'brain-sync', minTier: 1, render: generateBrainSyncBlock },
{ id: null, minTier: 1, render: generateModelOverlay },
{ id: 'voice', minTier: 1, render: generateVoiceDirective },
{ id: 'context-recovery', minTier: 2, render: generateContextRecovery },
{ id: 'writing-style', minTier: 2, render: generateWritingStyle },
{ id: 'completeness', minTier: 2, render: generateCompletenessSection },
{ id: 'confusion', minTier: 2, render: generateConfusionProtocol },
{ id: 'evidence', minTier: 2, render: generateEvidenceDirective },
{ id: 'context-health', minTier: 2, render: generateContextHealth },
{ id: 'question-tuning', minTier: 2, render: generateQuestionTuning },
{ id: 'repo-mode', minTier: 3, render: () => generateRepoModeSection() },
{ id: 'search-before-building', minTier: 3, render: generateSearchBeforeBuildingSection },
{ id: 'completion-status', minTier: 1, render: generateCompletionStatus },
];
export const PREAMBLE_MODULE_IDS: readonly string[] = PREAMBLE_PIECES.flatMap(p => (p.id ? [`preamble:${p.id}`] : []));
/** The preamble's generator outputs in render order, empty ones dropped. */
export function preambleSections(ctx: TemplateContext): string[] {
const tier = ctx.preambleTier;
@@ -84,41 +125,15 @@ export function preambleSections(ctx: TemplateContext): string[] {
if (tier < 1 || tier > 4) {
throw new Error(`Invalid preamble-tier: ${tier} in ${ctx.tmplPath}. Must be 1-4.`);
}
const sections = [
generatePreambleBash(ctx),
...(ctx.skillName === 'make-pdf' ? [generateMakePdfSetup(ctx)] : []),
// Plan-mode-skill semantics stays near the top: after bash (so _SESSION_ID /
// _BRANCH / _TEL env vars are live) and before all onboarding gates so
// models read the authoritative "AskUserQuestion satisfies plan mode's
// end-of-turn" rule before any other instruction. Renders for all skills
// (not interactive-gated) on hosts whose capabilities declare planMode.
generatePlanModeInfo(ctx),
generateUpgradeCheck(ctx),
// Phase 2: the 8 one-time onboarding generators (lake-intro, telemetry-
// prompt, proactive-prompt, first-run-guidance, routing-injection,
// vendoring-deprecation, spawned-session-check, writing-style-migration)
// moved into bin/gstack-skill-start's instruction-emission layer — their
// text appears at runtime only when its gate fires (plan Q2/OV6/F5).
generateBrainHealthInstruction(ctx),
// AskUserQuestion Format renders BEFORE the model overlay so the pacing rule
// is the ambient default; the overlay's behavioral nudges land as subordinate
// patches. Opus 4.7 reads top-to-bottom and absorbs the first pacing directive
// it hits; reversing this order regresses plan-review cadence (v1.6.4.0 bug).
...(tier >= 2 ? [generateAskUserFormat(ctx)] : []),
generateBrainSyncBlock(ctx),
generateModelOverlay(ctx),
generateVoiceDirective(ctx, tier),
...(tier >= 2 ? [
generateContextRecovery(ctx),
generateWritingStyle(ctx),
generateCompletenessSection(ctx),
generateConfusionProtocol(ctx),
generateEvidenceDirective(ctx),
generateContextHealth(ctx),
generateQuestionTuning(ctx),
] : []),
...(tier >= 3 ? [generateRepoModeSection(), generateSearchBeforeBuildingSection(ctx)] : []),
generateCompletionStatus(ctx),
];
return sections.filter(s => s && s.trim().length > 0);
const filter = ctx.modules;
const pieceTier = (piece: PreamblePiece): number => {
if (!filter || piece.id === null || filter.pinned.has(`preamble:${piece.id}`)) return tier;
return Math.min(tier, filter.preambleTierMax ?? tier);
};
return PREAMBLE_PIECES
.filter(piece => (piece.when?.(ctx) ?? true)
&& !(piece.id && filter?.excluded.has(`preamble:${piece.id}`))
&& pieceTier(piece) >= piece.minTier)
.map(piece => piece.render(ctx, pieceTier(piece)))
.filter(s => s && s.trim().length > 0);
}
+11 -10
View File
@@ -133,11 +133,12 @@ or missing-reviewer rules.`;
export function generateSharedCodeReuse(ctx: TemplateContext): string {
return `**Reuse a skipped shared-code advisory only with complete structural evidence:**
1. **Read the evidence.** Read all supporting callers and the helper destination.
Establish first-party authored provenance and whether the current extraction
is worthwhile; the checker cannot decide that. Retain \`evidence_paths\`/\`helper_target\`.
1. **Read the evidence.** Read all supporting callers and the helper target
read-only: \`git update-index\`, even \`--really-refresh\`, clears the index flags
the checker reads. Establish first-party authored provenance and whether this
extraction is worthwhile; the checker cannot. Retain \`evidence_paths\`/\`helper_target\`.
2. **Run the checker.** From the repository root, pass the current finding as
literal JSON on stdin. Replace REVIEW_START with this pass's captured token
literal JSON on stdin. Replace REVIEW_START with this pass's token
and the example paths/symbol with actual evidence. Keep the quoted delimiter.
\`\`\`bash
@@ -146,17 +147,17 @@ export function generateSharedCodeReuse(ctx: TemplateContext): string {
GSTACK_SHARED_LIBS_REUSE_JSON
\`\`\`
3. **Act on its result.** Read the JSON. Only \`reusable: true\` permits suppression.
3. **Act on its result.** Only \`reusable: true\` permits suppression.
False, command failure or unreadable output requires fresh source review and a
new decision, never suppression. Do not supply your own snapshot, prior record or coverage.
4. **Persist through the logger.** The logger recomputes final coverage; never
supply proof yourself. Real defects retain normal Fix-First handling independently.
4. **Persist through the logger.** The logger recomputes coverage; never supply
proof. Real defects retain normal Fix-First handling.
**What a reusable result proves (do not reconstruct these checks yourself):**
- Identity: \`sharedLibsFingerprint\` plus the actual repo, raw branch and current snapshot.
**What a reusable result proves (never reconstruct these checks):**
- Identity: \`sharedLibsFingerprint\` plus the repo, raw branch and current snapshot.
The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity.
- Prior decision: completed/converged review, verified binding, explicit Skip and
logger-versioned \`snapshot_covered_paths\`; older unversioned coverage needs a fresh decision.
logger-versioned \`snapshot_covered_paths\`; unversioned coverage needs a fresh decision.
- Source: \`canReuseSharedLibsAdvisory\` requires every supporting path's raw file
byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index
entries; symlinks/ancestors, submodules, ignored/outside or unreadable files;
+19 -1
View File
@@ -108,6 +108,9 @@ export const SECTION: ResolverFn = (ctx: TemplateContext, args?: string[]): stri
const id = args?.[0];
if (!id) throw new Error('{{SECTION:id}} requires a section id');
const entry = findSection(ctx.skillName, id);
if (ctx.modules?.excluded.has(`section:${ctx.skillName}/${id}`)) {
return `> Not loaded in the ${ctx.modules!.name} loadout: skip ${entry.trigger}. Do not offer it, and treat it as not run.`;
}
if (usesLazySections(ctx.host, ctx.skillName)) {
if (ctx.skillName === 'qa' || ctx.skillName === 'qa-only') {
@@ -129,6 +132,21 @@ export const SECTION: ResolverFn = (ctx: TemplateContext, args?: string[]): stri
return fs.readFileSync(tmplPath, 'utf-8').trimEnd();
};
const SECTION_GUARD_RE = /\{\{IF_SECTION:([a-z0-9-]+)\}\}([\s\S]*?)\{\{\/IF_SECTION\}\}/g;
/**
* {{IF_SECTION:id}}…{{/IF_SECTION}}: skeleton text that belongs to one of this
* skill's sections (an offer, a self-check naming it). The markers vanish and
* the text stays when the section loads, so `full` bytes are unchanged; a
* loadout that excludes the section drops the text with it.
*/
export function applySectionGuards(content: string, ctx: TemplateContext): string {
return content.replace(SECTION_GUARD_RE, (_match, id: string, inner: string) => {
findSection(ctx.skillName, id);
return ctx.modules?.excluded.has(`section:${ctx.skillName}/${id}`) ? '' : inner;
});
}
/**
* {{SECTION_INDEX:skill}} — situation→section table from the passive manifest.
* Lazy skills only; an index would be noise for inlined skills.
@@ -149,7 +167,7 @@ export const SECTION_INDEX: ResolverFn = (ctx: TemplateContext, args?: string[])
'|------|-------------------|',
];
for (const s of manifest.sections) {
if (s.runner_only) continue;
if (s.runner_only || ctx.modules?.excluded.has(`section:${skill}/${s.id}`)) continue;
const reference = skill === 'qa' || skill === 'qa-only' ? sectionPath(ctx, skill, s.id) : `\`sections/${s.file}\``;
if (skill === 'review' && s.id === 'review-army') {
lines.push(`| Select surfaces and read QA methods | Inline in [Step 4](#step-4-critical-pass-core-review); setup and probes run in Step 4.7 |`);
+15
View File
@@ -125,6 +125,21 @@ export interface TemplateContext {
* still apply.
*/
planMode?: boolean;
/**
* Module loadout filter (modules/loadouts.json, gen-skill-docs --loadout/--modules).
* Undefined = the `full` loadout: every module renders and committed bytes are unchanged.
*/
modules?: ModuleFilter;
}
/** A resolved loadout: which module ids it leaves out, and which ids can never be left out. */
export interface ModuleFilter {
/** Loadout name, or `adhoc` for a --modules spec. */
name: string;
excluded: ReadonlySet<string>;
pinned: ReadonlySet<string>;
/** Unpinned preamble pieces render as if the skill's preamble-tier were at most this. */
preambleTierMax?: number;
}
/** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */
+11 -10
View File
@@ -2,11 +2,12 @@
<!-- Regenerate: bun run gen:skill-docs -->
**Reuse a skipped shared-code advisory only with complete structural evidence:**
1. **Read the evidence.** Read all supporting callers and the helper destination.
Establish first-party authored provenance and whether the current extraction
is worthwhile; the checker cannot decide that. Retain `evidence_paths`/`helper_target`.
1. **Read the evidence.** Read all supporting callers and the helper target
read-only: `git update-index`, even `--really-refresh`, clears the index flags
the checker reads. Establish first-party authored provenance and whether this
extraction is worthwhile; the checker cannot. Retain `evidence_paths`/`helper_target`.
2. **Run the checker.** From the repository root, pass the current finding as
literal JSON on stdin. Replace REVIEW_START with this pass's captured token
literal JSON on stdin. Replace REVIEW_START with this pass's token
and the example paths/symbol with actual evidence. Keep the quoted delimiter.
```bash
@@ -15,17 +16,17 @@
GSTACK_SHARED_LIBS_REUSE_JSON
```
3. **Act on its result.** Read the JSON. Only `reusable: true` permits suppression.
3. **Act on its result.** Only `reusable: true` permits suppression.
False, command failure or unreadable output requires fresh source review and a
new decision, never suppression. Do not supply your own snapshot, prior record or coverage.
4. **Persist through the logger.** The logger recomputes final coverage; never
supply proof yourself. Real defects retain normal Fix-First handling independently.
4. **Persist through the logger.** The logger recomputes coverage; never supply
proof. Real defects retain normal Fix-First handling.
**What a reusable result proves (do not reconstruct these checks yourself):**
- Identity: `sharedLibsFingerprint` plus the actual repo, raw branch and current snapshot.
**What a reusable result proves (never reconstruct these checks):**
- Identity: `sharedLibsFingerprint` plus the repo, raw branch and current snapshot.
The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity.
- Prior decision: completed/converged review, verified binding, explicit Skip and
logger-versioned `snapshot_covered_paths`; older unversioned coverage needs a fresh decision.
logger-versioned `snapshot_covered_paths`; unversioned coverage needs a fresh decision.
- Source: `canReuseSharedLibsAdvisory` requires every supporting path's raw file
byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index
entries; symlinks/ancestors, submodules, ignored/outside or unreadable files;
+147
View File
@@ -0,0 +1,147 @@
{
"provenance": {
"kind": "public native AskUserQuestion calls and answers only (observation.json fingerprints[].nativeCall, byte-exact)",
"runId": 40664336141,
"artifact": "paid-slice-6-a1",
"headSha": "00e713837b71189588268b63a0e860c63c48d51c",
"recordedOutcome": "ceiling_reached",
"recordedReviewCount": 1,
"recordedStep0Count": 3,
"recordedPassed": false,
"note": "Opus 5.5 put the Step 0D offer in the title ('I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?') and carried the brief's option letters into the labels ('A) Review all 7 areas (recommended)', 'B) Focus on hierarchy/states/responsive'). The runner reached the review ceiling on the issue-labeled D4 finding, but the focus predicate accepted an all-seven offer only as dimensions/passes in the title or as the leading words of an option label, so the gate failed."
},
"calls": [
{
"preReview": true,
"observedAtMs": 38216,
"call": {
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
"toolUseId": "toolu_01B8NoAdG9DH1cyCwWXnU8Ny",
"questions": [
{
"header": "Routing",
"question": "D1: Should I add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: dashboard plan review on main.\nELI10: gstack works better when your project's CLAUDE.md lists which skill handles which kind of request, for example that design plan reviews go to /plan-design-review. Adding that list means editing CLAUDE.md and committing it. Plan mode is on, so I can't make that edit now. If you choose A, I'll do it after you leave plan mode.\nStakes if we pick wrong: Without the rules you call skills by name yourself. With them, CLAUDE.md gets a new section.\nRecommendation: A because it routes later requests to the right skill without you having to name it.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nPros / cons:\nA) Add routing rules (recommended)\n \u2705 Future requests route to the matching gstack skill without you naming it\n \u2705 This is asked once per project and never comes up again\n \u274c Adds a commit to CLAUDE.md, and it has to wait until plan mode ends\nB) No thanks\n \u2705 CLAUDE.md stays as it is, with no new commit in this repo\n \u2705 You keep full manual control over which skill runs and when\n \u274c You have to type the skill name yourself every time you want one\nNet: automatic routing versus leaving CLAUDE.md unchanged.",
"multiSelect": false,
"options": [
{
"label": "A) Add routing rules (recommended)",
"description": "\u2705 Future requests route to the matching skill without you naming it. \u2705 Asked once per project. \u274c Adds a CLAUDE.md commit, which waits until plan mode ends."
},
{
"label": "B) No thanks",
"description": "\u2705 CLAUDE.md stays as it is, with no new commit. \u2705 You keep manual control of skill calls. \u274c You type skill names yourself every time."
}
]
}
],
"answered": true,
"failed": false,
"answers": {
"D1: Should I add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: dashboard plan review on main.\nELI10: gstack works better when your project's CLAUDE.md lists which skill handles which kind of request, for example that design plan reviews go to /plan-design-review. Adding that list means editing CLAUDE.md and committing it. Plan mode is on, so I can't make that edit now. If you choose A, I'll do it after you leave plan mode.\nStakes if we pick wrong: Without the rules you call skills by name yourself. With them, CLAUDE.md gets a new section.\nRecommendation: A because it routes later requests to the right skill without you having to name it.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nPros / cons:\nA) Add routing rules (recommended)\n \u2705 Future requests route to the matching gstack skill without you naming it\n \u2705 This is asked once per project and never comes up again\n \u274c Adds a commit to CLAUDE.md, and it has to wait until plan mode ends\nB) No thanks\n \u2705 CLAUDE.md stays as it is, with no new commit in this repo\n \u2705 You keep full manual control over which skill runs and when\n \u274c You have to type the skill name yourself every time you want one\nNet: automatic routing versus leaving CLAUDE.md unchanged.": "A) Add routing rules (recommended)"
},
"unansweredQuestionIndices": [],
"answeredAt": "2026-10-28T09:30:34.396Z"
}
},
{
"preReview": true,
"observedAtMs": 87270,
"call": {
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
"toolUseId": "toolu_01XQ2nXMdLz3Q4tQU2LTE5ph",
"questions": [
{
"header": "Focus",
"question": "D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?\nProject/branch/task: /dashboard plan review on main.\nELI10: The plan says how the dashboard behaves but not how it looks or what users notice first. The biggest gaps are panel hierarchy, layout at each screen size, empty-state content, and accessibility for the modal and toasts. Next I'll generate mockups, then work through 7 areas: hierarchy, states, user journey, AI-slop risk, design system, responsive/accessibility, and open decisions.\nStakes if we pick wrong: If I narrow the focus, unreviewed areas get built with default guesses, such as a generic three-card grid.\nRecommendation: A because almost every area has a gap and the plan is small enough to cover in full.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Review all 7 areas (recommended)\n \u2705 Every gap is surfaced before implementation, including accessibility and design-system gaps\n \u2705 The final report scores all 7 areas, so you know exactly what is still open\n \u274c More decision questions, roughly one for each gap found across the 7 areas\nB) Focus on hierarchy, states and responsive only\n \u2705 Fewer questions; the gaps that most affect what users see get fixed first\n \u2705 Gets to a plan you can build from faster, with less back-and-forth\n \u274c Accessibility and design-system gaps stay open and become implementer guesses\nNet: full coverage versus fewer questions.",
"multiSelect": false,
"options": [
{
"label": "A) Review all 7 areas (recommended)",
"description": "\u2705 Every gap surfaced, including accessibility and design system. \u2705 Report scores all 7 areas. \u274c More decision questions."
},
{
"label": "B) Focus on hierarchy/states/responsive",
"description": "\u2705 Fewer questions; the most visible gaps first. \u2705 Faster to a plan you can build from. \u274c Accessibility and design-system gaps left to the implementer."
}
]
}
],
"answered": true,
"failed": false,
"answers": {
"D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?\nProject/branch/task: /dashboard plan review on main.\nELI10: The plan says how the dashboard behaves but not how it looks or what users notice first. The biggest gaps are panel hierarchy, layout at each screen size, empty-state content, and accessibility for the modal and toasts. Next I'll generate mockups, then work through 7 areas: hierarchy, states, user journey, AI-slop risk, design system, responsive/accessibility, and open decisions.\nStakes if we pick wrong: If I narrow the focus, unreviewed areas get built with default guesses, such as a generic three-card grid.\nRecommendation: A because almost every area has a gap and the plan is small enough to cover in full.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Review all 7 areas (recommended)\n \u2705 Every gap is surfaced before implementation, including accessibility and design-system gaps\n \u2705 The final report scores all 7 areas, so you know exactly what is still open\n \u274c More decision questions, roughly one for each gap found across the 7 areas\nB) Focus on hierarchy, states and responsive only\n \u2705 Fewer questions; the gaps that most affect what users see get fixed first\n \u2705 Gets to a plan you can build from faster, with less back-and-forth\n \u274c Accessibility and design-system gaps stay open and become implementer guesses\nNet: full coverage versus fewer questions.": "A) Review all 7 areas (recommended)"
},
"unansweredQuestionIndices": [],
"answeredAt": "2026-10-28T09:31:23.767Z"
}
},
{
"preReview": true,
"observedAtMs": 116281,
"call": {
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
"toolUseId": "toolu_01LxEwUZNTiiwQRydm1C9wCP",
"questions": [
{
"header": "Voices",
"question": "Want outside design voices before the detailed review?\nD3. Project/branch/task: /dashboard plan review on main.\nELI10: I can bring in two independent reviewers before the 7 areas: Codex scoring against strict design rules, and a fresh Claude reading for completeness. That's a cross-check on my review. It isn't required.\nStakes if we pick wrong: Without them, all findings come from one reviewer. With them, the review takes a few more minutes.\nRecommendation: No, proceed without, because the plan is small and every gap still gets its own decision from you.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: a cross-model check versus fewer steps.",
"multiSelect": false,
"options": [
{
"label": "Yes, run outside design voices",
"description": "\u2705 Codex hard-rule + litmus scorecard plus an independent Claude completeness read. \u2705 Disagreements surface with both perspectives in the relevant pass. \u274c Adds a few minutes; Codex may be missing here, in which case only the Claude voice runs and it's tagged single-model."
},
{
"label": "No, proceed without (recommended)",
"description": "\u2705 Straight into the 7 passes with the approved mockup as anchor. \u2705 Fewest steps to a fixed plan. \u274c Findings rest on one reviewer; no cross-model check on slop risk or hierarchy."
}
]
}
],
"answered": true,
"failed": false,
"answers": {
"Want outside design voices before the detailed review?\nD3. Project/branch/task: /dashboard plan review on main.\nELI10: I can bring in two independent reviewers before the 7 areas: Codex scoring against strict design rules, and a fresh Claude reading for completeness. That's a cross-check on my review. It isn't required.\nStakes if we pick wrong: Without them, all findings come from one reviewer. With them, the review takes a few more minutes.\nRecommendation: No, proceed without, because the plan is small and every gap still gets its own decision from you.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: a cross-model check versus fewer steps.": "No, proceed without (recommended)"
},
"unansweredQuestionIndices": [],
"answeredAt": "2026-10-28T09:31:53.125Z"
}
},
{
"preReview": false,
"observedAtMs": 148869,
"call": {
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
"toolUseId": "toolu_016u5bq59ktJnbdvhE61syx8",
"questions": [
{
"header": "Hierarchy",
"question": "D4 (Issue 1): What should users see first, second and third on /dashboard?\nProject/branch/task: /dashboard plan review on main, Pass 1.\nELI10: Right after login, the user scans the page for one obvious thing to do. The plan doesn't rank the three panels, so they will probably ship as three equal boxes and the user has to work out what matters. I'd make unread notifications the main column, put recent activity in a narrower side column, and show the two quick actions as a compact row under the page title.\nStakes if we pick wrong: Users skim past unread items, or the page becomes a generic card grid.\nRecommendation: 1A because notifications are the only panel that needs the user to act (principle 2: every screen has a hierarchy).\nCompleteness: A=10/10, B=9/10, C=4/10\nPros / cons:\nA) Notifications first, activity second, actions row on top (recommended)\n \u2705 Unread items get the widest column and the eye goes to them first\n \u2705 Quick actions sit at the top as the 'where next' step without competing\n \u274c Activity gets a narrower column, so long activity lines truncate sooner\nB) Activity first, notifications in a right rail\n \u2705 A timeline-led page suits users who mainly come back to check what changed\n \u2705 Activity gets full width, so long descriptions stay readable on one line\n \u274c Unread notifications sit in a narrow rail and are easier to skip\nC) Three equal panels, no ranking\n \u2705 Simplest to build, with one shared panel layout reused three times\n \u2705 No product call needed now about which panel matters more than the others\n \u274c Generic SaaS card grid with no first thing to look at, which fails the slop check\nNet: put the panel that needs action first, or put what changed first.",
"multiSelect": false,
"options": [
{
"label": "1A Notifications first (recommended)",
"description": "\u2705 Unread items get the widest column and are seen first. \u2705 Quick actions sit up top as 'where next'. \u274c Activity lines truncate sooner."
},
{
"label": "1B Activity first, notif rail",
"description": "\u2705 Timeline-led, for users checking what changed. \u2705 Activity gets full width. \u274c Unread items in a narrow rail are easier to skip."
},
{
"label": "1C Three equal panels",
"description": "\u2705 Simplest to build, one layout reused. \u2705 No ranking decision needed now. \u274c Generic card grid with no first thing to look at."
}
]
}
],
"answered": true,
"failed": false,
"answers": {
"D4 (Issue 1): What should users see first, second and third on /dashboard?\nProject/branch/task: /dashboard plan review on main, Pass 1.\nELI10: Right after login, the user scans the page for one obvious thing to do. The plan doesn't rank the three panels, so they will probably ship as three equal boxes and the user has to work out what matters. I'd make unread notifications the main column, put recent activity in a narrower side column, and show the two quick actions as a compact row under the page title.\nStakes if we pick wrong: Users skim past unread items, or the page becomes a generic card grid.\nRecommendation: 1A because notifications are the only panel that needs the user to act (principle 2: every screen has a hierarchy).\nCompleteness: A=10/10, B=9/10, C=4/10\nPros / cons:\nA) Notifications first, activity second, actions row on top (recommended)\n \u2705 Unread items get the widest column and the eye goes to them first\n \u2705 Quick actions sit at the top as the 'where next' step without competing\n \u274c Activity gets a narrower column, so long activity lines truncate sooner\nB) Activity first, notifications in a right rail\n \u2705 A timeline-led page suits users who mainly come back to check what changed\n \u2705 Activity gets full width, so long descriptions stay readable on one line\n \u274c Unread notifications sit in a narrow rail and are easier to skip\nC) Three equal panels, no ranking\n \u2705 Simplest to build, with one shared panel layout reused three times\n \u2705 No product call needed now about which panel matters more than the others\n \u274c Generic SaaS card grid with no first thing to look at, which fails the slop check\nNet: put the panel that needs action first, or put what changed first.": "1A Notifications first (recommended)"
},
"unansweredQuestionIndices": [],
"answeredAt": "2026-10-28T09:32:19.757Z"
}
}
]
}
+3
View File
@@ -1,5 +1,8 @@
{
"newModules": [
{ "file": "scripts/modules.ts", "movedFrom": "(new: module loadouts catalog and selection, GSTA-140)" },
{ "file": "scripts/modules-render.ts", "movedFrom": "(new: module loadout render and lock, GSTA-140)" },
{ "file": "scripts/modules-cli.ts", "movedFrom": "(new: bin/gstack-modules CLI, GSTA-140)" },
{ "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" },
{ "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" },
{ "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
+101
View File
@@ -0,0 +1,101 @@
{
"source": "Paid screen 40737741454 (#3553 head 4cd0777) ship-exploratory-unavailable: paid-slice-11-a1 and paid-rerun-1-a1 native-events.json, every Bash command in order (parent and child). In both trials the review plan dispatched the state-machines specialist, and the parent told it to run git diff with the literal merge-base sha.",
"runs": {
"40737741454-slice-11": {
"root": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs",
"mergeBase": "8c9a9c5",
"commands": [
{
"parent": false,
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json 300 2026-10-28T18:00:28.179Z"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json -- bun scripts/probe.ts 4"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-deadline status /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json --after 001 --hypothesis 'Riskiest changed edge: out-of-range 10 must exit 2 with the declared stderr; also confirms whether the missing native-engine prerequisite blocks every CLI path' -- bun scripts/probe.ts 10"
},
{
"parent": false,
"command": "bun run test"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-review-plan --base main"
},
{
"parent": true,
"command": "git diff 8c9a9c5"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports annotations.json"
},
{
"parent": false,
"command": "git status --short"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"issues_found\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":false,\"converged\":false,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish bbf15eec-7962-4aee-bcfd-88233b0375fe"
}
]
},
"40737741454-rerun-1": {
"root": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK",
"mergeBase": "0f7b81c",
"commands": [
{
"parent": false,
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json 300 2026-10-28T18:18:29.666Z"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json -- bun scripts/probe.ts 3"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json --after 001 --hypothesis 'Success path blocked by missing vendor/native-engine.ts; check whether the riskiest changed edge (out-of-range 10) is equally blocked before reaching scale().' -- bun scripts/probe.ts 10"
},
{
"parent": false,
"command": "bun run test"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-review-plan --base main"
},
{
"parent": true,
"command": "git diff 0f7b81c"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports annotations.json"
},
{
"parent": false,
"command": "git status --short"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"issues_found\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"testing\":{\"dispatched\":false,\"reason\":\"scope\"},\"maintainability\":{\"dispatched\":false,\"reason\":\"scope\"},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":false,\"converged\":false,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 4b1c9f3b-d67b-4919-8237-21634b5b4c0b"
}
]
}
}
}
+139
View File
@@ -0,0 +1,139 @@
{
"source": "Paid screen 40707142223 (#3544 head) ship-exploratory-plan-checks: paid-slice-10-a1 and paid-rerun-1-a1 native-events.json, every Bash command in order (parent and child); late-input receipt from train 35 rebuild gate 40664336141 paid-slice-9-a1",
"runs": {
"40707142223-slice-10": {
"root": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE",
"commands": [
{
"parent": false,
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json 300 2026-10-28T14:53:05.952Z"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json -- bun scripts/probe.ts 4"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-deadline status /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json --after 001 --hypothesis 'Success holds for 4; riskiest changed edge is just past the upper bound, so 10 must exit 2 with the exact stderr message.' -- bun scripts/probe.ts 10"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 003 --public --timeout-ms 10000 --after 002 --hypothesis 'Rejection of 10 holds; PLAN.md requires the upper boundary check: 9 must exit 0 with stdout 18 and empty stderr.' -- bun scripts/probe.ts 9"
},
{
"parent": false,
"command": "bun run test"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports annotations.json"
},
{
"parent": false,
"command": "if GATE_OUT=$(/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-gate-diff main 2>&1); then\n printf '%s\\n' \"$GATE_OUT\"\nelse\n GATE_EXIT=$?; printf '%s\\n' \"$GATE_OUT\"\n case \"$GATE_EXIT\" in\n 2) echo \"Gate integrity: UNAVAILABLE \u2014 $(printf '%s\\n' \"$GATE_OUT\" | grep -m1 '^GATE_ERROR=')\" ;;\n *) echo \"Gate integrity: UNAVAILABLE \u2014 helper exit $GATE_EXIT (stale install? run /gstack-upgrade)\" ;;\n esac\nfi"
},
{
"parent": false,
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-review-plan --base main"
},
{
"parent": true,
"command": "git diff origin/main"
},
{
"parent": false,
"command": "git status --short"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":true,\"converged\":true,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 5534d8f6-4d67-4b6f-b691-c6e83c659df2"
},
{
"parent": false,
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
}
]
},
"40707142223-rerun-1": {
"root": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP",
"commands": [
{
"parent": false,
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json 300 2026-10-28T15:06:29.695Z"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json -- bun scripts/probe.ts 9"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json --after 001 --hypothesis 'Range rule: 10 breaks only the 0..9 bound; changed return path must not alter rejection (exit 2, exact stderr, empty stdout).' -- bun scripts/probe.ts 10"
},
{
"parent": false,
"command": "bun run test"
},
{
"parent": false,
"command": "if GATE_OUT=$(/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-gate-diff main 2>&1); then\n printf '%s\\n' \"$GATE_OUT\"\nelse\n GATE_EXIT=$?; printf '%s\\n' \"$GATE_OUT\"\n case \"$GATE_EXIT\" in\n 2) echo \"Gate integrity: UNAVAILABLE \u2014 $(printf '%s\\n' \"$GATE_OUT\" | grep -m1 '^GATE_ERROR=')\" ;;\n *) echo \"Gate integrity: UNAVAILABLE \u2014 helper exit $GATE_EXIT (stale install? run /gstack-upgrade)\" ;;\n esac\nfi"
},
{
"parent": false,
"command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports annotations.json"
},
{
"parent": false,
"command": "git status --short"
},
{
"parent": false,
"command": "date -u +%Y-%m-%dT%H:%M:%SZ"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-review-plan --base main"
},
{
"parent": true,
"command": "git diff origin/main"
},
{
"parent": false,
"command": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":true,\"converged\":true,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 94e80914-bf02-4c55-84a9-a9ea9e1adbac"
},
{
"parent": false,
"command": "git status --short"
}
]
}
},
"lateInputReceipt": {
"status": "pass",
"probes": [
"probe-1cd02598-4151-4346-9556-b245a37e9a44",
"probe-11857dac-c540-41de-8e13-ca6181ec88fc",
"probe-a8572671-a2ec-4b2d-b5bb-aee043898de1"
],
"remaining": [
"Step 11 native adversarial review (not dispatched)",
"review record completed:false"
]
}
}
@@ -0,0 +1,613 @@
{
"source": "E2E Evals run 40617200857 slice 8 (paid-slice-8-a1), shared-libs-review-path-eligibility, ignored scenario at tested 39861825: one Bash call returned all of src/retry-worker.ts through cat -n, then a later echo === in the same call failed, so the result carried is_error. Native tool blocks only; sources are the fixture evidence files.",
"expected": {
"helper": "/__w/gstack/gstack/bin/gstack-review-log",
"repo": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo",
"state": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state",
"slug": "fixture-shared-libs",
"directory": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state/projects/fixture-shared-libs/.review-starts",
"branch": "feature/a",
"wtree": "96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c",
"startedAt": "2026-10-28T01:53:45.785Z",
"finding": {
"severity": "INFORMATIONAL",
"confidence": 9,
"advisory": true,
"path": "src/retry-worker.ts",
"line": 2,
"category": "shared-libs",
"summary": "Use the established Retry-After contract in the changed worker and the authored route sources.",
"fix": "Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.",
"evidence_paths": [
"src/retry-worker.ts",
"runtime-local/retry-route.ts",
"lib/retry-after.ts"
],
"helper_target": {
"path": "lib/retry-after.ts",
"symbol": "retrySeconds"
},
"fingerprint": "shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881"
},
"reusable": false,
"coveredPaths": [
"src/retry-worker.ts",
"lib/retry-after.ts"
],
"sources": {
"src/retry-worker.ts": "// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n",
"runtime-local/retry-route.ts": "// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n\n// Authored caller changed after the prior decision (ignored).\n",
"lib/retry-after.ts": "// Proven shared parser already used by the scheduler.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n"
}
},
"events": [
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01UXXksGCC1ZEq5bcTpXAVpH",
"name": "Read",
"input": {
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/review-lifecycle.md"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01UXXksGCC1ZEq5bcTpXAVpH",
"type": "tool_result",
"content": "1\t---\n2\tname: review\n3\tpreamble-tier: 4\n4\tversion: 1.0.0\n5\tdescription: Pre-landing PR review. (gstack)\n6\tallowed-tools:\n7\t - Bash\n8\t - Read\n9\t - Edit\n10\t - Write\n11\t - Grep\n12\t - Glob\n13\t - Agent\n14\t - AskUserQuestion\n15\t - WebSearch\n16\ttriggers:\n17\t - review this pr\n18\t - code review\n19\t - check my diff\n20\t - pre-landing review\n21\t---\n22\t\n23\t## Step 3: Get the diff\n24\t\n25\tAn invocation is this /review run; a pass reviews one candidate before any fixes.\n26\tOn first entry, initialize one invocation action list and CYCLES=0. Keep both through re-reviews.\n27\t\n28\tEach pass has one direction: collect findings in Steps 3–4.8, approve and apply\n29\tfixes in Step 5, then choose repeat or final persistence in Step 5.8.\n30\tDo not edit reviewed source until Step 5. All readers examine the same candidate.\n31\t\n32\tDiff the working tree against the merge base:\n33\t\n34\t```bash\n35\tDIFF_BASE=$(git merge-base origin/main HEAD)\n36\t/__w/gstack/gstack/bin/gstack-review-log --start review\n37\tgit diff \"$DIFF_BASE\"\n38\t```\n39\t\n40\t1. Save the printed REVIEW_START for this core candidate before reading its diff.\n41\t2. Each re-review captures a new token before reading, never at log time. After an\n42\t edited pass, start the next with `--start review --fix-range`: it prints\n43\t REVIEW_START, then `FIX_BASE=<sha>`. Wherever this pass sets DIFF_BASE outside\n44\t Step 4.8, use FIX_BASE instead; Step 4.8 and every other `<base>` use stay full.\n45\t On refusal, start a full pass.\n46\t3. Native/outside reviewer attempts own separate PASS_START tokens, not REVIEW_START.\n47\t4. Read non-ignored untracked source too (`git ls-files --others --exclude-standard`);\n48\t the captured candidate includes it.\n49\t5. First pass only: emit Step 1.5's final Scope Check; `Diff read` records this read.\n50\t\n51\tKeep the review-record terms separate:\n52\t\n53\t| Value | Purpose and owner |\n54\t|---|---|\n55\t| REVIEW_START / PASS_START | Opaque start receipts from the logger: one for the core pass, one for each other reviewer attempt. |\n56\t| Finding fingerprint | Groups duplicate findings. The installed helper computes shared-code fingerprints; a matching key alone never proves a prior Skip is reusable. |\n57\t| `review_binding` | The logger's proof tying a finished review to its captured candidate, not a finding identifier. |\n58\t| `snapshot_covered_paths` | Supporting advice files the logger proved byte-identical to that candidate. Used by the prior-Skip checker, never supplied by the reviewer. |\n59\t\n60\t## Step 4: Critical pass (core review)\n61\t\n62\t> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads in earlier responses. Never batch a probe (capture, guard start, plan check) with a Read.\n63\tStep 4 is read-only: defer charters, setup and probes to Step 4.7.\n64\t\n65\t1. From the installed /review SKILL.md's directory, choose one path:\n66\t- If the caller directory is `review`, Read `../qa/sections/exploratory.md` in full.\n67\t- If the caller directory is prefixed `gstack-review`, use `../gstack-qa/sections/exploratory.md` instead and read it in full.\n68\t- If neither layout applies, report an unresolved QA installation as a setup blocker; do not guess another path.\n69\tUse this host's installation, never the product tree. If missing or unreadable, report a QA setup blocker and its affected probes as blocked; continue other safe probes (independent functional/static checks). Missing/unreadable assets block required QA.\n70\t2. Then Read QA's `sections/scope.md` in full; pick surfaces.\n71\t3. Then Read each listed method in full; the entrypoint alone completes nothing.\n72\t\n73\tQA's `sections/...` and `templates/...` paths resolve from installed QA SKILL.md, not caller/product.\n74\t\n75\tApply both checklist passes in order: CRITICAL, then INFORMATIONAL. Respect its suppressions.\n76\tCRITICAL is the blocking floor (docs/review-policy.md): a finding that would fail a\n77\ttest, break production, or lose data or secrets. Everything else is INFORMATIONAL and\n78\tnever earns another pass. `NO FINDINGS` is a complete result from any reviewer.\n79\t\n80\t**Sideways sweep (every finding; reads code OUTSIDE the diff).** Grep the finding's pattern at every other ingress, command and call site, Read each match and rate severity on the worst sibling; a new enum value, status, tier or type constant is traced through every consumer the same way (checklist: Sideways sweep). Data loss, a trust-boundary bypass or uncapped spend is forced CRITICAL: no lane, specialist, dedup or saved decision downgrades it. Keep findings anchored to changed code.\n81\t\n82\t**Gate Integrity reads Step 3.5's listing.** Carry the `GATE_SUMMARY:` line and every\n83\tread-level hunk (`[<id>] <tag> <path> @<hunk>`) into the checklist's Gate Integrity\n84\tcategory: read each hunk in the diffLine truncated
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01HeRAdTGC3Wwdq9MHgFf4Zk",
"name": "Read",
"input": {
"file_path": "/__w/gstack/gstack/review/checklist.md"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01HeRAdTGC3Wwdq9MHgFf4Zk",
"type": "tool_result",
"content": "1\t# Pre-Landing Review Checklist\n2\t\n3\t## Instructions\n4\t\n5\tReview the merge-base diff from the caller, including its selected uncommitted and new source. Use the caller's detected base, not a hardcoded branch. Cite `file:line` and suggest fixes. Only flag real problems.\n6\t\n7\t**Two-pass review:**\n8\t- **Pass 1 (CRITICAL):** Run SQL & Data Safety, Race Conditions, LLM Output Trust Boundary, Shell Injection, Enum Completeness, and Gate Integrity first. Highest severity.\n9\t- **Pass 2 (INFORMATIONAL):** Run remaining categories below. Lower severity but still actioned.\n10\t- **Specialist categories (handled by parallel subagents, NOT this checklist):** Test Gaps, Dead Code, Magic Numbers, Conditional Side Effects, Performance & Bundle Impact, Crypto & Entropy, Simplification (unrequested structure). See `review/specialists/` for these.\n11\t\n12\tCompleteness Gaps and Simplification are orthogonal, not contradictory: Completeness pushes coverage UP (tests, edge cases, error paths), Simplification pushes unrequested structure DOWN (one-implementation abstractions, hand-rolled stdlib, dead flexibility). The same diff can legitimately receive both.\n13\t\n14\tAll findings get action via Fix-First Review: obvious mechanical fixes are applied automatically,\n15\tgenuinely ambiguous issues are batched into a single user question.\n16\t\n17\t**Output format:**\n18\t\n19\t```\n20\tPre-Landing Review: N issues (X critical, Y informational)\n21\t\n22\t**AUTO-FIXED:**\n23\t- [file:line] Problem → fix applied\n24\t\n25\t**NEEDS INPUT:**\n26\t- [file:line] Problem description\n27\t Recommended fix: suggested fix\n28\t```\n29\t\n30\tIf no issues found: `Pre-Landing Review: No issues found.`\n31\t\n32\tBe terse. For each issue: one line describing the problem, one line with the fix. No preamble, no summaries, no \"looks good overall.\"\n33\t\n34\t---\n35\t\n36\t## Review Categories\n37\t\n38\t### Pass 1 — CRITICAL\n39\t\n40\t#### SQL & Data Safety\n41\t- String interpolation in SQL (even if values are `.to_i`/`.to_f` — use parameterized queries (Rails: sanitize_sql_array/Arel; Node: prepared statements; Python: parameterized queries))\n42\t- TOCTOU races: check-then-set patterns that should be atomic `WHERE` + `update_all`\n43\t- Bypassing model validations for direct DB writes (Rails: update_column; Django: QuerySet.update(); Prisma: raw queries)\n44\t- N+1 queries: Missing eager loading (Rails: .includes(); SQLAlchemy: joinedload(); Prisma: include) for associations used in loops/views\n45\t\n46\t#### Race Conditions & Concurrency\n47\t- Read-check-write without uniqueness constraint or catch duplicate key error and retry (e.g., `where(hash:).first` then `save!` without handling concurrent insert)\n48\t- find-or-create without unique DB index — concurrent calls can create duplicates\n49\t- Status transitions that don't use atomic `WHERE old_status = ? UPDATE SET new_status` — concurrent updates can skip or double-apply transitions\n50\t- Unsafe HTML rendering (Rails: .html_safe/raw(); React: dangerouslySetInnerHTML; Vue: v-html; Django: |safe/mark_safe) on user-controlled data (XSS)\n51\t\n52\t#### LLM Output Trust Boundary\n53\t- LLM-generated values (emails, URLs, names) written to DB or passed to mailers without format validation. Add lightweight guards (`EMAIL_REGEXP`, `URI.parse`, `.strip`) before persisting.\n54\t- Structured tool output (arrays, hashes) accepted without type/shape checks before database writes.\n55\t- LLM-generated URLs fetched without allowlist — SSRF risk if URL points to internal network (Python: `urllib.parse.urlparse` → check hostname against blocklist before `requests.get`/`httpx.get`)\n56\t- LLM output stored in knowledge bases or vector DBs without sanitization — stored prompt injection risk\n57\t\n58\t#### Shell Injection (Python-specific)\n59\t- `subprocess.run()` / `subprocess.call()` / `subprocess.Popen()` with `shell=True` AND f-string/`.format()` interpolation in the command string — use argument arrays instead\n60\t- `os.system()` with variable interpolation — replace with `subprocess.run()` using argument arrays\n61\t- `eval()` / `exec()` on LLM-generated code without sandboxing\n62\t\n63\t#### Enum & Value Completeness\n64\tWhen the diff introduces a new enum value, status string, tier name, or type constant, or loosens what an input accepts (a new MIME type, extension or flag, or a widened validator/parser/guard):\n65\t- **Trace it through every consumer.** Read (don't just grep — READ) each file that switches on, filters by, or displays that value. If any consumer doesn't handle the new value, flag it. Common miss: adding a value to the frontend dropdown but the backend model/compute method doesn't persist it.\n66\t- **Check allowlists/filter arrays.** Search for arrays or `%w[]` lists containing sibling values (e.g., if adding \"revise\" to tiers, find every `%w[quick lfg mega]` and verify \"revise\" is included whereLine truncated
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_012nWx4Je2424rCcxZUaaeUg",
"name": "Read",
"input": {
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/current-advisory.jsonl"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_012nWx4Je2424rCcxZUaaeUg",
"type": "tool_result",
"content": "1\t{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"specialist\":\"maintainability\"}\n2\t"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01FiurRTF3YRGrjduMhTi5m8",
"name": "Read",
"input": {
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/resumed-review-prerequisites.json"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01FiurRTF3YRGrjduMhTi5m8",
"type": "tool_result",
"content": "1\t{\n2\t \"kind\": \"synthetic-path-review-prerequisites\",\n3\t \"synthetic\": true,\n4\t \"native_coverage\": false,\n5\t \"binding\": {\n6\t \"root\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2\",\n7\t \"repo\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\n8\t \"state\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state\",\n9\t \"branch\": \"feature/a\",\n10\t \"head\": \"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\n11\t \"base\": \"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\n12\t \"wtree\": \"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\n13\t \"index\": \"H 100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\\t.gitignore\\nH 100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\\tREADME.md\\nH 100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\\tlib/retry-after.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-route.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-worker.ts\\nH 100644 c362fe9878839740d82db85053758be5b95834cd 0\\tsrc/scheduler.ts\\nH 100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\\ttest/retry-after.test.ts\",\n14\t \"raw\": {\n15\t \".\": \"dir:16877\",\n16\t \".git/config\": \"33188:2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854\",\n17\t \".git/info/exclude\": \"33188:6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1\",\n18\t \".gitignore\": \"33188:326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4\",\n19\t \"README.md\": \"33188:94324728d3116382754324b62b8bcf233cf6046df4d1f4f770ecc68df7b10504\",\n20\t \"lib\": \"dir:16877\",\n21\t \"lib/retry-after.ts\": \"33188:67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942\",\n22\t \"runtime-local\": \"dir:16877\",\n23\t \"runtime-local/retry-route.ts\": \"33188:db46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33\",\n24\t \"src\": \"dir:16877\",\n25\t \"src/retry-route.ts\": \"33188:300feb02d24ed09ca763371d42249f8307dbe9201fb4cddf7aa634a9e341f887\",\n26\t \"src/retry-worker.ts\": \"33188:a17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e\",\n27\t \"src/scheduler.ts\": \"33188:8a07bfe628ea4f9e679bcab3cfb68482678eebc1e4031887e996d799a6d1198e\",\n28\t \"test\": \"dir:16877\",\n29\t \"test/retry-after.test.ts\": \"33188:653e4090ae4e0d856f4dbf04fb05449d04c44fef599c24ce146d94969a1040a1\"\n30\t }\n31\t },\n32\t \"qa\": {\n33\t \"settled\": true,\n34\t \"required_probes\": [\n35\t {\n36\t \"id\": \"retry-contract\",\n37\t \"status\": \"passed\",\n38\t \"result\": \"Synthetic fixture input: Retry-After seconds/date parsing, ceiling and fallback probes passed.\"\n39\t }\n40\t ],\n41\t \"findings\": []\n42\t },\n43\t \"native_adversarial\": {\n44\t \"settled\": true,\n45\t \"status\": \"completed\",\n46\t \"findings\": [],\n47\t \"result\": \"Synthetic fixture input: native adversarial review returned no findings.\"\n48\t },\n49\t \"structured_review\": {\n50\t \"required\": false,\n51\t \"reason\": \"Tiny diff; no full-review, structured-review or P1 override requested.\"\n52\t }\n53\t}\n54\t"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01Ux4koaFZbNCDB5y1zsdDTK",
"name": "Read",
"input": {
"file_path": "/__w/gstack/gstack/review/sections/shared-code-reuse.md"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01Ux4koaFZbNCDB5y1zsdDTK",
"type": "tool_result",
"content": "1\t<!-- AUTO-GENERATED from shared-code-reuse.md.tmpl — do not edit directly -->\n2\t<!-- Regenerate: bun run gen:skill-docs -->\n3\t**Reuse a skipped shared-code advisory only with complete structural evidence:**\n4\t\n5\t1. **Read the evidence.** Read all supporting callers and the helper destination.\n6\t Establish first-party authored provenance and whether the current extraction\n7\t is worthwhile; the checker cannot decide that. Retain `evidence_paths`/`helper_target`.\n8\t2. **Run the checker.** From the repository root, pass the current finding as\n9\t literal JSON on stdin. Replace REVIEW_START with this pass's captured token\n10\t and the example paths/symbol with actual evidence. Keep the quoted delimiter.\n11\t\n12\t```bash\n13\t\"$HOME/.claude/skills/gstack/bin/gstack-review-log\" --check-shared-libs REVIEW_START <<'GSTACK_SHARED_LIBS_REUSE_JSON'\n14\t{\"advisory\":true,\"severity\":\"INFORMATIONAL\",\"evidence_paths\":[\"src/caller-a.ts\",\"src/caller-b.ts\"],\"helper_target\":{\"path\":\"src/shared.ts\",\"symbol\":\"sharedHelper\"}}\n15\tGSTACK_SHARED_LIBS_REUSE_JSON\n16\t```\n17\t\n18\t3. **Act on its result.** Read the JSON. Only `reusable: true` permits suppression.\n19\t False, command failure or unreadable output requires fresh source review and a\n20\t new decision, never suppression. Do not supply your own snapshot, prior record or coverage.\n21\t4. **Persist through the logger.** The logger recomputes final coverage; never\n22\t supply proof yourself. Real defects retain normal Fix-First handling independently.\n23\t\n24\t**What a reusable result proves (do not reconstruct these checks yourself):**\n25\t- Identity: `sharedLibsFingerprint` plus the actual repo, raw branch and current snapshot.\n26\t The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity.\n27\t- Prior decision: completed/converged review, verified binding, explicit Skip and\n28\t logger-versioned `snapshot_covered_paths`; older unversioned coverage needs a fresh decision.\n29\t- Source: `canReuseSharedLibsAdvisory` requires every supporting path's raw file\n30\t byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index\n31\t entries; symlinks/ancestors, submodules, ignored/outside or unreadable files;\n32\t active/unknown Git filters, encodings and line conversion.\n33\t- Safe inspection: disables fsmonitor and optional locks; never uses external diff/textconv.\n34\t Unknown evidence fails closed.\n35\t"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01HgUDAPUD3QgytJmoHKQMdb",
"name": "Bash",
"input": {
"command": "git fetch origin 2>&1; echo \"MB=$(git merge-base origin/main HEAD)\"; echo \"HEAD=$(git rev-parse HEAD)\"; echo \"BASE=$(git rev-parse origin/main)\"; git branch --show-current; git status --porcelain=v1 --ignored; echo ---ls-files-v; git ls-files -v | head -50; echo ---debug; git ls-files --debug -- src | head -60; echo ---config; git config --local --list; echo ---attrs; cat .gitattributes .git/info/attributes .git/info/exclude 2>&1; cat .gitignore 2>&1; git config --get core.attributesFile; git config --get core.excludesFile"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"content": "Exit code 1\nMB=0065f046b3be1008d3788dcaa596fbdadc1b540b\nHEAD=0065f046b3be1008d3788dcaa596fbdadc1b540b\nBASE=0065f046b3be1008d3788dcaa596fbdadc1b540b\nfeature/a\n M src/retry-worker.ts\n!! runtime-local/\n---ls-files-v\nH .gitignore\nH README.md\nH lib/retry-after.ts\nH src/retry-route.ts\nH src/retry-worker.ts\nH src/scheduler.ts\nH test/retry-after.test.ts\n---debug\nsrc/retry-route.ts\n ctime: 1793152411:639522773\n mtime: 1793152411:639522773\n dev: 45\tino: 9962937\n uid: 1001\tgid: 1001\n size: 51\tflags: 0\nsrc/retry-worker.ts\n ctime: 1793152411:639522773\n mtime: 1793152411:639522773\n dev: 45\tino: 9962936\n uid: 1001\tgid: 1001\n size: 51\tflags: 0\nsrc/scheduler.ts\n ctime: 1793152411:615527724\n mtime: 1793152411:615527724\n dev: 45\tino: 9962940\n uid: 1001\tgid: 1001\n size: 126\tflags: 0\n---config\ncore.repositoryformatversion=0\ncore.filemode=true\ncore.bare=false\ncore.logallrefupdates=true\nuser.name=Shared Libs Fixture\nuser.email=shared-libs@example.invalid\nremote.origin.url=https://github.com/fixture/shared-libs.git\nremote.origin.fetch=+refs/heads/*:refs/remotes/origin/*\n---attrs\ncat: .gitattributes: No such file or directory\ncat: .git/info/attributes: No such file or directory\n# git ls-files --others --exclude-from=.git/info/exclude\n# Lines that start with '#' are comments.\n# For a project mostly in C, the following would be a good set of\n# exclude patterns (uncomment them if you want to use them):\n# *.[oa]\n# *~\n.fixture/\nruntime-local/",
"is_error": true,
"tool_use_id": "toolu_01HgUDAPUD3QgytJmoHKQMdb"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01XV73n6QkXKEbPiTQbhA5CJ",
"name": "Bash",
"input": {
"command": "'/__w/gstack/gstack/bin/gstack-review-log' --start review"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01XV73n6QkXKEbPiTQbhA5CJ",
"type": "tool_result",
"content": "547c0e1f-86d4-40e2-8be6-bf87f439bc75",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01A7hwW89RrEkkidKLpKJB41",
"name": "Read",
"input": {
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state/projects/fixture-shared-libs/.review-starts/547c0e1f-86d4-40e2-8be6-bf87f439bc75.json"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01A7hwW89RrEkkidKLpKJB41",
"type": "tool_result",
"content": "1\t{\"skill\":\"review\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"branch\":\"feature/a\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\"}"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01JNnuM42rwXQCenGMNg9Uc6",
"name": "Bash",
"input": {
"command": "git diff 0065f046b3be1008d3788dcaa596fbdadc1b540b; echo ---untracked; git ls-files --others --exclude-standard; echo ---ignored; git ls-files --others --ignored --exclude-standard; echo ---checkignore; git check-ignore -v runtime-local/retry-route.ts; echo ---gitignore; cat .gitignore"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01JNnuM42rwXQCenGMNg9Uc6",
"type": "tool_result",
"content": "diff --git a/src/retry-worker.ts b/src/retry-worker.ts\nindex 8ab4b18..8f6dd51 100644\n--- a/src/retry-worker.ts\n+++ b/src/retry-worker.ts\n@@ -1 +1,15 @@\n-export { retrySeconds } from '../lib/retry-after';\n+// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n+export function retrySeconds(value: string | null, now: number, fallback = 5): number {\n+ if (value == null || value.trim() === '') return fallback;\n+ const normalized = value.trim();\n+ if (/^\\d+$/.test(normalized)) {\n+ const seconds = Number(normalized);\n+ if (!Number.isSafeInteger(seconds)) return fallback;\n+ return Math.min(seconds, 3600);\n+ }\n+ const deadline = Date.parse(normalized);\n+ if (!Number.isFinite(deadline)) return fallback;\n+ const remaining = Math.ceil((deadline - now) / 1000);\n+ if (remaining < 0) return 0;\n+ return Math.min(remaining, 3600);\n+}\n---untracked\n---ignored\nruntime-local/retry-route.ts\n---checkignore\n.gitignore:2:runtime-local/\truntime-local/retry-route.ts\n---gitignore\n.fixture/\nruntime-local/",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01SqU5QcTMtXntb5BsZLwEnj",
"name": "Bash",
"input": {
"command": "cat -n src/retry-worker.ts; echo ===; cat -n runtime-local/retry-route.ts; echo ===; cat -n lib/retry-after.ts; echo ===; cat -n src/retry-route.ts; echo ===; cat -n src/scheduler.ts; echo ===; cat -n test/retry-after.test.ts; echo ===; cat -n README.md; echo ===; ls -la runtime-local; git ls-files -s runtime-local; git log --oneline -- runtime-local | head"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"content": "Exit code 1\n 1\t// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n(eval):1: == not found",
"is_error": true,
"tool_use_id": "toolu_01SqU5QcTMtXntb5BsZLwEnj"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01SG2WWtp5ukb9AXXBzLM52i",
"name": "Bash",
"input": {
"command": "cat -n runtime-local/retry-route.ts; echo '---'; cat -n lib/retry-after.ts; echo '---'; cat -n src/retry-route.ts; echo '---'; cat -n src/scheduler.ts; echo '---'; cat -n test/retry-after.test.ts; echo '---'; cat -n README.md; echo '---'; ls -la runtime-local; git ls-files -s runtime-local; git log --oneline --all -- runtime-local | head"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01SG2WWtp5ukb9AXXBzLM52i",
"type": "tool_result",
"content": " 1\t// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n 16\t\n 17\t// Authored caller changed after the prior decision (ignored).\n---\n 1\t// Proven shared parser already used by the scheduler.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n---\n 1\texport { retrySeconds } from '../lib/retry-after';\n---\n 1\timport { retrySeconds } from '../lib/retry-after';\n 2\texport const nextRun = (value: string) => retrySeconds(value, Date.now());\n---\n 1\timport { expect, test } from 'bun:test';\n 2\timport { retrySeconds } from '../lib/retry-after';\n 3\ttest('retry parser contract', () => {\n 4\t expect(retrySeconds(null, 0)).toBe(5);\n 5\t expect(retrySeconds('invalid', 0, 7)).toBe(7);\n 6\t expect(retrySeconds(' 42 ', 0)).toBe(42);\n 7\t expect(retrySeconds('999999', 0)).toBe(3600);\n 8\t expect(retrySeconds('Thu, 01 Jan 1970 00:00:01 GMT', 0)).toBe(1);\n 9\t});\n---\n 1\t# Fixture application\n 2\t\n 3\tThe application deployment supplies authored optional route adapters under runtime-local/. That established mount is intentionally excluded from this repository; runtime-local/retry-route.ts is first-party application code loaded only after the mount is present.\n---\ntotal 12\ndrwxr-xr-x 2 runner runner 4096 Oct 28 01:53 .\ndrwxr-xr-x 7 runner runner 4096 Oct 28 01:53 ..\n-rw-r--r-- 1 runner runner 737 Oct 28 01:53 retry-route.ts",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01CVpwC8ef1niGnBoDL1ppJL",
"name": "Bash",
"input": {
"command": "'/__w/gstack/gstack/bin/gstack-review-log' --check-shared-libs 547c0e1f-86d4-40e2-8be6-bf87f439bc75 <<'GSTACK_REVALIDATION_FINDING'\n{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"specialist\":\"maintainability\"}\nGSTACK_REVALIDATION_FINDING"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01CVpwC8ef1niGnBoDL1ppJL",
"type": "tool_result",
"content": "{\"reusable\":false,\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"review_start\":{\"skill\":\"review\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"branch\":\"feature/a\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\"},\"snapshot\":{\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\",\"covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}}",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01CbxQEpFUZwz6eTdFxic3QE",
"name": "Bash",
"input": {
"command": "'/__w/gstack/gstack/bin/gstack-review-read'"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01CbxQEpFUZwz6eTdFxic3QE",
"type": "tool_result",
"content": "{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:53:31.755Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:31.753Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n---CONFIG---\nfalse---HEAD---\n0065f04\n---WTREE---\n96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\n---TREE---\n3d257818bedb4ab8770ce806c8b0bfcc45537857\n---DIRTY---\ntrue",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_013zyFAS1agf999Po7qTodYm",
"name": "Grep",
"input": {
"pattern": "retrySeconds|3600|Retry-After",
"path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo",
"output_mode": "content"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_013zyFAS1agf999Po7qTodYm",
"type": "tool_result",
"content": "src/retry-route.ts:1:export { retrySeconds } from '../lib/retry-after';\nsrc/retry-worker.ts:1:// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nsrc/retry-worker.ts:2:export function retrySeconds(value: string | null, now: number, fallback = 5): number {\nsrc/retry-worker.ts:8: return Math.min(seconds, 3600);\nsrc/retry-worker.ts:14: return Math.min(remaining, 3600);\nsrc/scheduler.ts:1:import { retrySeconds } from '../lib/retry-after';\nsrc/scheduler.ts:2:export const nextRun = (value: string) => retrySeconds(value, Date.now());\ntest/retry-after.test.ts:2:import { retrySeconds } from '../lib/retry-after';\ntest/retry-after.test.ts:4: expect(retrySeconds(null, 0)).toBe(5);\ntest/retry-after.test.ts:5: expect(retrySeconds('invalid', 0, 7)).toBe(7);\ntest/retry-after.test.ts:6: expect(retrySeconds(' 42 ', 0)).toBe(42);\ntest/retry-after.test.ts:7: expect(retrySeconds('999999', 0)).toBe(3600);\ntest/retry-after.test.ts:8: expect(retrySeconds('Thu, 01 Jan 1970 00:00:01 GMT', 0)).toBe(1);\nlib/retry-after.ts:2:export function retrySeconds(value: string | null, now: number, fallback = 5): number {\nlib/retry-after.ts:8: return Math.min(seconds, 3600);\nlib/retry-after.ts:14: return Math.min(remaining, 3600);"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01Pgoa7ZGB1vUMTZ2QBC3kU2",
"name": "AskUserQuestion",
"input": {
"questions": [
{
"question": "[ADVISORY] src/retry-worker.ts:2-15 copies lib/retry-after.ts retrySeconds inline, and the copy matches it exactly. The prior Skip can't be reused: the checker returned reusable:false because runtime-local/retry-route.ts is gitignored, isn't covered by the snapshot, and changed after that decision. Proposed fix: put back `export { retrySeconds } from '../lib/retry-after';` in the worker, the same as src/retry-route.ts and src/scheduler.ts. Leave the runtime-local adapter out of the change, since it's a separate deployment mount. Estimated savings: about 15 lines removed, 1 added (net −14). Covered by the existing test/retry-after.test.ts. If the shared helper broke, it would affect the worker, route and scheduler. RECOMMENDATION: A. Note that A edits source, which blocks this no-edit replay, so the review would stay incomplete.",
"header": "Retry helper",
"multiSelect": false,
"options": [
{
"label": "A) Fix as recommended",
"description": "Re-export retrySeconds from lib/retry-after in the worker. Needs a source edit, which blocks this bounded no-edit replay, so the review is reported incomplete."
},
{
"label": "B) Skip",
"description": "No code or index change; the Skip is recorded."
}
]
}
]
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"content": "Your questions have been answered: \"[ADVISORY] src/retry-worker.ts:2-15 copies lib/retry-after.ts retrySeconds inline, and the copy matches it exactly. The prior Skip can't be reused: the checker returned reusable:false because runtime-local/retry-route.ts is gitignored, isn't covered by the snapshot, and changed after that decision. Proposed fix: put back `export { retrySeconds } from '../lib/retry-after';` in the worker, the same as src/retry-route.ts and src/scheduler.ts. Leave the runtime-local adapter out of the change, since it's a separate deployment mount. Estimated savings: about 15 lines removed, 1 added (net −14). Covered by the existing test/retry-after.test.ts. If the shared helper broke, it would affect the worker, route and scheduler. RECOMMENDATION: A. Note that A edits source, which blocks this no-edit replay, so the review would stay incomplete.\"=\"B) Skip\". You can now continue with these answers in mind.",
"tool_use_id": "toolu_01Pgoa7ZGB1vUMTZ2QBC3kU2"
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01MZ3iwhrhCqS2NwyehefiSg",
"name": "Bash",
"input": {
"command": "git rev-parse HEAD origin/main; git branch --show-current; git status --porcelain=v1 --ignored; git ls-files -s; git ls-files -v; sha256sum src/retry-worker.ts lib/retry-after.ts runtime-local/retry-route.ts .git/config .git/info/exclude .gitignore; git config --local --list | md5sum; date -u +%Y-%m-%dT%H:%M:%SZ; git rev-parse --short HEAD"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01MZ3iwhrhCqS2NwyehefiSg",
"type": "tool_result",
"content": "0065f046b3be1008d3788dcaa596fbdadc1b540b\n0065f046b3be1008d3788dcaa596fbdadc1b540b\nfeature/a\n M src/retry-worker.ts\n!! runtime-local/\n100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\t.gitignore\n100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\tREADME.md\n100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\tlib/retry-after.ts\n100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\tsrc/retry-route.ts\n100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\tsrc/retry-worker.ts\n100644 c362fe9878839740d82db85053758be5b95834cd 0\tsrc/scheduler.ts\n100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\ttest/retry-after.test.ts\nH .gitignore\nH README.md\nH lib/retry-after.ts\nH src/retry-route.ts\nH src/retry-worker.ts\nH src/scheduler.ts\nH test/retry-after.test.ts\na17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e src/retry-worker.ts\n67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942 lib/retry-after.ts\ndb46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33 runtime-local/retry-route.ts\n2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854 .git/config\n6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1 .git/info/exclude\n326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4 .gitignore\nafba8a8c204ce2d12c88f5a21198daad -\n2026-10-28T01:54:34Z\n0065f04",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01JqjkEAXGWYdxnP9Mu7fZ92",
"name": "Bash",
"input": {
"command": "bun '/__w/gstack/gstack/test/helpers/shared-libs-path-fixture.ts' --check-review-prerequisites '/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/resumed-review-prerequisites.json'"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01JqjkEAXGWYdxnP9Mu7fZ92",
"type": "tool_result",
"content": "{\"synthetic\":true,\"native_coverage\":false,\"settled\":true,\"current\":true,\"input_sha256\":\"b26272f134145cdb6bd79a2bea545d44e9d84d128c6f81852de73bf1876509a1\",\"context\":{\"kind\":\"synthetic-path-review-prerequisites\",\"synthetic\":true,\"native_coverage\":false,\"binding\":{\"root\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"state\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state\",\"branch\":\"feature/a\",\"head\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"base\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"index\":\"H 100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\\t.gitignore\\nH 100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\\tREADME.md\\nH 100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\\tlib/retry-after.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-route.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-worker.ts\\nH 100644 c362fe9878839740d82db85053758be5b95834cd 0\\tsrc/scheduler.ts\\nH 100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\\ttest/retry-after.test.ts\",\"raw\":{\".\":\"dir:16877\",\".git/config\":\"33188:2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854\",\".git/info/exclude\":\"33188:6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1\",\".gitignore\":\"33188:326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4\",\"README.md\":\"33188:94324728d3116382754324b62b8bcf233cf6046df4d1f4f770ecc68df7b10504\",\"lib\":\"dir:16877\",\"lib/retry-after.ts\":\"33188:67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942\",\"runtime-local\":\"dir:16877\",\"runtime-local/retry-route.ts\":\"33188:db46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33\",\"src\":\"dir:16877\",\"src/retry-route.ts\":\"33188:300feb02d24ed09ca763371d42249f8307dbe9201fb4cddf7aa634a9e341f887\",\"src/retry-worker.ts\":\"33188:a17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e\",\"src/scheduler.ts\":\"33188:8a07bfe628ea4f9e679bcab3cfb68482678eebc1e4031887e996d799a6d1198e\",\"test\":\"dir:16877\",\"test/retry-after.test.ts\":\"33188:653e4090ae4e0d856f4dbf04fb05449d04c44fef599c24ce146d94969a1040a1\"}},\"qa\":{\"settled\":true,\"required_probes\":[{\"id\":\"retry-contract\",\"status\":\"passed\",\"result\":\"Synthetic fixture input: Retry-After seconds/date parsing, ceiling and fallback probes passed.\"}],\"findings\":[]},\"native_adversarial\":{\"settled\":true,\"status\":\"completed\",\"findings\":[],\"result\":\"Synthetic fixture input: native adversarial review returned no findings.\"},\"structured_review\":{\"required\":false,\"reason\":\"Tiny diff; no full-review, structured-review or P1 override requested.\"}}}",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01JHrJYtsYw8HnZzznyDcNo6",
"name": "Bash",
"input": {
"command": "'/__w/gstack/gstack/bin/gstack-review-log' '{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:54:34Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{},\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\"}],\"commit\":\"0065f04\",\"completed\":true,\"converged\":true,\"cycles\":0}' --finish 547c0e1f-86d4-40e2-8be6-bf87f439bc75 && '/__w/gstack/gstack/bin/gstack-review-read'"
},
"caller": {
"type": "direct"
}
}
]
}
},
{
"type": "user",
"message": {
"content": [
{
"tool_use_id": "toolu_01JHrJYtsYw8HnZzznyDcNo6",
"type": "tool_result",
"content": "{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:53:31.755Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:31.753Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:54:34Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{},\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"commit\":\"0065f04\",\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n---CONFIG---\nfalse---HEAD---\n0065f04\n---WTREE---\n96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\n---TREE---\n3d257818bedb4ab8770ce806c8b0bfcc45537857\n---DIRTY---\ntrue",
"is_error": false
}
]
}
}
]
}
+1 -1
View File
@@ -39,7 +39,7 @@ describe('gen-skill-docs stale-render prune', () => {
const source = path.join(out, 'source');
fs.mkdirSync(path.join(source, 'scripts'), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), path.join(source, 'scripts', 'gen-skill-docs.ts'));
for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts']) {
for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts', 'modules.ts']) {
fs.symlinkSync(path.join(ROOT, 'scripts', file), path.join(source, 'scripts', file), 'file');
}
fs.symlinkSync(path.join(ROOT, 'scripts', 'resolvers'), path.join(source, 'scripts', 'resolvers'), 'dir');
+32 -1
View File
@@ -13,6 +13,7 @@ import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { verdictProblem } from '../lib/install-verdict';
const REPO = path.resolve(import.meta.dir, '..');
const BUN_DEFAULT = fs.readFileSync(path.join(REPO, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
@@ -58,7 +59,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
script(path.join(root, 'setup'), ['echo "setup $*" >> "$CAPY_LOG"', 'echo "gstack ready ($2)"', 'echo "env GSTACK_SKIP_PLAYWRIGHT=${GSTACK_SKIP_PLAYWRIGHT:-} GSTACK_SKIP_FONTS=${GSTACK_SKIP_FONTS:-} bun=$(bun --version)" >> "$CAPY_LOG"', 'exit "${STUB_SETUP_EXIT:-0}"']);
script(path.join(root, 'bin/gstack-doctor'), [
'echo "doctor $* GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-}" >> "$CAPY_LOG"', 'echo "doctor-cwd $(pwd -P)" >> "$CAPY_LOG"', 'echo "doctor-node $(node --version 2>/dev/null)" >> "$CAPY_LOG"',
'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"',
'echo "required: ${STUB_REQUIRED:-claude codex patch}"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"',
'[ "${STUB_DOCTOR_FAIL:-}" = 1 ] && { echo "FAIL codex — missing; fix: x (COMPONENT_MISSING; d)"; echo "gstack: fail codex"; exit 1; }',
'[ -n "${GSTACK_DOCTOR_RUNTIME_DRIFT:-}" ] && { echo "FAIL runtime — bun resolution drifted: $GSTACK_DOCTOR_RUNTIME_DRIFT; fix: $GSTACK_DOCTOR_RUNTIME_DRIFT_FIX (BUN_RESOLUTION_DRIFT; d)"; echo "gstack: fail runtime"; exit 1; }',
'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"',
@@ -351,6 +352,36 @@ describe('gstack-capy-install', () => {
expect(run(f, ['--for', 'review-voice', '--project', project]).stdout).not.toMatch(upgrading);
});
// #3561: on a shared machine /ship's docs step runs the installer --for document-release, whose
// narrower verdict replaced the --for ship one, so /ship lost admission mid-run.
test('--for ship then --for document-release: the verdict keeps both and admits both workflows; another HEAD replaces it', () => {
const f = fixture();
const env = { HOME: f.home, GSTACK_HOME: path.join(f.home, '.gstack-state') };
const admits = (skill: string) => verdictProblem(skill, f.root, env, '', '1.4.2', f.home);
const ship = run(f, ['--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' });
expect(ship.status, ship.stdout + ship.stderr).toBe(0);
const file = ship.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1]!;
expect(admits('ship')).toBeNull();
const docs = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' });
expect(docs.status, docs.stdout + docs.stderr).toBe(0);
expect(docs.stdout).toContain(`gstack-capy-install: the install verdict at ${file} keeps for=ship and adds document-release (for=ship,document-release)`);
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(v).toMatchObject({ ok: true, for: 'ship,document-release', pins_planning_only: false });
expect([...v.required].sort()).toEqual(['claude', 'codex', 'patch', 'pins', 'runtime']);
expect(admits('ship')).toBeNull();
expect(admits('document-release')).toBeNull();
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(v);
const again = run(f, ['--check', '--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' });
expect(again.status, again.stdout + again.stderr).toBe(0);
expect(again.stdout).not.toContain('keeps for=');
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'ship,document-release' });
sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root);
const moved = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' });
expect(moved.status, moved.stdout + moved.stderr).toBe(0);
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', required: ['claude', 'runtime', 'pins'] });
expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs');
});
test('--root clones when the directory is absent and refuses a non-gstack directory', () => {
const f = fixture();
const clone = path.join(f.base, 'clone');
+4
View File
@@ -85,6 +85,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/design-handoff-q-calls.json': ['test/design-completion-handoff-scored.test.ts'],
'test/fixtures/design-md-legacy.md': ['test/design-md.test.ts'],
'test/fixtures/design-ui-focus-capture-39199692993.json': ['test/plan-design-with-ui-fixture.test.ts'],
'test/fixtures/design-ui-focus-capture-40664336141.json': ['test/plan-design-with-ui-fixture.test.ts'],
'test/fixtures/design-ui-focus-capture.json': ['test/plan-design-with-ui-fixture.test.ts'],
'test/fixtures/detector-corpora/auto-decide-preserved/**': ['test/detector-corpus-auto-decide-preserved.test.ts'],
'test/fixtures/detector-corpora/plan-ceo-section-loading/**': ['test/detector-corpus-plan-ceo-section-loading.test.ts'],
@@ -178,7 +179,9 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/plan-floor-edit-wrap-37176835584.json': ['test/plan-floor-edit-wrap.test.ts'],
'test/fixtures/plan-format-kind-notes.json': ['test/plan-format-kind-note.test.ts'],
'test/fixtures/qa-caller-handoff-order-public.json': ['test/qa-exploratory-callers.test.ts'],
'test/fixtures/qa-caller-merge-base-diff-40737741454.json': ['test/qa-caller-review-plan.test.ts'],
'test/fixtures/qa-caller-read-probe-mtb1007a.json': ['test/qa-exploratory-callers.test.ts'],
'test/fixtures/qa-caller-review-plan-40707142223.json': ['test/qa-caller-review-plan.test.ts'],
'test/fixtures/qa-caller-section-skip-ship-small-cli.json': ['test/qa-exploratory-callers.test.ts'],
'test/fixtures/plans/autoplan-dashboard.md': ['test/autoplan-init.test.ts', 'test/autoplan-preconfigured-onboarding-ar.test.ts', 'test/autoplan-snapshot.test.ts'],
'test/fixtures/plans/autoplan-focus-appearance-design.md': [],
@@ -198,6 +201,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/shared-libs-claude-version-probe.json': ['test/shared-libs-fixture.test.ts'],
'test/fixtures/shared-libs-index-flags-loop-read-39173752116.json': ['test/shared-libs-checker-interface-evidence.test.ts'],
'test/fixtures/shared-libs-no-change-ci-public.json': ['test/shared-libs-fixture.test.ts'],
'test/fixtures/shared-libs-path-ignored-errored-read-40617200857.json': ['test/shared-libs-checker-interface-evidence.test.ts'],
'test/fixtures/shared-libs-plan-callers-fixes-behavior-local.json': ['test/shared-libs-plan-actor.test.ts'],
'test/fixtures/shared-libs-plan-callers-future-risk-37195203538.json': ['test/shared-libs-plan-actor.test.ts'],
'test/fixtures/shared-libs-plan-callers-no-hardening-36633323521.json': ['test/shared-libs-plan-actor.test.ts'],
+20 -1
View File
@@ -140,6 +140,14 @@ const literalCallerListing = new RegExp(`^ls(?: -[la]+)?(?: --)?(?: ${literalCal
const callerDiffMode = '(--stat|--numstat|--name-only|--name-status)';
const literalCallerDiff = new RegExp(`^git diff(?: ${callerDiffMode})?(?: (origin/main|"\\$DIFF_BASE"))?(?: ${callerDiffMode})?(?: --(?: ${literalCallerArgument})+)?$`);
const callerDiffPreface = 'DIFF_BASE=$(git merge-base origin/main HEAD) && ';
const literalMergeBaseDiff = new RegExp(`^(?:DIFF_BASE=([0-9a-f]{7,40}) && )?git diff(?: ${callerDiffMode})? ([0-9a-f]{7,40}|"\\$DIFF_BASE")(?: ${callerDiffMode})?(?: --(?: ${literalCallerArgument})+)?$`);
function mergeBaseDiffAllowed(text: string, context?: CallerDeadlineContext): boolean {
const diff = literalMergeBaseDiff.exec(text);
if (!diff || !context || (diff[2] && diff[4]) || !!diff[1] !== (diff[3] === '"$DIFF_BASE"')) return false;
const base = spawnSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: context.fixtureRoot, encoding: 'utf8', timeout: 5000 });
return base.status === 0 && base.stdout.trim().startsWith(diff[1] ?? diff[3]);
}
function literalCallerDiffAllowed(text: string): boolean {
const recomputedBase = text.startsWith(callerDiffPreface);
@@ -217,9 +225,19 @@ function callerDeadlineCommand(command: string, context?: CallerDeadlineContext)
return { action, stateFile };
}
const reviewPlanFlags = '(?: --pass \\d+)?(?: --last (?:none|full|fix-check))?(?: --(?:fixed-blocking|fixed-below|blocking-open|touched-gate|critical-found|adversarial-ran))*(?: --reviewed-numbers [\\w:.-]+)?';
const reviewPlanForms = new RegExp(`^(?:--base main${reviewPlanFlags}|--fix-check-prompt --fix-list ${literalCallerArgument} --hunks ${literalCallerArgument} --tests ${literalCallerArgument}(?: --callers ${literalCallerArgument})?)$`);
/** The review-plan helper forms ship Step 9 names, run by the fixture's exact installed path. */
function reviewPlanCommandAllowed(text: string, runtime?: string): boolean {
const helper = runtime && `${runtime}/bin/gstack-review-plan `;
return !!helper && text.startsWith(helper) && reviewPlanForms.test(text.slice(helper.length));
}
export function qaCallerCommandAllowed(command: string, workflowCommands: string[] = [], deadline?: CallerDeadlineContext): boolean {
const text = command.trim();
if (qaHelperUsageCommand(text)) return true;
if (reviewPlanCommandAllowed(text, deadline?.runtime)) return true;
if (callerEvidenceCommand(text, deadline)) return true;
if (callerDeadlineCommand(text, deadline)) return true;
if (/\bgstack-qa-(?:deadline|evidence)\b/.test(text) && !literalCallerCLI.test(text)
@@ -227,6 +245,7 @@ export function qaCallerCommandAllowed(command: string, workflowCommands: string
if (workflowCommands.includes(text)) return true;
if (literalCallerCLI.test(text)) return true;
if (literalCallerDiffAllowed(text)) return true;
if (mergeBaseDiffAllowed(text, deadline)) return true;
if (literalCallerListingAllowed(text)) return true;
let normalizedRecord = text;
const substitutedFields: string[] = [];
@@ -699,7 +718,7 @@ export function callerReviewRecordTemplate(fixture: Pick<QaCallerFixture, 'calle
export function qaCallerSessionOptions(fixture: QaCallerFixture, runId: string): Parameters<typeof runSkillTest>[0] {
return {
prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts <literal>\` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Handoff freshness check: Read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await those results, and compare evidence with current inputs; rerun any probe whose input changed. Run the check before each of these, and dispatch each only in a later message than the check's Read results: the evidence helper's materialize, which runs once per report root and freezes the verdict; every gstack-review-log completed:true record, adversarial-review included, since a record is a completion, not preliminary bookkeeping; and the completion report. A later handoff read cannot validate an earlier materialize or completion. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change.\n\nQA fixture bindings: the deadline guard, capture, checkpoint and materialize command forms are in the installed qa/sections/exploratory.md this phase requires you to Read; use those forms verbatim with these bindings substituted literally. DEADLINE_TOOL = ${fixture.runtime}/bin/gstack-qa-deadline. EVIDENCE_TOOL = ${fixture.runtime}/bin/gstack-qa-evidence. PROBE_DIR = ${fixture.cwd}/reports. DEADLINE_FILE = ${fixture.cwd}/reports/deadline.json. SECONDS must be positive and at most 300. EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The only native COMMAND is the existing diagnostic client, bun scripts/probe.ts, with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Its diagnostic receipts are declared public/synthetic, so --public is approved. Only an explicit plan check this phase requires may use --timeout-ms 10000 in place of the deadline; every other capture, including a rerun or revalidation of an earlier probe, uses the deadline. This does not reset or bypass the smoke deadline. Only the deadline helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it. Quote arguments literally. Sources stay inside reports. Decide to execute the next probe before publishing its checkpoint, then await successful publication and dispatch that exact probe. I Line truncated
prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts <literal>\` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Handoff freshness check: Read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await those results, and compare evidence with current inputs; rerun any probe whose input changed. Run the check before each of these, and dispatch each only in a later message than the check's Read results: the evidence helper's materialize, which runs once per report root and freezes the verdict; every gstack-review-log completed:true record, adversarial-review included, since a record is a completion, not preliminary bookkeeping; and the completion report. A later handoff read cannot validate an earlier materialize or completion. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change.\n\nQA fixture bindings: the deadline guard, capture, checkpoint and materialize command forms are in the installed qa/sections/exploratory.md this phase requires you to Read; use those forms verbatim with these bindings substituted literally. DEADLINE_TOOL = ${fixture.runtime}/bin/gstack-qa-deadline. EVIDENCE_TOOL = ${fixture.runtime}/bin/gstack-qa-evidence. PROBE_DIR = ${fixture.cwd}/reports. DEADLINE_FILE = ${fixture.cwd}/reports/deadline.json. SECONDS must be positive and at most 300. EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The only native COMMAND is the existing diagnostic client, bun scripts/probe.ts, with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Its diagnostic receipts are declared public/synthetic, so --public is approved. Only an explicit plan check this phase requires may use --timeout-ms 10000 in place of the deadline; every other capture, including a rerun or revalidation of an earlier probe, uses the deadline. This does not reset or bypass the smoke deadline. Only the deadline helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it. Quote arguments literally. Sources stay inside reports. Decide to execute the next probe before publishing its checkpoint, then await successful publication and dispatch that exact probe. I Line truncated
appendSystemPrompt: `Caller execution scheduling (fixture contract):
This session has at most 25 assistant turns, including required verification and final artifacts. The command boundary applies to each Bash call, not to the number of independent tool calls in an assistant turn.
After required clock and approval prerequisites settle, issue independent source Reads and read-only discovery together as separate native tool calls once their paths and inputs are known. Read-only discovery is Read, Glob, Grep and the declared inventory, Git and review-read forms; a capture, a deadline guard start or run, or a diagnostic-client run is a probe, never discovery. For example, after the first clock read, one response can Read the caller excerpt, README.md, every product file and the referenced checklist and templates. Wait for their results before decisions that depend on them.
@@ -438,9 +438,9 @@ function inspectsFile(source: string, file: string, text: string, expected: Star
}
/** Inspect native public tool blocks only; narration and instruction contents are not evidence. */
function nativeToolPairs(events: unknown[]) {
function nativeToolPairs(events: unknown[], includeErrors = false) {
const pending = new Map<string, { tool: string; input: any; at: number }>();
const pairs: { tool: string; input: any; at: number; returnedAt: number; text: string }[] = [];
const pairs: { tool: string; input: any; at: number; returnedAt: number; text: string; error: boolean }[] = [];
let position = 0;
for (const event of events as any[]) {
for (const block of Array.isArray(event?.message?.content) ? event.message.content : []) {
@@ -450,11 +450,11 @@ function nativeToolPairs(events: unknown[]) {
} else if (event.type === 'user' && block.type === 'tool_result') {
const call = pending.get(block.tool_use_id);
pending.delete(block.tool_use_id);
if (!call || block.is_error === true) continue;
if (!call || block.is_error === true && !includeErrors) continue;
const text = typeof block.content === 'string' ? block.content
: Array.isArray(block.content) ? block.content.filter((part: any) => part.type === 'text'
&& typeof part.text === 'string').map((part: any) => part.text).join('\n') : '';
pairs.push({ ...call, returnedAt: position, text });
pairs.push({ ...call, returnedAt: position, text, error: block.is_error === true });
}
}
}
@@ -467,6 +467,8 @@ interface CheckerContext extends StartContext {
finding: any;
reusable: boolean;
coveredPaths: string[];
/** Exact evidence sources: an errored Bash read counts only when its output holds the whole source. */
sources?: Record<string, string>;
}
export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerContext): boolean {
@@ -497,8 +499,11 @@ export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerCo
}).map(call => ({ ...call, last: call === calls.at(-1),
words: call.words.map(word => expandVariables(word, call.variables) ?? word) }));
};
const pairs = nativeToolPairs(events);
const bash = pairs.filter(pair => pair.tool === 'Bash' && typeof pair.input?.command === 'string');
// Run 40617200857: a later `echo ===` failed in the same call, so the result that
// returned the whole first file was marked is_error. Such a read counts only in a
// multi-command call whose output holds the exact source. Start, check and finish stay strict.
const pairs = nativeToolPairs(events, true);
const bash = pairs.filter(pair => !pair.error && pair.tool === 'Bash' && typeof pair.input?.command === 'string');
for (const start of bash) {
const direct = invocations(start.input.command).some(call => call.last && call.words.length === 3
&& call.words[1] === '--start' && call.words[2] === 'review');
@@ -546,10 +551,13 @@ export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerCo
const reads = pairs.filter(pair => pair.at > start.returnedAt && pair.returnedAt < check.at && pair.text.trim());
if (!expected.finding.evidence_paths.every((relative: string) => {
const file = paths.join(expected.repo, relative);
return reads.some(read => read.tool === 'Read' && typeof read.input?.file_path === 'string'
const source = expected.sources?.[relative]?.replace(/\n$/, '');
return reads.some(read => read.tool === 'Read' && !read.error && typeof read.input?.file_path === 'string'
&& literalPath(read.input.file_path, expected.repo, paths) === file
|| read.tool === 'Bash' && typeof read.input?.command === 'string'
&& inspectsFile(read.input.command, file, read.text, expected));
&& inspectsFile(read.input.command, file, read.text, expected)
&& (!read.error || !!source && commands(read.input.command).length > 1
&& read.text.replace(/^ *\d+\t/gm, '').includes(source)));
})) continue;
if (bash.some(finish => finish.at > check.returnedAt && invocations(finish.input.command).some(call =>
call.words.length === 4 && call.words[2] === '--finish' && call.words[3] === token))) return true;
+10 -2
View File
@@ -98,10 +98,18 @@ export function ghQueueShim(repo: FixtureRepo, data: { prs: Record<string, unkno
return { PATH: `${dir}:${process.env.PATH ?? ''}` };
}
/** A coordinator's separate clone of the fixture origin. */
/**
* A coordinator's separate clone of the fixture origin, fetched through git's
* transport (--no-local) like a real clone of GitHub. A local clone copies
* origin.git/objects entry by entry, and every push into the fixture origin
* leaves a detached `git maintenance run --auto` there that creates and
* deletes objects/maintenance.lock after the push returns: local transport
* drops the shard's GIT_CONFIG_COUNT maintenance-off backstop, so it never
* reaches that receive-pack.
*/
export function coordinatorClone(repo: FixtureRepo): string {
const dir = path.join(repo.root, 'coordinator');
const r = spawnSync('git', ['clone', '-q', repo.origin, dir], { timeout: 60_000, env: { ...process.env, ...repo.env } });
const r = spawnSync('git', ['clone', '-q', '--no-local', repo.origin, dir], { timeout: 60_000, env: { ...process.env, ...repo.env } });
if (r.status !== 0) throw new Error(`clone failed: ${r.stderr?.toString()}`);
return dir;
}
+301
View File
@@ -0,0 +1,301 @@
/**
* Module loadouts (GSTA-140, PR 1a): modules/loadouts.json, the derived
* catalog, --modules selection, the render filter, and gstack-modules render.
* Every rung loads `full` today, so the synthetic ad-hoc arms below are the
* only exclusion coverage; committed bytes are covered by Skill Docs Freshness.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { MODEL_CATALOG } from '../lib/model-catalog';
import { runGeneration } from '../scripts/gen-skill-docs';
import { ModuleError, excludedSkillDirs, loadLoadouts, moduleCatalog, normalizeModelId, rungFor, selectModules, validateLoadouts, type ModuleErrorCode } from '../scripts/modules';
import { LOCK_FILE, renderLoadout, type LoadoutLock } from '../scripts/modules-render';
import { applySectionGuards } from '../scripts/resolvers/sections';
import { PREAMBLE_MODULE_IDS, preambleSections } from '../scripts/resolvers/preamble';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
const manifest = loadLoadouts();
const catalog = moduleCatalog(manifest);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadouts-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
/** Model ids gstack runs on or GSTA-2 measures (Capy's model list, 2026-10-28). */
const CORPUS = [
...MODEL_CATALOG.map(e => e.model), 'gpt-6-astra',
'anthropic/claude-opus-5-5', 'anthropic/claude-haiku-5-5', 'anthropic/claude-sonnet-5-5', 'anthropic/claude-fable-5-1',
'openai/gpt-6.1-sol', 'openai/gpt-6-luna', 'openai/gpt-6.1-terra', 'deepseek/deepseek-v4.1-flash-1017', 'deepseek/deepseek-v4-pro',
'zai/glm-5.3', 'zai/glm-5.3-flash', 'google/gemini-4-argon', 'google/gemini-3.8-flash', 'xai/grok-4.7',
'moonshotai/kimi-k3.1-pro', 'qwen/qwen3.8-max', 'qwen/qwen3.8-flash', 'minimax/minimax-m3',
];
function codeOf(fn: () => unknown): ModuleErrorCode | 'none' {
try { fn(); return 'none'; } catch (error) { if (error instanceof ModuleError) return error.code; throw error; }
}
describe('modules/loadouts.json', () => {
test('validates against the derived catalog, with disjoint rungs over the model corpus', () => {
expect(validateLoadouts(manifest, catalog, CORPUS)).toEqual([]);
});
test('every rung and unknown loads full until GSTA-2 measures a smaller loadout', () => {
expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']);
});
test('the catalog derives every module kind from its source files', () => {
const ids = new Set(catalog.map(e => e.id));
expect(ids.has('skill:gstack')).toBe(true);
expect(ids.has('section:office-hours/second-opinion')).toBe(true);
expect(ids.has('overlay:opus-4-8')).toBe(true);
for (const id of PREAMBLE_MODULE_IDS) expect(ids.has(id)).toBe(true);
for (const entry of catalog) expect(fs.existsSync(path.join(ROOT, entry.source))).toBe(true);
expect(catalog.filter(e => e.pinned).map(e => e.id)).toEqual(expect.arrayContaining(['preamble:bash', 'skill:gstack', 'skill:gstack-upgrade', ...manifest.pinned]));
});
test('model ids normalize and map to rungs', () => {
expect(normalizeModelId(' Anthropic/Claude-Haiku-5-5[1m] ')).toBe('claude-haiku-5-5');
expect(rungFor(manifest, 'anthropic/claude-opus-5-5')).toBe('strong');
expect(rungFor(manifest, 'anthropic/claude-haiku-5-5')).toBe('mid');
expect(rungFor(manifest, 'deepseek/deepseek-v4.1-flash-1017')).toBe('light');
expect(rungFor(manifest, 'zai/glm-5.3-flash')).toBe('light');
expect(rungFor(manifest, 'zai/glm-5.3')).toBe('mid');
expect(rungFor(manifest, 'haiku')).toBe('mid');
expect(rungFor(manifest, 'some-new-model')).toBe('unknown');
expect(rungFor(manifest, 'constructor')).toBe('unknown');
for (const model of MODEL_CATALOG) expect(rungFor(manifest, model.model)).not.toBe('unknown');
});
test('a broken manifest names each problem', () => {
const bad = structuredClone(manifest);
bad.loadouts.lean = { base: 'missing', exclude: ['preamble:bash', 'section:nope/none'] };
bad.default.mid = 'ghost';
bad.default.light = 'toString';
bad.loadouts.noship = { base: 'constructor', exclude: ['skill:ship'] };
bad.rungs.light.push('claude-opus-5*');
const problems = validateLoadouts(bad, catalog, CORPUS).join('\n');
expect(problems).toContain('loadouts.lean.base names no loadout');
expect(problems).toContain('excludes pinned preamble:bash');
expect(problems).toContain('"section:nope/none", which is not a module');
expect(problems).toContain('default.mid names no loadout');
expect(problems).toContain('default.light names no loadout');
expect(problems).toContain('loadouts.noship.base names no loadout');
expect(problems).toContain('excludes skill:ship, which holds pinned section:ship/');
expect(problems).toContain('matches more than one rung');
});
});
describe('selectModules', () => {
test('full and an empty spec render today\'s bytes (no filter)', () => {
expect(selectModules({ loadout: 'full' })).toBeNull();
expect(selectModules({})).toBeNull();
expect(selectModules({ modules: 'full,-section:office-hours/second-opinion,+section:office-hours/second-opinion' })).toBeNull();
});
test('an ad-hoc spec records its name, normalized spec and hash', () => {
const s = selectModules({ modules: ' full , -skill:design-shotgun ' })!;
expect(s.name).toBe('adhoc');
expect(s.spec).toBe('full,-skill:design-shotgun');
expect(s.specSha256).toMatch(/^[0-9a-f]{64}$/);
expect(excludedSkillDirs(s)).toEqual(['design-shotgun']);
});
test('bad input is refused with a code, never a fallback to full', () => {
expect(codeOf(() => selectModules({ loadout: 'nope' }))).toBe('LOADOUT_UNKNOWN');
expect(codeOf(() => selectModules({ modules: 'full,skill:qa' }))).toBe('MODULE_SPEC_INVALID');
expect(codeOf(() => selectModules({ loadout: 'full', modules: 'full' }))).toBe('MODULE_SPEC_INVALID');
expect(codeOf(() => selectModules({ loadout: 'full,-skill:qa' }))).toBe('MODULE_SPEC_INVALID');
expect(codeOf(() => selectModules({ modules: 'full,-skill:nope' }))).toBe('MODULE_UNKNOWN');
expect(codeOf(() => selectModules({ modules: 'full,-preamble:bash' }))).toBe('MODULE_PINNED');
expect(codeOf(() => selectModules({ modules: 'full,-section:ship/adversarial' }))).toBe('MODULE_PINNED');
expect(codeOf(() => selectModules({ modules: 'full,-skill:ship' }))).toBe('MODULE_PINNED');
expect(codeOf(() => selectModules({ modules: 'full,-skill:autoplan' }))).toBe('MODULE_PINNED');
expect(codeOf(() => selectModules({ loadout: 'constructor' }))).toBe('LOADOUT_UNKNOWN');
});
test('every error code has a troubleshooting section', () => {
const doc = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf-8');
const codes: ModuleErrorCode[] = ['LOADOUTS_INVALID', 'LOADOUT_UNKNOWN', 'MODULE_SPEC_INVALID', 'MODULE_UNKNOWN', 'MODULE_PINNED', 'MODULE_EXCLUSION_UNSAFE', 'RENDER_FAILED', 'RENDER_DEST_NOT_OWNED'];
for (const code of codes) {
const anchor = code.toLowerCase().replaceAll('_', '-');
const at = doc.indexOf(`<a id="${anchor}"></a>`);
expect(at).toBeGreaterThan(-1);
expect(doc.slice(at).split('\n')[1]).toMatch(new RegExp(`^### \`${code}`));
}
});
});
describe('render filter units', () => {
const ctx = (extra: Partial<TemplateContext> = {}): TemplateContext => ({
skillName: 'office-hours', tmplPath: 'office-hours/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.claude, preambleTier: 3, model: 'claude', ...extra,
});
const filter = (excluded: string[], preambleTierMax?: number) => ({
name: 'test', excluded: new Set(excluded), pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)), preambleTierMax,
});
test('section guards vanish under full and drop their text when the section is excluded', () => {
const text = 'a\n{{IF_SECTION:second-opinion}}offer\n{{/IF_SECTION}}b';
expect(applySectionGuards(text, ctx())).toBe('a\noffer\nb');
expect(applySectionGuards(text, ctx({ modules: filter(['section:office-hours/second-opinion']) }))).toBe('a\nb');
expect(() => applySectionGuards('{{IF_SECTION:nope}}x{{/IF_SECTION}}', ctx())).toThrow('no section "nope"');
});
test('a tier cap drops unpinned pieces; pinned pieces follow full', () => {
const full = preambleSections(ctx()).join('\n');
const capped = preambleSections(ctx({ modules: filter([], 1) })).join('\n');
expect(full).toContain('## Writing Style');
expect(capped).not.toContain('## Writing Style');
expect(capped).toContain('## AskUserQuestion Format');
const tier1 = ctx({ preambleTier: 1 });
expect(preambleSections({ ...tier1, modules: filter([], 1) })).toEqual(preambleSections(tier1));
});
test('excluding a base overlay drops its text from the overlay that inherits it', () => {
const astra = ctx({ model: 'gpt-6-astra' as TemplateContext['model'] });
const withBase = preambleSections(astra).join('\n');
const withoutBase = preambleSections({ ...astra, modules: filter(['overlay:gpt']) }).join('\n');
expect(withBase.length).toBeGreaterThan(withoutBase.length);
expect(preambleSections({ ...astra, modules: filter(['overlay:gpt-6-astra']) }).join('\n')).not.toContain('Model-Specific Behavioral Patch');
});
});
describe('gstack-modules render', () => {
const spec = 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design,-preamble:writing-style,-overlay:claude,-skill:design-shotgun';
const readLock = (dir: string) => JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as LoadoutLock;
const ensureArm = async (dir: string) => { if (!fs.existsSync(path.join(dir, LOCK_FILE))) await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: dir }); };
test('claude: one arm excludes one module of each kind and locks every file', async () => {
const out = path.join(tmp, 'claude-arm');
const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: out });
const skill = fs.readFileSync(path.join(out, 'office-hours/SKILL.md'), 'utf-8');
expect(skill).toContain('<!-- loadout: adhoc -->');
expect(skill).not.toContain('Want a second opinion');
expect(skill).not.toContain('## Phase 4.25: Visual design');
expect(skill).toContain('> Not loaded in the adhoc loadout: skip running the Phase 3.5 second opinion');
expect(skill).not.toContain('sections/second-opinion.md');
expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(false);
expect(fs.existsSync(path.join(out, 'office-hours/sections/design-and-handoff.md'))).toBe(true);
expect(skill).toContain(`${out}/office-hours/sections/design-and-handoff.md`);
const preamble = fs.readFileSync(path.join(out, 'office-hours/sections/preamble.md'), 'utf-8');
expect(preamble).not.toContain('## Writing Style');
expect(preamble).not.toContain('Model-Specific Behavioral Patch');
expect(preamble).toContain('## AskUserQuestion Format');
expect(fs.existsSync(path.join(out, 'design-shotgun'))).toBe(false);
expect(fs.readFileSync(path.join(out, 'gstack/llms.txt'), 'utf-8')).not.toContain('design-shotgun/SKILL.md');
expect(fs.readFileSync(path.join(out, 'SKILL.md'), 'utf-8')).toContain('Disabled on this install (never invoke or suggest them): `/design-shotgun`');
expect(lock).toMatchObject({ loadout: 'adhoc', spec, host: 'claude', model: 'claude' });
expect(lock.excluded).toEqual(spec.split(',').slice(1).map(t => t.slice(1)).sort());
expect(readLock(out).files.length).toBe(lock.files.length);
expect(lock.files.find(f => f.path === 'office-hours/SKILL.md')!.bytes).toBe(Buffer.byteLength(skill));
expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-') || n.includes('.old-'))).toEqual([]);
});
test('codex: the same arm inlines nothing it excluded', async () => {
const out = path.join(tmp, 'codex-arm');
await renderLoadout({ host: 'codex', selection: selectModules({ modules: spec }), outDir: out, model: 'gpt-6-astra' });
const skill = fs.readFileSync(path.join(out, '.agents/skills/gstack-office-hours/SKILL.md'), 'utf-8');
expect(skill).not.toContain('Want a second opinion');
expect(skill).toContain('> Not loaded in the adhoc loadout');
expect(fs.existsSync(path.join(out, '.agents/skills/gstack-design-shotgun'))).toBe(false);
expect(readLock(out).model).toBe('gpt-6-astra');
});
test('a dangling reference is refused and the earlier render survives', async () => {
const out = path.join(tmp, 'claude-arm');
await ensureArm(out);
const before = readLock(out);
const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out });
await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' });
await expect(unsafe).rejects.toThrow('referenced by plan-design-review/sections/checklist.md');
await expect(renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:qa' }), outDir: out })).rejects.toThrow(/skill:qa: referenced by qa-only\//);
expect(readLock(out)).toEqual(before);
expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-'))).toEqual([]);
});
test('a later render replaces an earlier one; a foreign directory is never replaced', async () => {
const out = path.join(tmp, 'claude-arm');
await ensureArm(out);
expect(readLock(out).loadout).toBe('adhoc');
const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:design-shotgun' }), outDir: out });
expect(readLock(out).spec_sha256).toBe(lock.spec_sha256);
expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(true);
const foreign = path.join(tmp, 'foreign');
fs.mkdirSync(foreign);
fs.writeFileSync(path.join(foreign, 'notes.txt'), 'mine');
await expect(renderLoadout({ host: 'claude', selection: null, outDir: foreign })).rejects.toMatchObject({ code: 'RENDER_DEST_NOT_OWNED' });
expect(fs.readFileSync(path.join(foreign, 'notes.txt'), 'utf-8')).toBe('mine');
});
test('the full loadout regenerates the committed claude tree byte for byte', async () => {
const result = await runGeneration({ host: 'claude', dryRun: true, modules: selectModules({ loadout: 'full' }) });
expect(result.diagnostics.filter(d => d.kind === 'stale' || d.kind === 'error')).toEqual([]);
});
});
describe('modules/loadouts.schema.json', () => {
const schema = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.schema.json'), 'utf-8'));
const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.json'), 'utf-8'));
test('the manifest carries every required key and no key the schema lacks', () => {
for (const key of schema.required) expect(raw).toHaveProperty(key);
for (const key of Object.keys(raw)) expect(Object.keys(schema.properties)).toContain(key);
expect(raw.version).toBe(schema.properties.version.const);
});
test('rung names and patterns agree between the schema and the manifest', () => {
const rungs = schema.properties.rungs.required as string[];
expect(Object.keys(raw.rungs).sort()).toEqual([...rungs].sort());
expect([...schema.properties.aliases.additionalProperties.enum].sort()).toEqual([...rungs].sort());
const pattern = new RegExp(schema.properties.rungs.properties.frontier.items.pattern);
for (const rung of rungs) for (const glob of raw.rungs[rung]) expect(glob).toMatch(pattern);
});
});
describe('bin/gstack-modules', () => {
const run = (...args: string[]) => {
const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), ...args], { cwd: ROOT, encoding: 'utf-8', timeout: 60_000 });
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
};
test('validate and list read the committed manifest', () => {
expect(run('validate')).toMatchObject({ code: 0, stdout: expect.stringContaining('LOADOUTS: ok') });
const listed = JSON.parse(run('list', '--json').stdout);
expect(listed.modules.map((m: { id: string }) => m.id)).toEqual(catalog.map(e => e.id));
const pinned = run('list', '--pinned').stdout;
expect(pinned).toContain('preamble:bash');
expect(pinned).not.toContain('skill:design-shotgun');
});
test('refused inputs exit 2 with their code; unknown commands print usage', () => {
const out = path.join(tmp, 'cli-refused');
expect(run('render', '--host', 'claude', '--modules', 'full,-skill:nope', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_UNKNOWN') });
expect(run('render', '--host', 'claude', '--modules', 'full,-preamble:bash', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_PINNED') });
expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--plan-mode', 'maybe')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--model', 'no-such-model')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
expect(run('render', '--host', 'claude', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
expect(fs.existsSync(out)).toBe(false);
expect(run('bogus')).toMatchObject({ code: 2, stderr: expect.stringContaining('Usage:') });
expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') });
});
test('gen:skill-docs refuses an exclusion render into the committed tree', () => {
const before = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout;
for (const extra of [[], ['--out-dir', '.']]) {
const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun', ...extra], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 });
expect(result.status).toBe(2);
expect(result.stderr).toContain('MODULE_SPEC_INVALID');
}
expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before);
});
test('render prints one RENDERED line and writes the lock', () => {
const out = path.join(tmp, 'cli-render');
const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out);
expect(result.code).toBe(0);
expect(result.stdout).toMatch(/^RENDERED: loadout=adhoc host=claude files=\d+ bytes=\d+ excluded=1 lock=/);
expect(JSON.parse(fs.readFileSync(path.join(out, LOCK_FILE), 'utf-8')).excluded).toEqual(['skill:design-shotgun']);
});
});
+19 -2
View File
@@ -7,7 +7,7 @@ import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const SUITE = path.join(ROOT, 'test/skill-e2e-office-hours-brain-writeback.test.ts');
async function probeWritebackEnvironment(option: 'env' | 'extraEnv') {
async function probeWritebackEnvironment(option: 'env' | 'extraEnv', put: 'content' | 'stdin' | 'stdin-short' = 'content') {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'office-hours-env-'));
const driverBin = path.join(dir, 'bin');
const operatorHome = path.join(dir, 'operator-home');
@@ -43,7 +43,9 @@ const version = Bun.spawn(['gbrain', '--version'], { env: process.env, stdin: 'i
const [versionOut, versionErr, versionCode] = await Promise.all([new Response(version.stdout).text(), new Response(version.stderr).text(), version.exited]);
if (versionCode || versionErr || versionOut.trim() !== 'gbrain test-0.41.0') process.exit(44);
const payload = '---\\ntitle: Pixel fund\\ntags: [fixture]\\n---\\n' + 'Design detail. '.repeat(25);
const put = Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund', '--content', payload], { env: process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
const put = ${JSON.stringify(put)} === 'content'
? Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund', '--content', payload], { env: process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
: Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund'], { env: process.env, stdin: new Blob([${JSON.stringify(put)} === 'stdin' ? payload : 'not a page']), stdout: 'pipe', stderr: 'pipe' });
const [putOut, putErr, putCode] = await Promise.all([new Response(put.stdout).text(), new Response(put.stderr).text(), put.exited]);
if (putCode || putOut || putErr) process.exit(45);
fs.writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({ ...facts, fakeVersionMatches: true, fakePutSucceeded: true,
@@ -108,6 +110,21 @@ describe('office-hours writeback child environment', () => {
expect(facts.fakePutSucceeded).toBe(true);
}, 30_000);
// GSTA-134 b0fba22: failing trials piped the page on stdin, as the SAVE_RESULTS template shows.
test('a page piped on stdin reaches the payload the original assertions read', async () => {
const { code, output, facts } = await probeWritebackEnvironment('env', 'stdin');
expect(code, output).toBe(0);
expect(output).toContain('1 pass');
expect(facts.fakePutSucceeded).toBe(true);
}, 30_000);
test('a stdin body without frontmatter still fails the original payload assertions', async () => {
const { code, output, facts } = await probeWritebackEnvironment('env', 'stdin-short');
expect(code).toBe(1);
expect(output).toContain('Received: "not a page"');
expect(facts.fakePutSucceeded).toBe(true);
}, 30_000);
test('restoring extraEnv fails before any fallback gbrain executes', async () => {
const { code, output, facts } = await probeWritebackEnvironment('extraEnv');
expect(code).toBe(1);
+22 -2
View File
@@ -95,6 +95,10 @@ const captured39363705692 = JSON.parse(fs.readFileSync(${JSON.stringify(path.joi
// "completeness") and offered "All 7 passes (recommended)" against "States + layout only".
const captured39852003387 = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(ROOT, 'test/fixtures/design-ui-focus-capture-39852003387.json'))}, 'utf8'))
.calls.map(({ preReview, observedAtMs, call }) => nativePlanCallFingerprint(call, observedAtMs, preReview));
// Run 40664336141 slice 6 (00e71383): Opus 5.5 asked "I rated this plan 4/10 on design completeness.
// Should I review all 7 areas or focus on a few?" with lettered options ("A) Review all 7 areas").
const captured40664336141 = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(ROOT, 'test/fixtures/design-ui-focus-capture-40664336141.json'))}, 'utf8'))
.calls.map(({ preReview, observedAtMs, call }) => nativePlanCallFingerprint(call, observedAtMs, preReview));
const unnumberedFinding = fp('unnumbered-finding', "Which region is the dashboard's primary anchor, and how is the page composed?", false);
unnumberedFinding.nativeCall.questions[0].options = nativeFinding.nativeCall.questions[0].options;
const finding = fp('finding', 'Which loading feedback should Save show?', false);
@@ -223,6 +227,20 @@ mock.module(${JSON.stringify(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'
expect(opts.isReviewAUQ(captured39852003387[0])).toBe(false);
expect(captured39852003387.slice(1, 3).some(call => opts.isLastStep0AUQ(call) || opts.isReviewAUQ(call))).toBe(false);
expect(opts.isReviewAUQ(captured39852003387[3])).toBe(true);
expect(opts.isLastStep0AUQ(captured40664336141[1])).toBe(true);
expect(opts.isReviewAUQ(captured40664336141[1])).toBe(false);
expect([0, 2].some(index => opts.isLastStep0AUQ(captured40664336141[index]))).toBe(false);
expect(opts.isReviewAUQ(captured40664336141[3])).toBe(true);
const lettered = ['A) Review all 7 areas (recommended)', 'B) Focus on hierarchy/states/responsive'];
for (const [label, question, labels] of [
['title-offer-unrated', 'D2: Should I review all 7 areas or focus on a few?\\nELI10: I will review every area unless you narrow it.', lettered],
['title-offer-out-of-seven', 'D2: I rated this plan 4/7 on design completeness. Should I review all 7 areas or focus on a few?', lettered],
['title-offer-without-focus', 'D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas?', lettered],
['title-offer-other-count', 'D2: I rated this plan 4/10 on design completeness. Should I review all 4 areas or focus on a few?', lettered],
['offer-below-title', 'D2: I rated this plan 4/10 on design completeness. Want me to focus?\\nNext I will review all 7 areas.', lettered],
['issue-labeled-title-offer', 'D5 (Issue 3): Keyboard focus across all 7 panels? I rated this plan 4/10 on design completeness.', ['3A Trap focus in the modal', '3B Leave focus order as is']],
['single-option-title-offer', 'D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?', lettered.slice(0, 1)],
]) expect(opts.isLastStep0AUQ(labeled(label, question, labels)), label).toBe(false);
const areas = ['All 7 areas (recommended)', 'Only specific areas'];
for (const body of ['ELI10: I rated this plan 4/10 for design completeness.', 'ELI10: This plan is a 3/10 on design completeness.',
'ELI10: Design completeness: 6.5/10. The gaps are layout and states.', 'ELI10: I rated this plan 4/10 on design.']) {
@@ -298,6 +316,8 @@ mock.module(${JSON.stringify(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'
: mode === 'captured-39363705692-no-focus' ? captured39363705692.filter((_, index) => index !== 0)
: mode === 'captured-39852003387' ? captured39852003387
: mode === 'captured-39852003387-no-focus' ? captured39852003387.filter((_, index) => index !== 0)
: mode === 'captured-40664336141' ? captured40664336141
: mode === 'captured-40664336141-no-focus' ? captured40664336141.filter((_, index) => index !== 1)
: mode === 'captured-focus-after-finding' ? [...captured.filter((_, index) => index !== 1), captured[1]]
: mode.startsWith('target-menu') ? [target, fp('other', 'Which artifact should I inspect?', false)]
: mode === 'early-exit' ? [] : mode === 'focus-only' ? [chosenFocus]
@@ -342,12 +362,12 @@ await import(${JSON.stringify(path.join(ROOT, 'test/skill-e2e-plan-design-with-u
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
}
test.each(['source', 'paraphrase', 'native-sequence', 'native-unnumbered', 'native-passes', 'captured', 'captured-rated', 'captured-39298269902', 'captured-39363705692', 'captured-39852003387'])('UI gate seeds the exact target and accepts Design progress with an unselected no-UI alternative (%s)', mode => {
test.each(['source', 'paraphrase', 'native-sequence', 'native-unnumbered', 'native-passes', 'captured', 'captured-rated', 'captured-39298269902', 'captured-39363705692', 'captured-39852003387', 'captured-40664336141'])('UI gate seeds the exact target and accepts Design progress with an unselected no-UI alternative (%s)', mode => {
const result = exercise(mode);
expect(result.code, result.output).toBe(0);
}, 20_000);
test.each(['target-menu', 'target-menu-design-system', 'focus-only', 'captured-no-focus', 'captured-focus-after-finding', 'captured-rated-no-focus', 'captured-39298269902-no-focus', 'captured-39298269902-focus-only', 'captured-39363705692-no-focus', 'captured-39852003387-no-focus', 'native-setup-only', 'native-voices-only', 'early-exit', 'timeout', 'exited', 'unanswered-finding', 'failed-finding', 'no-native-finding'])('UI gate rejects %s and removes its fixture', mode => {
test.each(['target-menu', 'target-menu-design-system', 'focus-only', 'captured-no-focus', 'captured-focus-after-finding', 'captured-rated-no-focus', 'captured-39298269902-no-focus', 'captured-39298269902-focus-only', 'captured-39363705692-no-focus', 'captured-39852003387-no-focus', 'captured-40664336141-no-focus', 'native-setup-only', 'native-voices-only', 'early-exit', 'timeout', 'exited', 'unanswered-finding', 'failed-finding', 'no-native-finding'])('UI gate rejects %s and removes its fixture', mode => {
const result = exercise(mode);
expect(result.code, result.output).toBe(1);
expect(result.output).toContain('plan-design-review with UI scope FAILED');
+21 -1
View File
@@ -8,6 +8,7 @@ import { isPreTurnInfraFailure, recordE2E } from './helpers/e2e-helpers';
import { EvalCollector, isFinalizedEvalResultFile, listEvalJsonFiles, type EvalTestEntry } from './helpers/eval-store';
import { OFFICE_HOURS_BUN_GRACE_MS, runRecordedOfficeHoursAttempt } from './helpers/office-hours-attempt';
import { isPaidTestFile } from './helpers/paid-test-set';
import { seedHermeticGstackHome } from './helpers/hermetic-env';
import { resolveEvalModel } from '../lib/eval-model';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -21,6 +22,17 @@ const CASE = '/plan-eng-review writes GSTACK REVIEW REPORT to plan file';
type Scenario = { kind: string; table?: string; exitReason?: string; open?: boolean };
// Run the copied section's real Outside Voice preflight against a state root.
function outsideMode(section: string, home: string, gstackHome?: string): string {
const block = section.slice(section.indexOf('**Preflight — decide whether and how the outside voice runs:**')).match(/```bash\n([\s\S]*?)```/)![1];
// Git Bash on Windows needs its own spelling of the checkout path.
const bin = Bun.spawnSync(['bash', '-c', 'cd "$1" && pwd', '_', path.join(ROOT, 'bin')], { timeout: 10_000 }).stdout.toString().trim();
const child = Bun.spawnSync(['bash', '-c', block.replaceAll('"$HOME/.claude/skills/gstack/bin/gstack-config"', '"$GSTACK_CONFIG_BIN"')], {
env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_CONFIG_BIN: `${bin}/gstack-config`,
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), ...(gstackHome ? { GSTACK_HOME: gstackHome } : {}) }, timeout: 10_000 });
return child.stdout.toString().trim();
}
async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibling?: boolean } = {}) {
const owned = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-report-recording-free-'));
const evalDir = path.join(owned, 'evals');
@@ -46,7 +58,7 @@ async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibli
for (const declaration of source.matchAll(/^import[\s\S]*?;\n/gm)) executable = executable.replace(declaration[0], '');
const js = new Bun.Transpiler({ loader: 'ts', target: 'bun' }).transformSync(executable);
const args: Record<string, any> = {
expect, ROOT, runId: 'public-report-free', evalsEnabled: true, CAPTURE_LONG_MS, resolveEvalModel,
expect, ROOT, runId: 'public-report-free', evalsEnabled: true, CAPTURE_LONG_MS, resolveEvalModel, seedHermeticGstackHome,
OFFICE_HOURS_BUN_GRACE_MS, EvalCollector: BoundCollector, recordE2E,
createEvalCollector: (tier: string) => new BoundCollector(tier),
finalizeEvalCollector: async (collector: EvalCollector | null) => {
@@ -82,7 +94,10 @@ async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibli
runSkillTest: async (options: any) => {
const file = path.join(options.workingDirectory, 'plan.md');
const seed = fs.readFileSync(file, 'utf8');
const section = fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/sections/review-sections.md'), 'utf8');
calls.push({ ...options, seed,
outside: outsideMode(section, options.workingDirectory, options.env?.GSTACK_HOME),
unseeded: outsideMode(section, options.workingDirectory, fs.mkdtempSync(path.join(owned, 'unseeded-state-'))),
main: fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/SKILL.md'), 'utf8'),
section: fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/sections/review-sections.md'), 'utf8'),
});
@@ -219,7 +234,12 @@ test('canonical reports preserve both successful exits, terminal forms, metadata
expect(call.main).toBe(fs.readFileSync(path.join(ROOT, 'plan-eng-review/SKILL.md'), 'utf8'));
expect(call.section).toBe(fs.readFileSync(path.join(ROOT, 'plan-eng-review/sections/review-sections.md'), 'utf8'));
expect(call.section).toContain('| Review | Trigger | Why | Runs | Status | Findings |');
// Gate 40660626349: both reds spent 4 of 21 turns on the default-on outside voice.
expect(call.outside).toBe('CODEX_MODE: disabled');
expect(call.unseeded).not.toContain('disabled');
expect(call.unseeded).toMatch(/^CODEX_MODE: \w+$/);
}
expect(actual.fixtures.filter(dir => path.basename(dir).startsWith('skill-e2e-review-report-state-'))).toHaveLength(2);
for (const outcome of actual.outcomes) {
expect(outcome.entries).toHaveLength(1);
expect(outcome.entries[0]).toMatchObject({ name: '/plan-review-report', suite: 'Plan Review Report E2E', tier: 'e2e', passed: true,
+21
View File
@@ -10,6 +10,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildPage, cleanText, diffLines, hunks, pathSides, resolveCommit } from '../lib/pr-shots';
import { wtreeFingerprint } from '../lib/script-invocation';
const ROOT = path.resolve(import.meta.dir, '..');
const dirs: string[] = [];
@@ -120,6 +121,26 @@ describe('gstack-pr-shots CLI', () => {
expect(text).toStartWith('<details><summary>Before/after as text: The record renders as a table</summary>');
});
// #3560: pr-shots starts the browse daemon from the worktree; the daemon's state-dir setup
// appended .gstack/ to the tracked .gitignore in a repository that tracks .gstack/ config, so the
// tree changed after pregate and the /ship receipt refused with PREGATE_STALE. The stub browse
// runs the daemon's real state-dir setup (browse/src/config.ts), then fails like a browser-less host.
test.skipIf(process.platform === 'win32')('the working-tree fingerprint is unchanged after a run that starts browse (#3560)', () => {
const { dir } = repo({ '.gitignore': 'node_modules/\n.gstack/tmp/\n', '.gstack/pregate.json': '{}\n', 'doc.md': '# Doc\n\nold\n' });
fs.writeFileSync(path.join(dir, 'doc.md'), '# Doc\n\nnew\n');
const stubDir = tmp('shots-browse-');
fs.writeFileSync(path.join(stubDir, 'state-dir.ts'), `import { ensureStateDir, resolveConfig } from ${JSON.stringify(path.join(ROOT, 'browse/src/config.ts'))};\nensureStateDir(resolveConfig());\nprocess.exit(1);\n`);
const browse = path.join(stubDir, 'browse');
fs.writeFileSync(browse, `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} --no-env-file --config=/dev/null ${JSON.stringify(path.join(stubDir, 'state-dir.ts'))}\n`, { mode: 0o755 });
const before = wtreeFingerprint({ cwd: dir });
expect('tree' in before).toBe(true);
const r = cli(['--path', 'doc.md', '--caption', 'c'], dir, { GSTACK_STATE_ROOT: tmp('shots-state-'), GSTACK_BROWSE_BIN: browse, BROWSE_STATE_FILE: '' });
expect(r.out).toMatch(/^SHOT_PNG: failed /m);
expect(fs.existsSync(path.join(dir, '.gstack', '.gitignore'))).toBe(true);
expect(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')).toBe('node_modules/\n.gstack/tmp/\n');
expect(wtreeFingerprint({ cwd: dir })).toEqual(before);
});
test('a secret in a capture is refused before anything is written, without echoing it', () => {
const { dir } = repo({ 'x.txt': 'x' });
const secret = 'ghp_' + 'b'.repeat(36);
+137
View File
@@ -0,0 +1,137 @@
/**
* #3527 put Step 9's review plan into ship/sections/review-army.md as prose, so the qa-callers transport
* neither admitted the helper nor said where the plan's Step 11 voices run.
* - Paid screen 40707142223 (ship-exploratory-plan-checks, slice 10 and its rerun): the only refused call in
* both trials was `<runtime>/bin/gstack-review-plan --base main`.
* - Train 35 rebuild gate 40664336141 (ship-exploratory-late-input, slice 9): the actor never ran the plan,
* read Step 11's native adversarial pass as owed by this Steps 8-9 excerpt, and reported pass with it remaining.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from '../lib/bun-isolation';
import {
createQaCallerFixture, qaCallerCommandAllowed, qaCallerSessionOptions, validateCallerEvidence,
type QaCallerCase, type QaCallerFixture,
} from './helpers/qa-callers-fixture';
const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/qa-caller-review-plan-40707142223.json'), 'utf8'));
const specialistDiff = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/qa-caller-merge-base-diff-40737741454.json'), 'utf8'));
const SHIP_CASES: QaCallerCase[] = ['ship-exploratory-small-cli', 'ship-exploratory-unavailable', 'ship-exploratory-plan-checks', 'ship-exploratory-late-input'];
const fixtures: QaCallerFixture[] = [];
afterEach(async () => {
for (const fixture of fixtures.splice(0)) { await fixture.close(); fs.rmSync(fixture.root, { recursive: true, force: true }); }
});
const fixtureFor = (caseId: QaCallerCase, installRuntime = false) => {
const fixture = createQaCallerFixture(caseId, { installRuntime });
fixtures.push(fixture);
return fixture;
};
const context = (fixture: QaCallerFixture) => ({ runtime: fixture.runtime, fixtureRoot: fixture.cwd });
describe('ship Step 9 review plan through the qa-callers transport', () => {
for (const [run, trial] of Object.entries(captured.runs) as [string, { root: string; commands: { command: string }[] }][]) {
test(`captured ${run}: every Bash call the actor made is inside the declared interface`, () => {
const fixture = fixtureFor('ship-exploratory-plan-checks', true);
const commands = trial.commands.map(({ command }) => command.replaceAll(trial.root, fixture.root));
expect(commands.filter(command => command.includes('/bin/gstack-review-plan '))).toEqual([`${fixture.runtime}/bin/gstack-review-plan --base main`]);
expect(commands.filter(command => !qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)))).toEqual([]);
});
}
test('the helper forms Step 9 names are admitted at the exact installed path, and nothing else is', () => {
const fixture = fixtureFor('ship-exploratory-late-input');
const helper = `${fixture.runtime}/bin/gstack-review-plan`;
const allowed = (command: string) => qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture));
for (const args of ['--base main', '--base main --pass 1 --last full', '--base main --pass 2 --last fix-check --fixed-below --adversarial-ran',
'--base main --pass 1 --last full --blocking-open --touched-gate --critical-found --reviewed-numbers fp:0a1b',
'--fix-check-prompt --fix-list reports/fix-list.md --hunks reports/hunks.diff --tests reports/tests.txt',
"--fix-check-prompt --fix-list 'reports/fix list.md' --hunks reports/h.diff --tests reports/t.txt --callers reports/c.txt"]) {
expect(allowed(`${helper} ${args}`), args).toBe(true);
}
for (const command of [`${helper} --base origin/main`, `${helper} --base main --json`, `${helper} --base main --force all`,
`${helper} --base main --last full --pass 1`, helper, `${helper} --base main | tee reports/plan.txt`, `${helper} --base main; rm -rf reports`,
`${helper} --base main && git reset --hard`, `${helper} --base "$(git rev-parse HEAD)"`, `${helper} --fix-check-prompt --fix-list $HOME/x --hunks a --tests b`,
`bun ${helper} --base main`, `${helper}x --base main`, `${fixture.runtime}/bin/gstack-review-planner --base main`,
'/tmp/elsewhere/bin/gstack-review-plan --base main', '~/.claude/skills/gstack/bin/gstack-review-plan --base main']) {
expect(allowed(command), command).toBe(false);
}
expect(qaCallerCommandAllowed(`${helper} --base main`, fixture.workflowCommands)).toBe(false);
});
test.each(SHIP_CASES)('%s: every reviewer the real plan dispatches has a declared route in the transport', caseId => {
const fixture = fixtureFor(caseId, true);
const options = qaCallerSessionOptions(fixture, 'free-review-plan');
expect(qaCallerCommandAllowed(`${fixture.runtime}/bin/gstack-review-plan --base main`, fixture.workflowCommands, context(fixture))).toBe(true);
const plan = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(fixture.runtime, 'bin/gstack-review-plan'), '--base', 'main'],
{ cwd: fixture.cwd, env: { ...process.env, ...options.env }, encoding: 'utf8', timeout: 60_000 });
expect(plan.status, plan.stderr).toBe(0);
const roles = [...plan.stdout.matchAll(/^DISPATCH: (\S+)/gm)].map(match => match[1]);
expect(roles).toContain('adversarial-native');
const prompt = options.prompt.toLowerCase();
for (const role of roles) expect(prompt.includes(role) || prompt.includes(role.replace('-', ' ')), role).toBe(true);
expect(options.prompt).toContain("The plan's adversarial-native and outside-voice lines are Step 11's voices, which Step 11 runs in a later stage outside this excerpt; they are not remaining work of this phase.");
});
test('review keeps its own Step 4.8 transport and is not told about ship Step 11', () => {
const prompt = qaCallerSessionOptions(fixtureFor('review-exploratory-small-cli'), 'free-review-plan').prompt;
expect(prompt).toContain('the native adversarial reviewer (Step 4.8)');
expect(prompt).not.toContain('gstack-review-plan');
expect(prompt).not.toContain("Step 11's voices");
});
test('captured 40664336141 late-input receipt: pass with remaining work is still a false green', () => {
const receipt = captured.lateInputReceipt;
expect(receipt.remaining).toContain('Step 11 native adversarial review (not dispatched)');
const reportRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qc-late-'));
const validate = (remaining: string[]) => validateCallerEvidence({ caller: 'ship', result: { exitReason: 'success', transcript: [] }, probes: [],
receipt: { status: receipt.status, probes: [], remaining }, currentSnapshot: 'none', requiredCharters: [], mutations: [], observerComplete: true,
reportRoot, checkpointFiles: {}, reportMarkdown: '' });
try {
expect(validate(receipt.remaining)).toContain('blocked, failing or incomplete coverage reported green');
expect(validate([])).not.toContain('blocked, failing or incomplete coverage reported green');
} finally { fs.rmSync(reportRoot, { recursive: true, force: true }); }
});
});
/**
* Paid screen 40737741454 (ship-exploratory-unavailable, slice 11 and its rerun): the plan dispatched the
* state-machines specialist, review-army tells the parent to write the merge-base sha into the specialist's
* diff command, and the only refused call in both trials was that child's `git diff <merge-base sha>`.
*/
describe('review-army specialist diff at the literal merge base', () => {
const mergeBase = (fixture: QaCallerFixture) => spawnSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: fixture.cwd, encoding: 'utf8', timeout: 5000 }).stdout.trim();
for (const [run, trial] of Object.entries(specialistDiff.runs) as [string, { root: string; mergeBase: string; commands: { command: string }[] }][]) {
test(`captured ${run}: every Bash call, the specialist child's included, is inside the declared interface`, () => {
const fixture = fixtureFor('ship-exploratory-unavailable', true);
const base = mergeBase(fixture).slice(0, trial.mergeBase.length);
const commands = trial.commands.map(({ command }) => command.replaceAll(trial.root, fixture.root).replaceAll(trial.mergeBase, base));
expect(commands).toContain(`git diff ${base}`);
expect(commands.filter(command => !qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)))).toEqual([]);
});
}
test('only the fixture merge base is admitted, bare or in the specialist DIFF_BASE form, and the prompt declares it', () => {
const fixture = fixtureFor('ship-exploratory-unavailable');
const base = mergeBase(fixture);
const allowed = (command: string) => qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture));
for (const command of [`git diff ${base}`, `git diff ${base.slice(0, 7)}`, `git diff --stat ${base.slice(0, 12)}`, `git diff ${base} -- scale.ts`,
`DIFF_BASE=${base} && git diff "$DIFF_BASE"`, `DIFF_BASE=${base.slice(0, 9)} && git diff --name-status "$DIFF_BASE"`]) {
expect(allowed(command), command).toBe(true);
}
const other = base.replace(/^./, base[0] === '0' ? '1' : '0');
for (const command of [`git diff ${other}`, `git diff ${base.slice(0, 6)}`, `git diff ${base} --output=reports/x`, `git diff --stat ${base} --numstat`,
`DIFF_BASE=${other} && git diff "$DIFF_BASE"`, `DIFF_BASE=${base} && git diff ${base}`, `DIFF_BASE=${base}; git diff "$DIFF_BASE"`,
`git diff "$DIFF_BASE"`, `DIFF_BASE=${base} && git diff "$DIFF_BASE" && git status --short`, `git diff ${base.toUpperCase()}`]) {
expect(allowed(command), command).toBe(false);
}
expect(qaCallerCommandAllowed(`git diff ${base}`, fixture.workflowCommands)).toBe(false);
for (const caseId of ['ship-exploratory-unavailable', 'review-exploratory-small-cli'] as QaCallerCase[]) {
expect(qaCallerSessionOptions(fixtureFor(caseId), 'free-review-plan').prompt).toContain(
'The merge base itself, the sha git merge-base origin/main HEAD prints (at least 7 characters), may replace origin/main as a literal diff base');
}
});
});
+15 -10
View File
@@ -5,7 +5,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
const ROOT = path.resolve(import.meta.dir, '..');
test.each(['success', 'bash-edit', 'max-turns', 'no-edit', 'no-commit', 'runner', 'retry', 'directory', 'recording', 'late-timeout'])
test.each(['success', 'bash-edit', 'max-turns', 'no-edit', 'no-commit', 'runner', 'retry', 'directory', 'recording', 'late-timeout', 'slow-setup'])
('QA attempt recording and fixture ownership: %s', scenario => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qa-fix-body-'));
const script = path.join(dir, 'body.fixture.test.ts');
@@ -21,7 +21,10 @@ const scenario = ${JSON.stringify(scenario)};
const attempts = [], records = [], servers = [];
const late = scenario === 'late-timeout';
const workMs = 1000, drainMs = 80;
let finalized = false, outerMs;
const realNow = Date.now, slowSetup = scenario === 'slow-setup';
let finalized = false, outerMs, frozenAt, clock = realNow();
if (late) Date.now = () => frozenAt ?? realNow();
else if (slowSetup) Date.now = () => clock;
const collector = { addTest: entry => {
records.push({ ...entry, afterFinalization: finalized });
if (scenario === 'recording') throw new Error('QA recorder failure');
@@ -36,7 +39,7 @@ Bun.serve = options => {
return server;
};
mock.module(path.join(root, 'test/helpers/aside-available.ts'), () => ({ asideAvailable: () => false }));
if (late) mock.module(path.join(root, 'test/helpers/eval-budgets.ts'), () => ({
if (late || slowSetup) mock.module(path.join(root, 'test/helpers/eval-budgets.ts'), () => ({
JUDGE_MS: 120_000, CAPTURE_MS: 300_000, CAPTURE_LONG_MS: workMs,
}));
mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
@@ -46,13 +49,13 @@ mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
testConcurrentIfSelected: (id, body, budget) => {
if (id !== 'qa-fix-loop') return;
outerMs = budget;
// Keep the actual native outer deadline, scaling only its 5s recording
// margin to80ms. The pre-fix equal work/outer deadline stays equal.
const nativeBudget = late ? (budget >= workMs + 5000 ? budget - 5000 + 80 : budget) : 5000;
test(id, body, nativeBudget);
// The late attempt's clock stands still through its real setup, so the
// mocked work budget reaches the capture whole; the deadline is the
// registered outer budget.
test(id, late ? () => { frozenAt = realNow(); return body(); } : body, late ? budget : 5000);
},
copyDirSync: (source, target) => fs.cpSync(source, target, { recursive: true }),
setupBrowseShims: cwd => fs.mkdirSync(path.join(cwd, 'browse/bin'), { recursive: true }),
setupBrowseShims: cwd => { fs.mkdirSync(path.join(cwd, 'browse/bin'), { recursive: true }); if (slowSetup) clock += 400; },
logCost: () => {}, createEvalCollector: () => collector, finalizeEvalCollector: async () => {},
recordE2E: (_collector, name, suite, result, extra) => collector.addTest({
name, suite, exit_reason: result.exitReason, model: result.model,
@@ -62,6 +65,7 @@ mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({
SESSION_DRAIN_GRACE_MS: late ? drainMs : 5000,
runSkillTest: async options => {
frozenAt = undefined;
const cwd = options.workingDirectory;
const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
const initial = fs.readFileSync(path.join(cwd, 'index.html'), 'utf8');
@@ -102,6 +106,7 @@ afterAll(async () => {
// behind child exit; this reproduces the observed late recording race.
if (late) await new Promise(resolve => setTimeout(resolve, 200));
fs.writeFileSync(${JSON.stringify(factsPath)}, JSON.stringify({ attempts, records, servers, outerMs }));
Date.now = realNow;
});
await import(path.join(root, 'test/skill-e2e-qa-workflow.test.ts'));
`);
@@ -152,11 +157,11 @@ await import(path.join(root, 'test/skill-e2e-qa-workflow.test.ts'));
}
if (scenario === 'late-timeout') {
expect(outerMs).toBe(1000 + 80 + 5000);
expect(attempts[0].captureBudgetMs).toBeGreaterThan(0);
expect(attempts[0].captureBudgetMs).toBeLessThan(1000);
expect(attempts[0].captureBudgetMs).toBe(1000);
expect(records[0].exit_reason).toBe('timeout');
expect(child.stderr).not.toContain('Unhandled error between tests');
}
if (scenario === 'slow-setup') expect(attempts[0].captureBudgetMs).toBe(1000 - 400);
if (scenario === 'directory') {
expect(attempts[0].directoryStatus).toBe(404);
expect(attempts[0].missingStatus).toBe(404);
@@ -17,6 +17,7 @@ import {
import { CAPTURE_LONG_MS } from './helpers/eval-budgets';
import capturedChecker from './fixtures/shared-libs-index-flags-r59-checker-public.json';
import loopRead from './fixtures/shared-libs-index-flags-loop-read-39173752116.json';
import erroredRead from './fixtures/shared-libs-path-ignored-errored-read-40617200857.json';
const helper = path.join(SHARED_LIBS_ROOT, 'bin/gstack-review-log');
const fixtures: SharedLibsFixture[] = [];
@@ -96,7 +97,8 @@ async function capture(change: string, prepared?: PathEligibilityFixture, declar
directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'),
branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f),
startedAt: receipt.review_start.started_at, finding: current, reusable: change === 'unchanged',
coveredPaths: receipt.snapshot.covered_paths };
coveredPaths: receipt.snapshot.covered_paths,
...(prepared ? { sources: Object.fromEntries(current!.evidence_paths.map((file: string) => [file, fs.readFileSync(path.join(f.repo, file), 'utf8')])) } : {}) };
return { f, current, token, receipt, events, checkAt, finishAt, expected, prepared, resumed, prerequisites, ...(declared ? { questions } : {}) };
}
@@ -687,3 +689,37 @@ describe('literal loop reads from the captured skip-worktree review', () => {
expect(hasTrustedSharedLibsCheck(events, loopRead.expected)).toBe(false);
});
});
// Gate 40617200857 slice 8: the ignored-path review returned all of src/retry-worker.ts through
// cat -n, then a later `echo ===` in the same call failed, so the result carried is_error.
describe('errored Bash reads from the captured ignored-path review', () => {
const blocks = (events: any[]) => events.flatMap(event => event.message.content);
const read = (events: any[]) => blocks(events).find((block: any) => block.type === 'tool_use'
&& block.input?.command?.startsWith('cat -n src/retry-worker.ts; echo ===;'));
const output = (events: any[], id: string) => blocks(events).find((block: any) => block.type === 'tool_result' && block.tool_use_id === id);
test('a whole evidence source returned before a later failure counts as reading it', () => {
const events = structuredClone(erroredRead.events);
expect(output(events, read(events).id).is_error).toBe(true);
expect(hasTrustedSharedLibsCheck(events, erroredRead.expected)).toBe(true);
});
test.each(['no expected sources', 'empty expected source', 'last source line missing', 'missing file', 'errored single read', 'errored native Read', 'errored checker'])('%s is still rejected', shape => {
const events = structuredClone(erroredRead.events), call = read(events), result = output(events, call.id);
const expected: any = structuredClone(erroredRead.expected);
if (shape === 'no expected sources') delete expected.sources;
if (shape === 'empty expected source') expected.sources['src/retry-worker.ts'] = '';
if (shape === 'last source line missing') result.content = result.content.replace(/\n +15\t\}\n/, '\n');
if (shape === 'missing file') result.content = 'Exit code 1\ncat: src/retry-worker.ts: No such file or directory';
if (shape === 'errored single read') call.input.command = 'cat -n src/retry-worker.ts';
if (shape === 'errored native Read') {
call.name = 'Read';
call.input = { file_path: 'src/retry-worker.ts' };
}
if (shape === 'errored checker') {
const checker = blocks(events).find((block: any) => block.type === 'tool_use' && block.input?.command?.includes('--check-shared-libs'));
output(events, checker.id).is_error = true;
}
expect(hasTrustedSharedLibsCheck(events, expected)).toBe(false);
});
});
@@ -0,0 +1,41 @@
/**
* Train gate 40619718717 (798a37d8) slice 14, shared-libs-review-index-flags, assume-unchanged trial: while
* gathering evidence the actor ran `git update-index --really-refresh`, which cleared the assume-unchanged flag
* the checker reads, so the prerequisite binding went stale. The skill's evidence step now says it is read-only.
*/
import { afterAll, expect, test } from 'bun:test';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { generateSharedCodeReuse } from '../scripts/resolvers/review-scope';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
import { fixtureGit, type SharedLibsFixture } from './helpers/shared-libs-eval-fixture';
import { checkPathReviewPrerequisites, preparePathEligibilityFixture } from './helpers/shared-libs-path-fixture';
const fixtures: SharedLibsFixture[] = [];
afterAll(() => { for (const f of fixtures) fs.rmSync(f.root, { recursive: true, force: true }); });
test.each((['review', 'ship'] as const).flatMap(skill => (Object.keys(HOST_PATHS) as TemplateContext['host'][]).map(host => [skill, host] as const)))('%s on %s: the shared-code evidence step forbids the index refresh the captured actor ran', (skillName, host) => {
const section = generateSharedCodeReuse({ skillName, tmplPath: '', host, paths: HOST_PATHS[host] });
const step = section.slice(section.indexOf('1. **Read the evidence.**'), section.indexOf('2. **Run the checker.**')).replace(/\s+/g, ' ');
expect(step).toContain('read-only: `git update-index`, even `--really-refresh`, clears the index flags the checker reads');
});
test.each([
['captured refresh', 'git update-index --really-refresh >/dev/null 2>&1; echo "(no index mutation above intended)"', false],
['captured read-only inspection', 'git ls-files -v; git status --porcelain=v1; git hash-object --no-filters -- src/retry-route.ts; git ls-files --debug src/retry-route.ts', true],
] as const)('%s against the assume-unchanged fixture: the prerequisite detector sees it (%s)', (_, command, unchanged) => {
const prepared = preparePathEligibilityFixture('assume-unchanged');
const f = prepared.fixture;
fixtures.push(f);
const before = checkPathReviewPrerequisites(f, prepared.resumed.input);
expect(before.settled).toBe(true);
expect(fixtureGit(f, 'ls-files', '-v', 'src/retry-route.ts')).toMatch(/^h /);
execFileSync('bash', ['-c', command], { cwd: f.repo, env: { ...process.env, ...f.env }, encoding: 'utf8', timeout: 30_000 });
const after = checkPathReviewPrerequisites(f, prepared.resumed.input);
if (unchanged) expect(after).toEqual(before);
else {
expect(fixtureGit(f, 'ls-files', '-v', 'src/retry-route.ts')).toMatch(/^H /);
expect({ current: after.current, settled: after.settled }).toEqual({ current: false, settled: false });
}
});
+9
View File
@@ -59,6 +59,15 @@ function publishMain(fx: TrainFixture, prefixCommit: string, version: string, ex
return repo.git('rev-parse', 'HEAD');
}
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named
test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => {
const fx = make();
fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(fx.repo.origin, 'objects', 'maintenance.lock'));
const coord = coordinatorClone(fx.repo);
expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main'));
expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false);
});
describe('the train command surface', () => {
test('train with no subcommand (flags only) prints the train help and exits usage; --help exits 0; bad arguments name what is missing', () => {
const fx = make();
@@ -16,8 +16,8 @@
* template on demand (the compact block points to it).
* 3. Write a fake `gbrain` shell script into workdir/bin/ with robust
* argv quoting (printf %q) so heredoc payloads in --content survive
* shell-to-shell. The fake logs every invocation + writes payloads
* to a per-slug file for inspection.
* shell-to-shell. The fake logs every invocation + writes the --content
* or stdin page (the template's form) to a per-slug file for inspection.
* 4. Run /office-hours via runSkillTest with workdir/bin/ first on PATH.
* Feed a deterministic founder pitch + auto-decide instructions.
* 5. Assert the argv log contains `gbrain put office-hours/<slug>`, the
@@ -180,16 +180,21 @@ case "$1" in
get_page) echo "Unknown command: get_page" >&2; exit 1 ;;
put)
SLUG="$2"
PAYLOAD="${payloadDir}/$SLUG.md"
shift 2
while [ -n "$1" ]; do
if [ "$1" = "--content" ]; then
PAYLOAD_DIR="${payloadDir}"
mkdir -p "$PAYLOAD_DIR/$(dirname "$SLUG")"
printf '%s' "$2" > "$PAYLOAD_DIR/$SLUG.md"
break
mkdir -p "$(dirname "$PAYLOAD")"
printf '%s' "$2" > "$PAYLOAD"
exit 0
fi
shift
done
# The SAVE_RESULTS template pipes the page: gbrain put "<slug>" < "<page-file>".
if [ -p /dev/stdin ] || [ -f /dev/stdin ]; then
mkdir -p "$(dirname "$PAYLOAD")"
cat > "$PAYLOAD"
fi
exit 0
;;
esac
@@ -241,7 +246,7 @@ Read pitch.md — that's a founder pitch coming to office hours. Select Startup
For the diagnostic, assume the founder confirmed Q1 (strongest evidence = "230 from a single tweet + 51 paying creators in 6 weeks"), Q2 (status quo = "creators write ad-hoc checks or use opaque Patreon-style platforms"), and Q3 (forcing question already asked).
Generate the design doc per Phase 5. The feature-slug value to substitute into the SAVE_RESULTS template's \`<feature-slug>\` placeholder is exactly 'pixel-fund' (no path prefix — the template already provides the prefix). The \`gbrain\` binary is on PATH at ${workDir}/bin/gbrain. Apply the SAVE_RESULTS template literally: the slug should land at \`<prefix>/pixel-fund\` per the resolver shape, with the actual design doc markdown body in the --content payload. Then enrich entity stubs for any named people or companies mentioned in the pitch.
Generate the design doc per Phase 5. The feature-slug value to substitute into the SAVE_RESULTS template's \`<feature-slug>\` placeholder is exactly 'pixel-fund' (no path prefix — the template already provides the prefix). The \`gbrain\` binary is on PATH at ${workDir}/bin/gbrain. Apply the SAVE_RESULTS template literally: the slug should land at \`<prefix>/pixel-fund\` per the resolver shape, with the actual design doc markdown body as the page content. Then enrich entity stubs for any named people or companies mentioned in the pitch.
This is a test of the brain-writeback path. Do NOT skip the gbrain save step under any circumstance — the runtime guard ("skip if gbrain not on PATH") does NOT apply here because gbrain IS available. Do NOT explore gbrain --help; follow the SAVE_RESULTS template's exact CLI shape. If you encounter any AskUserQuestion, auto-decide recommended.`,
workingDirectory: workDir,
@@ -302,7 +307,7 @@ This is a test of the brain-writeback path. Do NOT skip the gbrain save step und
throw new Error(
`Agent called gbrain put but no payload file with 'pixel-fund' ` +
`in name was written to ${payloadDir}. Check the fake gbrain ` +
`--content parser for argv quoting issues.`,
`--content and stdin page capture.`,
);
}
const payload = readFileSync(payloadPath, 'utf-8');
+5 -2
View File
@@ -42,12 +42,15 @@ const designFocusBoundary = (fp: AskUserQuestionFingerprint): boolean =>
// on one line before Project/branch/task:), with the step's own rating in the
// body and an option that offers all seven dimensions against a narrower one.
// The option leads with all seven, whatever it calls them ("All 7 areas", run 39363705692).
// Run 40664336141 put the offer in the rated title instead ("Should I review all 7 areas
// or focus on a few?") and lettered its options ("A) Review all 7 areas"). Either place
// carries the offer; issue-numbered options are a finding, never Step 0D.
const lead = text.split(/\r?\n|\s+(?=Project\/branch\/task:)/, 1)[0]!;
return /^I(?:['’]ve| have) rated this plan (?:10(?:\.0+)?|[0-9](?:\.\d+)?)\/10 on design completeness\.[\s\S]*\bWant me to focus on specific areas instead(?: of all (?:7|seven))?\?/i.test(text)
|| (/\ball (?:7|seven) (?:design )?(?:dimensions|passes)\b/i.test(title) && /\bfocus\b/i.test(title)
&& (/\bdesign\b/i.test(title) || rated(text.slice(title.length))) && /\?\s*$/.test(title))
|| (/\bfocus\b/i.test(lead) && rated(text) && options.length >= 2
&& options.some(({ label }) => /^\s*all (?:7|seven)\s+[a-z]/i.test(label)));
|| (/\bfocus\b/i.test(lead) && rated(text) && options.length >= 2 && !issueLabeled(options)
&& (/\ball (?:7|seven)\s+[a-z]/i.test(lead) || options.some(({ label }) => /^\s*all (?:7|seven)\s+[a-z]/i.test(label))));
});
// Require a choice about the supplied UI, not a workflow offer after focus.
+10
View File
@@ -1,6 +1,7 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { JUDGE_MS, CAPTURE_MS, CAPTURE_LONG_MS, PTY_MS } from './helpers/eval-budgets';
import { runSkillTest } from './helpers/session-runner';
import { seedHermeticGstackHome } from './helpers/hermetic-env';
import { resolveEvalModel } from '../lib/eval-model';
import { EvalCollector } from './helpers/eval-store';
import { OFFICE_HOURS_BUN_GRACE_MS, runRecordedOfficeHoursAttempt } from './helpers/office-hours-attempt';
@@ -380,12 +381,19 @@ Focus on architecture, code quality, tests, and performance sections.`,
describeIfSelected('Plan Review Report E2E', ['plan-review-report'], () => {
test('/plan-eng-review writes GSTACK REVIEW REPORT to plan file', async () => {
let planDir: string | undefined;
let stateDir: string | undefined;
try {
await runRecordedOfficeHoursAttempt({
collector: evalCollector, name: '/plan-review-report', suite: 'Plan Review Report E2E',
model: resolveEvalModel('capture'), budgetMs: CAPTURE_LONG_MS,
run: async signal => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-review-report-'));
// This case measures the report write within 20 turns. The default-on outside
// voice (timer, Agent, TaskStop) took 4 of the 21 turns in both reds of gate
// 40660626349, so the case uses the skill's documented off switch.
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-review-report-state-'));
seedHermeticGstackHome(stateDir);
fs.appendFileSync(path.join(stateDir, 'config.yaml'), 'codex_reviews: disabled\n');
const run = (cmd: string, args: string[]) => {
const result = spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
if (result.error || result.status !== 0) {
@@ -445,6 +453,7 @@ plan.md is the plan file for this review session; save your review there.`,
maxTurns: 20,
timeout: CAPTURE_LONG_MS,
testName: 'plan-review-report',
env: { GSTACK_HOME: stateDir },
runId, signal,
model: resolveEvalModel('capture'),
});
@@ -496,6 +505,7 @@ plan.md is the plan file for this review session; save your review there.`,
} finally {
// Each configured retry owns a pristine plan and finishes cleanup first.
if (planDir) try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
if (stateDir) try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {}
}
}, CAPTURE_LONG_MS + OFFICE_HOURS_BUN_GRACE_MS);
});
+1
View File
@@ -125,6 +125,7 @@ async function exerciseEligibility(testId: string, kinds: PathEligibilityCase[])
directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'),
branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f),
startedAt: last.review_binding.started_at, finding: prepared.current, reusable: false,
sources: Object.fromEntries(sourceBefore),
coveredPaths: skipped.find((finding: any) => finding.fingerprint === prepared.current.fingerprint)?.snapshot_covered_paths,
})).toBe(true);
} else {
+12 -1
View File
@@ -391,7 +391,6 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
'cmd=${*: -1}\n' +
'printf \'%s\\n\' "$cmd" >> "$root/ssh.log"\n' +
'case "$cmd" in\n' +
' *-O*) exit 0 ;;\n' +
' *pkill*) echo "$cmd" >> "$root/cancel.log" ;;\n' +
' *captured*) cat > "$root/captured" ;;\n' +
' *"cat > ~/.ubirun-env"*) cat > "$root/ubirun-env" ;;\n' +
@@ -458,6 +457,18 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('run: the fake ssh dispatches on the remote command, so a checkout whose name holds -O still syncs and runs (GSTA-139)', () => {
const f = fakeVm();
try {
const checkout = join(f.root, 'ubi-run-O1x2y3');
mkdirSync(checkout);
const r = run(f, ['run', '--src', checkout, '--', 'make test']);
expect(r.exitCode, r.stderr.toString()).toBe(0);
expect(r.stdout.toString()).toContain('ran: ');
expect(f.ssh()).toContain('tar -xzf - -C work/ubi-run-O1x2y3');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('run: --pass forwards a named local variable only; UBICLOUD_* and non-names are refused before any API call', () => {
const f = fakeVm();
try {