Files
gstack/CLAUDE.md
T
b1485d8897 v1.74.0.0 test/CI overhaul: green means green, suites restructured for speed (#2721)
* fix(ci): free-tests lane actually runs the make-pdf e2e gates

The 9 make-pdf/test/e2e gate tests probe make-pdf/dist/pdf,
browse/dist/browse, and the diagram-render bundle, then self-skip when
absent. The required free-tests lane never built any of them, so the
gates silently skipped on Linux for their entire life (verified: 9 of
14 skip, exit 0). make-pdf-gate.yml's justification for deleting its
Linux leg claimed the free lane covered this — it didn't.

- new build:gates script: exactly the three artifacts the gates probe
  (full bun run build compiles five binaries; ~60-90s tax on the only
  required check is not warranted)
- free-tests.yml: build:gates step + poppler-utils +
  fonts-noto-color-emoji (fonts must precede the first browse daemon
  launch — Chromium snapshots fontconfig at startup; verified live:
  a warm daemon renders tofu, a fresh one embeds NotoColorEmoji)
- make-pdf/test/e2e/ci-prereqs.test.ts: GSTACK_EXPECT_BINARIES=1 (set
  by the workflow) inverts the skip polarity in CI — dropping the
  build step or poppler fails the lane instead of re-opening the
  silent-skip hole

Pre-flight: all 9 gates green on Linux locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): kill the three zero-test eval jobs (hollow green)

- delete the vestigial e2e-codex / e2e-gemini matrix rows: both files
  are whole-file periodic-tier, so with no row tier: they ran ZERO
  tests and reported green on every PR (~2 min of runner each, pure
  false confidence; the periodic lane owns those suites)
- e2e-pty-plan-smoke gains tier: gate — its two files are whole-file
  describeE2ETier('gate'), so the job burned ~7 min of container setup
  then skipped every describe
- KNOWN_TIER_UNSET burned down to empty; the ratchet stays armed so a
  future row/file tier mismatch fails the suite instead of shipping
  hollow green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): least-privilege permissions + fork-safe concurrency keys

- evals.yml / evals-periodic.yml evals jobs: explicit contents:read +
  packages:read (container-image pull) and persist-credentials:false —
  the jobs that execute PR-authored code with three provider API keys
  ran on the repo-default token grant with the token written into
  .git/config
- permissions blocks for the 4 workflows that had none (skill-docs,
  make-pdf-gate, windows-free-tests, windows-setup-e2e)
- fork-safe concurrency keys: actionlint, skill-docs, make-pdf-gate,
  windows-setup-e2e switch from head_ref to PR-number keying — a bare
  branch name carries no fork prefix, so same-name branches from two
  forks shared one group and cancelled each other's runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): one bun version everywhere + drift tripwire

Lanes disagreed four ways: 1.3.13 (free-tests, windows, Dockerfile.ci),
latest (quality-gate, make-pdf-gate), unpinned (skill-docs,
version-gate — setup-bun installs latest), 1.3.10 (.gitlab-ci.yml).
Different Bun versions change the runner output shapes the strict
classifiers regex-match, spawn semantics, and shell parsing — a lane on
a different Bun tests a different product; Dockerfile.ci's own comment
records this class biting once already (silent 1.3.13/1.3.14 drift).

All surfaces pinned to 1.3.13; test/bun-version-drift.test.ts scans
every workflow setup-bun stanza + Dockerfile.ci + .gitlab-ci.yml and
fails on any mismatch or unpinned stanza. skill-docs also gains
--frozen-lockfile (was bare bun install).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ci): bind the three-way image-tag hashFiles() expressions

evals.yml, evals-periodic.yml, and ci-image.yml each compute the CI
image tag from hashFiles('.github/docker/Dockerfile.ci', 'bun.lock',
'patches/**') — synced by comment only (TODOS.md 'CI three-way
image-tag drift'). If one input list drifts, that workflow computes a
different tag for the same content: eval lanes silently rebuild the
image every run, or ci-image prebuilds a tag nobody looks up. The test
extracts each tag-computation site and fails on any mismatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): ci-image stops rebuilding the identical image every ship

- package.json out of the trigger paths: the tag hash deliberately
  excludes it (version bumps every ship), so every merge rebuilt and
  re-pushed the IDENTICAL tag (~2m26s for zero content change);
  patches/** added (it IS a tag input)
- manifest existence check (mirrors evals.yml): tag already exists →
  skip the build
- concurrency group: two rapid main pushes raced pushing the same
  :latest/:buildcache tags
- cron staggered 06:00→04:00 Monday: it shared the exact minute with
  evals-periodic, which could race a half-pushed tag or duplicate the
  build
- timeout-minutes: 30 (was unbounded → 360-min default for a hung
  docker build)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): quality-gate drops the 74s full-history checkout

fetch-depth:0 cost 74 of the job's 92 seconds; the three gates it feeds
take ~12s combined. Shallow checkout + exact-SHA fetches for the diff's
base/head (an exact-SHA fetch, not a guessed depth — long-lived
branches and merge queues still resolve), with a --deepen fallback for
push events whose 'before' is unusable. timeout right-sized 20→10 min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): small-lane batch — timeouts, right-sizing, windows cache warm-start

- timeout-minutes on the 6 remaining unbounded jobs (actionlint 5,
  skill-docs 10, version-gate 10, make-pdf-gate 15, pr-title-sync 5,
  evals build-image 15) — a hung step sat on GitHub's 360-min default
- right-size measured-over-long timeouts: dependency-review 10→5,
  windows-setup-e2e 15→10
- dependency-review: 2-core runner (28s API call on an 8-core box) and
  drop .github/workflows/** from its trigger paths (workflow edits have
  no dependencies to review)
- windows caches gain restore-keys: a lockfile bump paid the 26s/43s
  restore for a guaranteed cold miss

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): scope GSTACK_HOME to each file's execution window

Five files assigned process.env.GSTACK_HOME at module scope. Shard
processes evaluate sibling modules before running their tests, so the
assignment leaked into every other file in the shard — the damage was
already visible in defensive workarounds (relink.test.ts:28 'fresh
install test saw a neighbor's skill_prefix'; cdp-e2e's own comment
documents a sibling's temp dir baked into artifacts).

Pattern: save original, assign in beforeAll, restore in afterAll
(cdp-e2e already restored but still assigned at load — its window now
matches the others). GSTACK_TELEMETRY_OFF and GSTACK_PROJECT_SLUG get
the same treatment where they rode along. Victim files' defenses stay
in place (cheap insurance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: tripwire against module-scope GSTACK_HOME assignments

Column-0 assignment of GSTACK_HOME / GSTACK_STATE_ROOT in any tracked
*.test.ts fails with the file:line and the fix (beforeAll + afterAll
restore). Kills the cross-file env-leak class the previous commit
swept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): e2e-harness-audit derives its skill census from disk

The hand-maintained 39-name SKILL_GLOBS list had drifted to 39 of 54
SKILL.md.tmpl on disk. No live gap today (none of the 15 unlisted
skills is interactive), but the next interactive skill would have
landed unguarded with zero signal. The audit now walks top-level dirs
for SKILL.md.tmpl (statSync so symlinked dirs like connect-chrome
count), so new skills are in scope the commit they appear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): judges honor the eval-model resolution chain + real 429 backoff

callJudge inlined GSTACK_EVAL_MODEL_JUDGE || sonnet, silently ignoring
the global GSTACK_EVAL_MODEL override every other eval call site honors
via lib/eval-model.ts. New 'judge' kind in DEFAULTS (sonnet — the D1a
pin-on-regressors calibration stands; model CHOICE unchanged) and
callJudge resolves through it: explicit arg > GSTACK_EVAL_MODEL_JUDGE >
GSTACK_EVAL_MODEL > default.

429 handling upgraded from one fixed 1s retry (reliably lost races at
CI concurrency) to three jittered exponential retries (~1s/4s/16s),
honoring the server's retry-after when present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): the two expect(true) paid stubs become test.todo

skill-e2e-spec-execute (600s budget) and skill-llm-eval-spec (300s)
reported PASS on every periodic run while asserting nothing. Deleting
them would remove the periodic-tier selector surface they exist to
register (diff-based selection for spec/ changes), so they become
test.todo — reported as todo/skip, never pass — with the v1.1
implementation specs kept in-file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): reactivate 5 quarantined browse tests (2 security)

extension-sender-auth's two privileged-message denial tests (content
script + missing sender.url — the extension's security boundary) and
snapshot's three skips were quarantined 'pre-existing' failures. Root
cause: machine-local state on the quarantining dev machines — the test
and gate code are byte-identical between the quarantining commit
(410b4928) and HEAD, and all five pass deterministically on a clean
checkout (68/68 across both files, multiple runs). No assertions
weakened, no product changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): activate the 4 paid test files that could never run anywhere

carve-section-loading, codex-e2e-plan-format,
codex-e2e-recommendation-substance, and llm-judge-recommendation gated
on EVALS/tier (free suite loads them as describe.skip) but their names
fell outside PAID_TEST_GLOBS, so no paid lane ever selected them — net
execution zero, forever. The existing matrix tripwire filtered on
isPaidTestFile() first, so it was blind to exactly this class (the same
bug that hid the pre-split monolith's gate tests for ~8 releases).

- PAID_TEST_GLOBS: codex-e2e* + skill-llm-eval* wildcards (replacing
  exact names) + llm-judge-recommendation + carve-section-loading;
  package.json's six test-script glob lists mirrored
- codex-e2e-plan-format gains the explicit periodic tier gate its
  siblings carry (external-service rule) — without it the sharded
  runner's no-guard default would spawn Codex in the gate tier per PR
- eval:bg:periodic --timeout 32400→37800: the census growth pushed the
  periodic worst case to 35910s; the old value had 270s of headroom
  BEFORE this change and would now kill healthy runs mid-flight
- new test/paid-orphan-tripwire.test.ts: any EVALS/tier-gated test file
  outside the globs fails the free suite (reasoned SCANNER_EXEMPT for
  the gate helpers + meta-tests) — the class-killer
- paid-shards pins updated: the four orphans now assert INSIDE the
  census

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): restrictDirectoryPermissions warns and skips symlinked dirs

Closes the Windows Free Tests red: recent lane failures showed a
platform-unguarded POSIX mode-bit assertion ('Expected: 493' — a
symlink-skip test) from PR-branch variants; the KNOWN_WINDOWS_SAFE
force-include reason ('mode-bitmask hits are POSIX-branch only') did
not hold for that shape, and main had neither the guard nor the
behavior.

- product: lstat first; a symlinked dir gets a warning and a skip on
  both platforms — chmod AND icacls dereference the link, so
  restricting through a symlink hardens an unvetted target (and
  /inheritance:r could lock out its real owner). All callers already
  treat hardening as best-effort (try/catch).
- test: the symlink regression test, platform-aware — symlinkSync in
  the house try/catch skip pattern (Windows runners without Developer
  Mode can't create symlinks), mode-bit assertion guarded off win32,
  behavior assertions (no throw, warning text, target readable)
  everywhere; POSIX still proves the skip (0o755 unchanged, not 0o700)
- KNOWN_WINDOWS_SAFE reason updated to the now-true premise

20/20 pass on Linux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): unique tmp dirs for plan artifacts + audited live-repo cwd sites

Six paid PTY tests wrote their expected plan artifact to a FIXED shared
/tmp path ('/tmp/gstack-test-plan-<mode>.md') and rmSync'd it in
finally — under --retry 1, EVALS_JOBS>1, or two concurrent worktrees, a
sibling's cleanup deletes this run's artifact and the D19 'agent did
not produce expected plan file' assertion fires spuriously. Each test
now mkdtemps its own dir, interpolates the unique path into the agent
prompt (fixture-sourced prompts get a replaceAll + drift guard that
throws if the fixture's literal ever moves), and cleans up its own dir.

The 18 cwd:-into-the-live-repo sites were audited: all deliberate
(skill registry + hermetic pre-trusted dir, in-repo gen renders, git
history reads, slug resolution) — each now carries a
'// LIVE-REPO CWD: <reason>' comment so the next audit can tell
deliberate from accidental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): trim the seven over-wall 1700s timeouts to the 1500s physical ceiling

1,700,000ms (28.3 min) exceeded every wall these tests run inside: the
25-min CI job timeout and the 1800s sharded-runner wall (which also
leaves --retry 1 zero room for a second attempt). Budget above the wall
is fiction, not headroom — a test that actually used it produced a
job-level kill (no bun summary, no artifact) instead of a clean
per-test timeout. No recorded p95 exists for this family (they are
being retiered to periodic in the re-platform wave); the trim stops at
the physical ceiling rather than guessing lower. Final policy lands in
the Wave-2 eval-budgets constants module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(gen): main() guard — importing gen-skill-docs no longer regenerates the tree

The generator's whole body executed at module load, so any import of it
(test/gen-skill-docs.test.ts pulls assertSinglePreamble via require();
test/catalog-trim.test.ts imports helpers) regenerated all 71 SKILL.md
in place — the root cause of half the TREE_MUTATING serial-shard
entries (hazard class #2532). The body now lives in an exported
main(): number behind if (import.meta.main).

Semantics preserved exactly: failure exits are immediate (matching the
old top-level process.exit), success leaves the event loop to drain so
the llms.txt fire-and-forget IIFE finishes its write, and the module
stays synchronous/require()-able. Proofs: byte-identical --host all
output (git status clean), --dry-run stale-tree still exits 1 (the
skill-docs freshness lane depends on it), and the new
test/gen-skill-docs-import-purity.test.ts pins load-time purity via a
subprocess probe (mtime-based, so a dirty worktree can't false-fail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(gen): --out-dir renders every host, outputs-only

--out-dir was Claude-host-only (gen-skill-docs.ts:842), which forced
the codex/factory-regenerating tests (gen-skill-docs, skill-validation,
host-config) to mutate the live tree — the reason they sit in the
TREE_MUTATING serial shard. The flag now mirrors ALL outputs into the
out-dir: external-host trees (.agents/.factory/... via
processExternalHost), external section files, openclaw docs, and
gstack/llms.txt (a catalog-mode render must never rewrite the tracked
index). OUTPUTS ONLY — inputs (templates, sections/, host configs) are
always read from ROOT, so an empty out-dir can never feed the render.
rewriteSectionBase stays Claude-only (external hosts have their own
path grammar).

Proofs: in-place --host all is byte-identical (tree clean);
--host all --out-dir <mkdtemp> renders the full multi-host tree with
ROOT untouched; gen-skill-docs-out-dir tests + 415/415
gen-skill-docs.test.ts green (bin/dev-setup's claude rendering
byte-compat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): every E2E key's dep list names its own declaring test file

129-of-177 keys omitted their own test file, so editing only a test's
prompt or assertions selected NOTHING — the changed test never ran on
the change that changed it. 135 keys self-registered (110 E2E + 25
LLM-judge), resolved by strict declaration evidence (testName:/
testIfSelected/judge call sites), with skill-name false positives
excluded.

e2e-tier-alignment's warn-only branch for unregistered files is now a
hard failure with a 4-entry KNOWN_UNREGISTERED ratchet (template-
literal testNames, fail-open-safe) + a burn-down test so the set only
shrinks. Selection sanity: a one-file diff on skill-e2e-qa-workflow now
selects its 4 tests (was 0); skill-llm-eval 0 → 25.

Known follow-ups (filed): 15 E2E + 2 judge PHANTOM keys select tests
that exist nowhere; codex-e2e-plan-format's testIfSelected names have
no map keys (run-all only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(evals): ratchet the 8 newly-visible gate-matrix gaps

The self-registration sweep made these eight files' gate-tier keys
visible to the census for the first time — their gate tests run in NO
CI lane today (pre-existing hole, newly measurable). Ratcheted into
KNOWN_MATRIX_GAPS with the burn-down note: the paid-lane re-platform
runs every gate file by construction and retires this ratchet class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): duration-aware LPT shard packing for the free suite

Hash sharding balances file COUNTS (1.15x spread) but not cost — the
Playwright-launching files landed 4/3/4/1/2/1 across 6 shards, giving a
measured 28s–97s shard spread and ~40s of idle tail on every run.
Full-suite mode now packs by recorded per-file durations
(longest-processing-time-first) when the committed seed
scripts/free-test-durations.json exists.

- ONE store, no overlay: the seed is refreshed occasionally via the new
  --record-durations mode (each file timed in its own child — exact,
  and immune to bun's stream buffering, where silent passers print no
  header to timestamp); GSTACK_FREE_TEST_DURATIONS overrides the path
  for experiments; CI never records
- seed is a hint: missing → silent hash-shard fallback; corrupt (bad
  merge) → one warning + fallback; unknown files → 75th-percentile
  pessimism so a surprise long-runner can't recreate the tail
- packed shards get duration-aware walls (max(base, predicted x 3)) —
  LPT decouples count from cost BY DESIGN, so the 5s/file heuristic
  would undersize a shard holding few expensive files
- one log line per shard (files + predicted seconds) so packing
  regressions are diagnosable from any run log
- the --shard CI-matrix path is untouched: stable hash indices are its
  contract
- successor note in-code: bun >=1.3.14 ships native --timings/--shard
  LPT — swap this packer when the repo unpins 1.3.13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): decouple slop:diff from bun run test; quality-gate runs it per PR

'bun run test' silently appended up to two 120s npx slop-scan runs plus
a git worktree add/remove after the suite (2>/dev/null || true) —
invisible in the documented '~90-100s' timing and pure friction in the
pre-commit loop. Decoupling is not coverage removal: quality-gate.yml
now runs slop:diff on every PR (advisory, matching its in-repo 'never
blocking' contract), and /review already invokes it explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): eval-budgets timeout tiers + fit/ceiling policy test

Five named tiers (JUDGE 120s / CAPTURE 300s / CAPTURE_LONG 600s /
PTY 900s / PTY_LONG 1200s) replace hand-ratcheted sprawl (46x300s,
46x120s, 44x360s, 44x180s, 27x240s, 19x150s, 13x420s, 12x600s...),
much of it inflated to paper over the old 40-way in-shard concurrency
that the sharded runner's 1-file-per-shard model kills. Policy test
pins: every tier fits the shard wall minus 120s overhead (the
structural fix for budgets-above-the-wall fiction), tiers stay ordered,
and no paid literal exceeds PTY_LONG x1.25 — oversized tests get split,
not budgeted past the wall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): shared runBin helper for bin-script unit tests

~36 free test files each carry a near-identical local run() (spawnSync
+ utf-8 + {status, stdout, stderr}) differing only in env composition,
cwd, and timeout. runBin absorbs the invariant core; options carry the
variance (gstackHome sets BOTH GSTACK_HOME and GSTACK_STATE_DIR — the
config-precedence trap several locals rediscovered independently; home
for $HOME-anchored bins; input/trim/timeout/maxBuffer). Free-test-only
by design so it never becomes a de facto global touchfile. Migration of
the 36 call sites lands separately (mechanical batches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): runBin trim assertion — trim shapes stream ends, not interior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): mechanical sweep — 298 paid-test timeouts onto eval-budget tiers

69 files, both shapes (trailing bun-test budgets and runner
timeout/timeoutMs options), ROUND-UP ONLY so nothing that passed can
start failing: 75 → JUDGE_MS, 137 → CAPTURE_MS, 74 → CAPTURE_LONG_MS,
9 → PTY_MS, 3 → PTY_LONG_MS. Raw >=60s literal count in the paid scope:
395 → 97, of which 51 are non-timeout noise (fixture dates, run IDs)
and 46 are enumerated justified holds (comment-carrying calibrated
budgets, poll-loop constants, utility spawn waits, and the seven
physical-ceiling 1_500_000 sites). The eval-budgets policy ratchet
keeps the residue from regrowing.

Known collapse: where an inner runner budget and its enclosing test
budget now share a tier, the old stagger is gone — an overrun surfaces
as a bun test timeout instead of a graceful runner timeout
(diagnosability trade, not a correctness one).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage fill — 95 tests for six zero-coverage surfaces

- eval CLI family (eval-list/compare/summary + eval-select smoke): the
  primary interface to eval results had no tests; isolation via a fake
  gstack-slug under a mkdtemp HOME (the scripts' real resolution path —
  they do NOT honor GSTACK_EVAL_DIR; only EvalCollector does). Pinned
  current behavior: eval-list does NOT exclude _partial runs (documented
  improvement candidate)
- slop-diff (runs on every /review + quality-gate): fixture git repo +
  first-on-PATH npx stub (never downloads real slop-scan); no-diff
  early exit, missing-scanner fallback, fingerprint line-insensitivity,
  merge-base worktree scan
- bin/gstack-code-intelligence CLI arg surface (lib was covered, the
  284-line CLI wasn't): select/consent/suggest/index/search gating;
  pinned: --help routes to usage failure exit 1 (no handler)
- browse media-extract: the page.evaluate callback exercised in-process
  against a mock DOM (no exports added) — lazy-src fallback chain,
  HLS/DASH detection, bg-image url() parsing, 500-element cap
- browse session-cookie-store: factory contract (cookieName/ttlMs/
  maxSessions eviction, cross-store isolation, mint→validate
  round-trip); store is in-memory — no fs cases exist
- lib/version-source direct unit tests (gstack-version-bump.test.ts
  spawns the bin, never imports the lib): parse/format/cmp/bump
  coercion, npm 4→3 translation, #2501 mangled-JSON regression class

All hermetic (mkdtemp homes, runBin child isolation); windows curation
correctly partitions the six.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): first runBin migration batch (3 of ~36 run() duplicates)

explain-level-config, benchmark-cli, evidence move onto the shared
helper; each file's remaining special-case spawnSync sites (raw-buffer
probes, env-scrub probes) stay put deliberately. 55/55 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(evals): paid shards spool to disk + shared runShardChild lifecycle

- runPaidShard no longer buffers whole 30-min stream-json streams in
  RAM (x concurrent jobs): every byte tees to a per-shard log file
  (slug-named, path printed at START for mid-run inspection and on the
  FAILED terminal line); failures print a 64KiB tail read back from
  disk; passing shards stay quiet (the file is the record) — the free
  runner's proven contract. Classification unchanged: the strict
  classifier still sees every byte first.
- the ~35 duplicated spawn/group-kill/wall-timer/finally-reap lines
  move into runShardChild in test-strict-output.ts (detached-per-
  platform spawn, signal forwarding, SIGKILL group kill at the wall,
  drain-before-verdict); designed so the free runner can migrate later
- expectedFiles drift fixed toward ENFORCEMENT: the injected-command
  exemption is gone — a fake command exiting 0 without bun's terminal
  summary now reads FAILED (pinned: silent-pass → failed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(evals): parent-computed selection propagates to shard children

The sharded runner computed diff selection once, then each of its 48-73
children recomputed it at module load — including, on touchfiles-diff
branches, a per-child bun subprocess evaluating the old data file (20s
timeout each). The parent now serializes {version, selected, reason} as
EVALS_SELECTION_JSON into the shard env; e2e-helpers adopts it at load.
Fail-open preserved: any parse/shape violation → ONE stderr warning +
local recompute; absent env → silent local compute (non-sharded
entrypoints unchanged). Drift test pins parent→child round-trip to
identical selection decisions plus the malformed/absent cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): kill the four worst fixed sleeps (300s/30s/30s/20s)

- watchdog.test: the 20s blind wait for one production parent-watchdog
  tick becomes BROWSE_PARENT_WATCHDOG_INTERVAL_MS=250 (new env knob in
  server.ts, NaN-safe, production default unchanged) + polls for the
  boot line and the tick's stay-alive log — strictly stronger (the old
  form never proved a tick observed the parent death). 24s → 3.6s.
- stop-dead-daemon / terminal-agent-owner-watchdog: the 300s/30s
  stand-in child lifetimes become stdin-EOF-bound — the child can never
  self-exit mid-test on a slow runner (spurious-failure class) and
  self-reaps instantly if the test dies (no 300s orphans). Node-compat
  stdin APIs (owner-watchdog runs on the Windows lane).
- browser-skill-commands: the sleeper fixture's 30s self-time becomes
  8s (no stdin pipe exists in runToFiles) — far above the 1s product
  timeout it must outlive, below the test ceiling, so a timeout-kill
  regression fails on clean assertions instead of an opaque bun
  timeout; added: stdout must NOT contain 'done'.

45/45 green across the four files + server tripwires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gen-skill-docs + catalog-trim leave the serial mutator shard

gen-skill-docs.test.ts's 15 in-place generator spawns now render into
mkdtemp out-dirs (gitignored-artifact reads repointed; the handshake
scan's silent console.warn degrade became a hard assertion); its
tracked-tree reads (freshness dry-run, SKILL.md content pins) stay
reads. catalog-trim needed no change beyond the earlier main() guard —
its import is now side-effect-free (pinned by the import-purity test).
Both TREE_MUTATING entries deleted in this commit, per the transition
rule: an entry leaves in the same commit as the file's last in-place
write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): skill-validation renders codex host into an out-dir

Its 3 in-place --host codex regeneration sites collapse into one
module-level --out-dir render; assertions untouched. TREE_MUTATING
entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): host-config self-provisions goldens (ordering dependency severed)

Its goldens were 'produced by gen-skill-docs.test.ts' with a
when-missing beforeAll fallback that wrote the live tree — an
inter-test ordering dependency the serial shard hid. It now renders
codex+factory UNCONDITIONALLY into its own out-dir and reads goldens
only from there (the Claude golden deliberately keeps reading tracked
ship/SKILL.md — a read; out-dir claude renders repoint section-base
paths by design). TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gbrain-detection-override drops mutate-then-git-restore

regenAndSnapshot renders --host claude --out-dir <mkdtemp> (+
--respect-detection) and snapshots probes from the out-dir. The
git-restore machinery is deleted outright — it restored only
PROBE_FILES of the 71 files each call wrote, so a stale tree kept the
other 68 dirty (the partial-restore bug), and its 'no output-path arg'
comment had been false since --out-dir landed. TREE_MUTATING entry
deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): catalog-mode-full renders to out-dir; restore machinery deleted

The full-catalog smoke no longer rewrites all 71 SKILL.md then
regenerates to restore (with its 'CRITICAL: failed to restore' prayer
path) — it renders into a mkdtemp and additionally asserts tracked
ship/SKILL.md is byte-unchanged. TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): idempotency proof strengthens to two-out-dir recursive diff

Two renders into two separate out-dirs, EVERY file diffed byte-for-byte
(claude-only and --host all; normalization only for each dir's own
sanctioned section-base repoint; presence-sanity lists guard against a
vacuous empty-dir pass) — strictly stronger than the old in-place
double-regen that sampled 5 files. TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): spec-template-sync compares an out-dir render, not an in-place one

TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): the serial tree-mutating shard dissolves — TREE_MUTATING is empty

Zero mutators remain (all eight render into out-dirs now), so the four
ratchet READERS (parity caps, size budgets, carve parity/ordering) get
a quiet tree by construction in any shard and rejoin the parallel
phase. The ~35-40s serial tail on every full-suite run is gone. The
mechanism stays: a future test that genuinely must write shared
artifacts in place earns an entry with a reason and is serialized
again; the census pin still fails on renamed keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gen): out-dir byte-identity + tree-clean pins for external hosts

codex render: porcelain unchanged AND out-dir gstack-ship/SKILL.md
byte-identical to a fresh in-place render (+openai.yaml presence);
--host all render: exit 0, porcelain unchanged, claude + .agents +
.factory + llms.txt + openclaw docs all present in the out-dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): commit the initial free-test durations seed (496 files)

Recorded via --record-durations on a quiescent tree: 479s serial
total, p50 92ms / p90 1.8s / max 31.4s — the top-heavy cost shape LPT
packing exists for. A hint, not a contract: refresh opportunistically
with bun run test:free --record-durations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(evals): planner/executor/report modes — the CI re-platform surface

One PLANNER computes diff selection + the slice plan ONCE and writes a
manifest (--emit-plan <path> --slices K); K executors consume it
(--plan <path> --slice i), never self-selecting, and write slice-result
artifacts; a REPORT reconciles results against the manifest (--report
<dir>) fail-closed: a slice whose artifact never landed is a FAILURE,
a planned shard nobody reported fails, wrong-slice/duplicate/cross-tier
results fail. Kills per-slice selector divergence and hollow-lane
aggregation at the root.

- hollow-shard guard: under EVALS_ALL, exit 0 with ZERO executed tests
  (bun's 'Ran N tests' now captured by the classifier — additive) is
  'passed-empty' and fails the run; selective runs keep it 'passed'
  with one warning (in-file diff/tier self-skips are legitimate there);
  unknown counts are never guessed hollow
- retry parity: --retry 1 default + RETRY_OVERRIDES literals for the
  three files whose old matrix rows earned retries: 2 (stale entries
  pinned against disk)
- live smoke: gate plan = 48 shards across 6 slices; report mode exits
  1 on a fabricated missing slice, 0 when complete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): sliced paid lane (planner -> 6 executors -> fail-closed report)

The parity-phase re-platform: evals.yml gains a second, sliced lane
driven by scripts/test-paid-shards.ts — the SAME engine local
eval:bg:gate uses, so CI and local share one selection engine.

- plan-slices: ONE planner (fetch-depth 0 — the only job needing
  history) emits the manifest; selection fails open to run-all, never
  per-slice (the divergence class is structurally dead)
- eval-slices: 6-way matrix consuming the manifest; PTY seed +
  skill-registration steps run unconditionally (idempotent — a sliced
  lane cannot key them on suite names); aggregate spawn budget
  6 x EVALS_JOBS=2 x EVALS_CONCURRENCY=2 = 24 lane-wide (the matrix's
  40-way per row queued session startup behind 39 siblings — the
  timeout-flake family root); slice results + spooled shard logs
  uploaded as artifacts
- slices-report: reconciles slice artifacts against the manifest
  FAIL-CLOSED via --report — a slice whose artifact never landed, or a
  planned shard nobody reported, is a failure, not an absence
- sequenced needs: evals so provider concurrency never doubles while
  both lanes coexist; the matrix + its ratchets are deleted after
  demonstrated parity (intersection + expected-additions comparison)
- workflow_dispatch gains evals_all (default true) for parity runs and
  post-merge smokes — a dispatch can never silently select zero

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): weekly periodic lane runs EVERY periodic test + gate census backstop

evals-periodic.yml re-platforms onto the sharded runner: planner
manifest → 6 executor slices → FAIL-CLOSED report. This IS the coverage
contract: all ~70 periodic-tier files weekly (EVALS_ALL=1), killing the
silent-rot class where a hard-coded 9-file matrix left ~57 files
running NOWHERE (the autoplan E2E rotted invisibly for months).

- test/helpers/periodic-exclude-data.ts: reasoned exclusions in their
  OWN literals file (deliberately not touchfiles-data — map-diff
  evaluates old versions of that file standalone). Every entry carries
  reason + tracking with a re-entry condition; the runner surfaces each
  exclusion per run; policy test pins real-file + non-empty fields.
  Initial: ship-idempotency + brain-privacy-gate (documented-red,
  never green) and skill-e2e-ios (manual hardware). The TODOS 'sidebar
  E2E trio' turned out already deleted — only tombstone tests remain.
- gate-census job: weekly EVALS_ALL gate-tier run — PR lanes are
  diff-billed, so without this the full gate census might never execute
  anywhere; with the hollow-shard guard it is a census-health check
  (exit 0 + zero executed tests fails), not just a test run.
- failure notification is a concrete gh issue UPSERT (one tracking
  issue, commented per red week — never issue-per-week spam), with
  issues:write scoped to the report job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: TESTING_INTERNALS covers the 2026-08 runner overhaul

LPT-packed free suite + --record-durations, the emptied TREE_MUTATING
mechanism, the sharded paid runner as the single selection engine,
CI planner/executor/report with the fail-closed report and hollow-shard
guard, the weekly coverage contract + exclusions policy, and the
eval-budgets timeout tiers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(CLAUDE.md): testing prose matches the overhauled runners

- bun run test: duration-packed shards + --record-durations; the
  trailing serial tree-mutating shard no longer exists
- two-tier system: the sliced CI lanes (one engine local+CI), the
  weekly all-periodic coverage contract + exclusions, the gate census
- periodic detach timeout 32400 → 37800

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): close the absorbed test-infra items, file the overhaul follow-ups

Closed with receipts: the periodic coverage contract (implemented as
full weekly coverage + exclusions), the eval-harness observability P1
(verified already landed: heartbeat, incremental _partial persistence,
live stderr + eval-watch), and the sidebar trio (already deleted —
tombstones remain). Filed: matrix deletion after parity, the
required-check maintainer decision, browse /tmp-namespace hardening,
PTY boot-readiness waits, the single typed test registry, bun-native
LPT swap, runBin/free-runner migrations, eval-list partial exclusion,
phantom key cleanup, duration-weighted slicing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.73.0.0: test/CI overhaul — green means green, suites restructured for speed

Version + release notes for the audit-and-overhaul branch: every
silently-skipping or never-running test class fixed and tripwired, the
free suite duration-packed with the serial mutator shard dissolved, the
paid lane re-platformed onto the sharded runner (planner/slices/
fail-closed report, parity phase), the weekly all-periodic coverage
contract, eval-budget timeout tiers, and 95 new coverage tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): first-live-run fixes — executor history + two environment-blind assertions

The sliced lane's first run (PR #2721) did its job: the planner and
report worked, the manifest governed, and every failure had a name.
Three were fixable on the spot:

- executor + gate-census checkouts get fetch-depth: 0 — files with
  SELF-derived selection (the LLM-judge map, routing) walk git at
  module load, and selection is deliberately fail-closed on git errors,
  so the shallow checkout crashed those shards ('ambiguous argument
  main...HEAD'). The manifest still governs WHICH shards run.
- landscape --toc gate: the exact toBe(3) landscape-page count was
  font-metric-dependent (3 on Amazon Linux, 2 on ubuntu CI — the same
  disease the file's own page-index comment warns about). Now a
  comparative invariant: --toc must not CHANGE the landscape count vs
  a baseline render.
- paid-run-manifest parse test builds its manifest under EVALS_ALL so
  it never walks git (proven with GIT_DIR=/nonexistent).

Remaining first-run failures are newly-exposed rot in gate files that
had never executed in CI (skillify D1 refusal, session-intelligence
context-restore, one tpa-apple-ban retry flake) — being probed
separately; they are the lane WORKING, not the lane failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): file the three first-execution findings from the sliced lane's live run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.74.0.0: queue-advance — #2722 claims the v1.73.0.0 slot

The version gate caught a live queue collision (its whole job); same
MINOR bump level, next free slot per bin/gstack-next-version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): per-shard CHROMIUM_PROFILE — the collision class duration packing exposed

Nine test files launch in-process persistent contexts or daemons that
default to the SHARED ~/.gstack/chromium-profile. Two concurrent shard
processes on one profile dir kill each other's browser — observed live
on CI once duration packing recomposed shards: handoff's
launchPersistentContext died 'Target page, context or browser has been
closed' (--user-data-dir=~/.gstack/chromium-profile in the call log)
while a sibling shard's daemon logged 'Chromium process crashed'. Hash
sharding had masked the collision by chance placement; handoff passes
standalone everywhere.

Fix at the runner, not per file: each shard child gets
CHROMIUM_PROFILE=<shard-state>/chromium-profile (the documented env
knob, same isolation idea as the existing per-shard TMPDIR). Files
within a shard run serially, so sharing the per-shard profile is safe;
config.test's resolution-order tests save/restore the env around their
assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): landscape --toc gate asserts promotion PRESENCE, not counts

Two rounds of CI receipts: the exact toBe(3) was font-metric-coupled
(3 on Amazon Linux, 2 on ubuntu), and the baseline-comparison repair
then failed 2-vs-3 across renders SECONDS apart in one CI job while the
sibling no-toc test saw 3 — per-render image-promotion timing makes any
count assertion here a coin flip. The sibling test owns exact promotion
counts; this test's actual invariant is that --toc does not break the
promotion machinery: >=1 landscape page + the TOC rendered. Also drops
the second render (halves the test's runtime).

Flaky per-render image promotion itself is worth its own look — noted
in TODOS with these receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): file the per-render image-promotion nondeterminism (receipts from PR #2721)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): per-FILE Chromium profiles for the nine in-process launcher files

Completes the profile-isolation work: the per-shard CHROMIUM_PROFILE
stopped cross-shard kills; these nine files launch in-process
persistent contexts and could still collide with a lingering daemon a
sibling file spawned on the SAME shard profile. Each now scopes a
mkdtemp profile via beforeAll/afterAll (the module-scope-tripwire-safe
pattern), cleaned up per file. All nine green solo and in combined
runs, except the pre-existing commands+snapshot pairing — proven
identical WITH and WITHOUT these edits (baseline receipts) — which is
the daemon-lifecycle follow-up now extended in TODOS with this
session's receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): Chromium-crash exit is daemon-only — embedded launches never kill their host

handleChromiumDisconnect unconditionally process.exit()ed. Correct for
the standalone daemon (its supervisor/user must notice); suicidal when
a TEST launches BrowserManager in-process: a mid-suite Chromium death
exited the whole bun shard with no terminal summary — the exact
truncation class the strict runner flags (observed live: CI shard 1 on
eb233299 died at cache-concurrent-refresh right after a daemon-spawning
gate test; with this fix the same pairing runs to completion and
REPORTS instead of dying).

The standalone entrypoint opts in via markDaemonProcess() under
server.ts's import.meta.main gate — the same embedder contract its
signal handlers already use (gbrowser phoenix keeps its own handlers).
Embedded contexts now get the disconnect log line and continue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): context-restore assertion is evidence-based, not prose-matching

The test failed twice per run in TWO CI cycles while passing locally
4/4: the prompt said 'present the content' and the check grepped the
FINAL message for exact phrases — local runs quoted the file, CI runs
paraphrased ('the most recent context is from branch-b...') and the
substring check lost the coin flip.

- prompt now demands machine-checkable output: the newest file's
  '## Working on:' heading VERBATIM + a literal 'RESTORED: <filename>'
  marker (the mtime-scramble and cross-branch subject matter untouched)
- assertion ordered strongest-first: RESTORED marker → legacy content
  phrases → tool-call corroboration (Read/Bash input naming the newer
  file, credited ONLY when the older file was never read — a
  both-files run must still present the right one)
- the older-file negative got STRONGER: an explicit RESTORED marker
  naming the older file fails even if wintermute words appear elsewhere
- sibling scan: context-recovery-artifacts got the additive prompt-side
  treatment only (quote the matched literals verbatim); its lenient
  1-of-6 assertion deliberately unchanged

3/3 consecutive local green with all evidence classes firing
(marker=true, content=true, toolNewer=true, toolOlder=false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): skillify family — HOME==cwd broke project-skill registration

Root cause (forensically pinned from stream-json init events + a
kill-after-init probe): with HOME set EQUAL to the child's cwd, claude
resolves <cwd>/.claude/skills as the PERSONAL skills directory and the
seeded project-tier skills never register — the Skill tool returned
'Unknown skill'. The provenance-refusal test then improvised a refusal
whose wording missed the regex (the deterministic CI+local red); the
happy-path and approval-reject siblings passed only because their
agents self-recovered by Reading SKILL.md manually — silently not
exercising the Skill-tool path at all.

All three tests now use HOME=<workDir>/home (a fresh subdir keeps the
override's intent: child ~/.gstack writes land in the assertable
sandbox, without the cwd collision). Refusal test additionally: a
'not registered/unknown skill' tripwire (a not-loaded skill can never
pass as a refusal) and the refusal regex now matches assistant text
only — the skill BODY echoed into the transcript contains the exact
refusal message, so the old full-surface match could pass vacuously
once the skill loaded. Sibling disk assertions sweep both $HOME/.gstack
and cwd .gstack roots (positives and negatives).

Verified paid: refusal 2x consecutive green with the skill's EXACT
message rendered ('Launching skill: skillify' in-transcript), then the
full file 5/5 green (~$1.35) with both siblings driving real Skill
calls (25-27 turns each).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): two of three first-execution findings fixed (skillify family, context-restore)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): context-restore gets a private home — the REAL root cause was fixture sharing

The evidence-based assertion fix was treating a symptom. The slice
artifact's embedded transcript showed the CI agent restoring
20260829-context-save-skill-test.md — the checkpoint the SIBLING
context-save test wrote into the SHARED gstackHome checkpoints dir,
which by filename-prefix ordering genuinely IS the newest. The agent
behaved CORRECTLY; the test's fixture set was open to concurrent
sibling writes, and bun --concurrent ordering differs between CI (save
finished first) and local (restore listed first) — the entire
local-green/CI-red split explained.

The restore test now uses its own .gstack-restore-home (the whole home
moves, not just the handed path — an agent deriving the dir from
GSTACK_HOME/projects/<slug> must land in the closed set too). Full file
4/4 paid green with all evidence flags firing.

Also: the on-failure shard-log artifact glob uploaded nothing — the
Fix-bun-temp step points TMPDIR at /home/runner/.cache, so the spool
lands there, not /tmp. Both eval workflows now glob both locations
(this gap is why diagnosing THIS failure required digging transcripts
out of the slice-results artifact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evidence): carry the real index mtime onto gstack-wtree's temp copy

The stat-cache seed (cp of the real index) stamped the temp index "now",
which defeats git's racy-git protection: an entry is only re-hashed when
its cached mtime is not older than the index file itself, so a same-size
rewrite landing in the same second as the last real index write looked
non-racy, kept its stale stat-cache entry, and vanished from the
fingerprint — evidence stayed FRESH after a source change. This is the
CI flake in test/evidence.test.ts "allow-paths carve-out" (sub-second
alignment on fast runners: expected STALE exit 1, got FRESH exit 0).

touch -r restores the original index timestamp, reinstating the exact
racy window git itself uses. Deterministic regression pin in
test/review-log.test.ts reproduces the miss with pinned zero-nsec
timestamps (fails on the old script, passes now); receipts: manual
probe shows the fresh-stamped copy returning the clean tree for a
same-size 'hello'→'howdy' rewrite while the mtime-carried copy detects
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): landscape gate bounds the promotion count instead of pinning 3

The alt-hinted image promotion rides the per-render measurement race
already filed in TODOS (2-vs-3 landscape pages on renders seconds
apart — CI receipts from PR #2721, now reproduced locally). Pin the
two deterministic promotions as the floor and the three promotable
blocks as the ceiling (anything above 3 means the veto leaked); the
veto/portrait assertions remain exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Test <test@test.com>
2026-08-29 09:06:54 -07:00

800 lines
44 KiB
Markdown

# gstack development
## Commands
```bash
bun install # install dependencies
bun run test # run free tests via the strict parallel runner (~90-100s full suite)
bun run test:evals # run paid evals: LLM judge + E2E (diff-based, ~$4.35/run max)
bun run test:evals:all # run ALL paid evals regardless of diff
bun run test:gate # run gate-tier tests only (CI default, blocks merge)
bun run test:periodic # run periodic-tier tests only (weekly cron / manual)
bun run test:gate:sharded # gate tier via the sharded paid runner (one Bun process per test file)
bun run test:periodic:sharded # periodic tier via the sharded paid runner (implies EVALS_ALL=1)
bun run test:e2e # run E2E tests only (diff-based, ~$4.20/run max)
bun run test:e2e:all # run ALL E2E tests regardless of diff
bun run eval:select # show which tests would run based on current diff
bun run dev <cmd> # run CLI in dev mode, e.g. bun run dev goto https://example.com
bun run build # gen docs + compile binaries
bun run gen:skill-docs # regenerate SKILL.md files from templates
bun run skill:check # health dashboard for all skills
bun run dev:skill # watch mode: auto-regen + validate on change
bun run eval:list # list all eval runs from ~/.gstack/projects/<slug>/evals/
bun run eval:compare # compare two eval runs (auto-picks most recent)
bun run eval:summary # aggregate stats across all eval runs
bun run slop # full slop-scan report (all files)
bun run slop:diff # slop findings in files changed on this branch only
```
`test:evals` requires `ANTHROPIC_API_KEY`. Codex E2E tests (`test/codex-e2e.test.ts`,
`test/codex-e2e-sol-scope.test.ts`) use Codex's own auth — the hermetic runner copies
only `auth.json` from `${CODEX_HOME:-~/.codex}` and pins `CODEX_HOME` in the child
env — no `OPENAI_API_KEY` env var needed.
**Hermetic E2E + env keys:** every E2E runner spawns children through
`test/helpers/hermetic-env.ts` (allowlist-scrubbed env, fresh seeded
`CLAUDE_CONFIG_DIR`, temp `GSTACK_HOME`, `--strict-mcp-config`); per-test
`env:` overrides merge last onto a COMPLETE hermetic env, so they're safe.
A PTY test that types a `/skill` command must pass `seedSkills: true`.
Debug against real operator state with `EVALS_HERMETIC=0`. Full detail
(env-shim, seeding tripwires, wiring tests):
[docs/TESTING_INTERNALS.md](docs/TESTING_INTERNALS.md).
**Diff-based test selection:** `test:evals` and `test:e2e` auto-select tests based
on `git diff` against the base branch. Each test declares its file dependencies in
`test/helpers/touchfiles.ts`. Changes to global touchfiles (session-runner, eval-store,
touchfiles.ts itself) trigger all tests. Use `EVALS_ALL=1` or the `:all` script
variants to force all tests. Run `eval:select` to preview which tests would run.
**Two-tier system:** Tests are classified as `gate` or `periodic` in `E2E_TIERS`
(in `test/helpers/touchfiles.ts` — a facade over `touchfiles-data.ts` +
`test-selection.ts`). CI runs gate tests per PR via evals.yml's sliced lane
(planner manifest → executors → fail-closed report; engine =
scripts/test-paid-shards.ts, the same runner as local eval:bg:gate); the free
suite runs on every PR via `.github/workflows/free-tests.yml` (a REQUIRED
check, secretless — fork PRs get real signal); ALL periodic tests run weekly
via evals-periodic.yml (EVALS_ALL, minus the reasoned exclusions in
`test/helpers/periodic-exclude-data.ts` — reason + tracking required per
entry), plus a weekly EVALS_ALL gate census. Use `EVALS_TIER=gate` or
`EVALS_TIER=periodic` to filter locally. When adding new E2E tests, classify them:
1. Safety guardrail or deterministic functional test? -> `gate`
2. Quality benchmark, Opus model test, or non-deterministic? -> `periodic`
3. Requires external service (Codex, Gemini)? -> `periodic`
Tier declarations are enforced by `test/e2e-tier-alignment.test.ts` (free, runs
in `bun test`): a `skill-e2e-*` file named in a touchfiles dep list whose
`EVALS_TIER` self-gate disagrees with its declared tier in `E2E_TIERS` fails the
suite. Files not named in any dep list are reported, not enforced — keep both
in sync.
## Testing
```bash
bun run test # run before every commit — free, ~90-100s for the full ~7,000-test suite
bun run test:evals # run before shipping — paid, diff-based (~$4.35/run max)
```
`bun run test` routes through `scripts/test-free-shards.ts` (N concurrent
shard processes, serial within each, packed by recorded per-file durations
when `scripts/free-test-durations.json` exists — refresh occasionally with
`bun run test:free --record-durations`; strict-output classification per
shard: a shard without bun's terminal summary line FAILS — silent truncation
cannot report green). The former trailing serial tree-mutating shard is
gone: `TREE_MUTATING` is empty (gen-skill-docs has a main() guard and
`--out-dir` renders every host, so tests render into mkdtemps — see
docs/TESTING_INTERNALS.md). Never type bare `bun test` for the suite: it
walks the whole repo, loading paid eval files and missing the strict
classifier.
It covers skill validation, gen-skill-docs quality checks, and browse
integration tests. `bun run test:evals` runs LLM-judge quality evals and E2E
tests via `claude -p`. Both must pass before creating a PR.
## Project structure
Full annotated tree: [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md).
Quick map: `browse/` headless-browser CLI, `design/` design binary,
`hosts/` typed host configs, `scripts/` build+DX tooling (gen-skill-docs,
resolvers), `test/` validation+evals, `lib/` shared libraries, `bin/` CLI
utilities, `extension/` Chrome extension, one directory per skill
(`ship/`, `review/`, `qa/`, ...), `.github/` CI, `contrib/` contributor
tools, `docs/designs/` design documents.
## SKILL.md workflow
SKILL.md files are **generated** from `.tmpl` templates. To update docs:
1. Edit the `.tmpl` file (e.g. `SKILL.md.tmpl` or `browse/SKILL.md.tmpl`)
2. Run `bun run gen:skill-docs` (or `bun run build` which does it automatically)
3. Commit both the `.tmpl` and generated `.md` files
Generation uses each host's `defaultModel` (`claude` for existing hosts, `gpt`
for Codex) unless `--model` is explicit. Codex installs additionally read the
top-level model from `${CODEX_HOME:-~/.codex}/config.toml`; rerun
`./setup --host codex` after changing that model. Note: `bun run build` and a
bare `gen:skill-docs --host codex` render the host default (gpt) — if your
Codex config.toml pins a different model, rerun `./setup --host codex`
afterwards to restore your profile (single-owner persistence is filed in
TODOS.md).
To add a new browse command: add it to `browse/src/commands.ts` and rebuild.
To add a snapshot flag: add it to `SNAPSHOT_FLAGS` in `browse/src/snapshot.ts` and rebuild.
**Token ceiling:** Generated SKILL.md files trip a warning above 160KB (~40K tokens).
This is a "watch for feature bloat" guardrail, not a hard gate. Modern flagship
models have 200K-1M context windows, so 40K is 4-20% of window, and prompt caching
makes the marginal cost of larger skills small. The ceiling exists to catch runaway
preamble/resolver growth, not to force compression on carefully-tuned big skills
(`ship`, `plan-ceo-review`, `office-hours` legitimately pack 25-35K tokens of
behavior). If you blow past 40K, the right fix is usually: (1) look at WHAT grew,
(2) if one resolver added 10K+ in a single PR, question whether it belongs inline
or as a reference doc, (3) only compress carefully-tuned prose as a last resort —
cuts to the coverage audit, review army, or voice directive have real quality cost.
A second, harder ceiling guards the DISCOVERY surface: `test/catalog-budget.test.ts`
caps the aggregate frontmatter `name` + `description` across all skills at 1,150
token-equivalents (260-byte per-skill sub-cap), counted through the shared census
in `test/helpers/skill-census.ts`. This one is enforced, not a warning — every
host loads the full catalog every session, so growth here taxes every
conversation. The failure message carries the re-measure + ratchet protocol.
`bin/gstack-context-bill` shows the full token bill-of-materials for a skills
tree (always-on vs per-invocation, `--diff`, `--budget`; `--exact` opts into the
real tokenizer and POSTs file text to api.anthropic.com with an egress receipt).
The context-budget ratchet (`test/context-budget-ratchet.test.ts`, free, runs
in `bun run test`) pins ABSOLUTE ceilings on two more ledgers: the always-on
FULL-frontmatter aggregate (catalog-budget counts only name+description) and
each skill's per-invocation eager tokens (SKILL.md + forced-read references —
size floors and parity ratios guard these relatively, not absolutely), graded
against `test/fixtures/context-budget.json`. A skill that grows past its
ceiling fails; a new skill fails until it's consciously budgeted. For
legitimate growth or a landed reduction, re-run
`bun test/helpers/capture-context-budget.ts` and commit the refreshed fixture
in the same commit, so ceilings ratchet down and every win is locked.
**Merge conflicts on SKILL.md files:** NEVER resolve conflicts on generated SKILL.md
files by accepting either side. Instead: (1) resolve conflicts on the `.tmpl` templates
and `scripts/gen-skill-docs.ts` (the sources of truth), (2) run `bun run gen:skill-docs`
to regenerate all SKILL.md files, (3) stage the regenerated files. Accepting one side's
generated output silently drops the other side's template changes.
## Platform-agnostic design
Skills must NEVER hardcode framework-specific commands, file patterns, or directory
structures. Instead:
1. **Read CLAUDE.md** for project-specific config (test commands, eval commands, etc.)
2. **If missing, AskUserQuestion** — let the user tell you or let gstack search the repo
3. **Persist the answer to CLAUDE.md** so we never have to ask again
This applies to test commands, eval commands, deploy commands, and any other
project-specific behavior. The project owns its config; gstack reads it.
## Writing SKILL templates
SKILL.md.tmpl files are **prompt templates read by Claude**, not bash scripts.
Each bash code block runs in a separate shell — variables do not persist between blocks.
Rules:
- **Use natural language for logic and state.** Don't use shell variables to pass
state between code blocks. Instead, tell Claude what to remember and reference
it in prose (e.g., "the base branch detected in Step 0").
- **Don't hardcode branch names.** Detect `main`/`master`/etc dynamically via
`gh pr view` or `gh repo view`. Use `{{BASE_BRANCH_DETECT}}` for PR-targeting
skills. Use "the base branch" in prose, `<base>` in code block placeholders.
- **Keep bash blocks self-contained.** Each code block should work independently.
If a block needs context from a previous step, restate it in the prose above.
- **Express conditionals as English.** Instead of nested `if/elif/else` in bash,
write numbered decision steps: "1. If X, do Y. 2. Otherwise, do Z."
## Writing style (V1)
Default output from every tier-≥2 skill follows the Writing Style section in
`scripts/resolvers/preamble.ts`: jargon glossed on first use (curated list in
`scripts/jargon-list.json`, baked at gen-skill-docs time), questions framed in
outcome terms ("what breaks for your users if...") not implementation terms,
short sentences, decisions close with user impact. Power users who want the
tighter V0 prose set `gstack-config set explain_level terse` (binary switch,
no middle mode). See `docs/designs/PLAN_TUNING_V1.md` for the full design
rationale. The review pacing overhaul that originally tried to ride alongside
writing-style was extracted to V1.1 — see `docs/designs/PACING_UPDATES_V0.md`.
## Browser interaction
When you need to interact with a browser (QA, dogfooding, cookie setup), use the
`/browse` skill or run the browse binary directly via `$B <command>`. NEVER use
`mcp__claude-in-chrome__*` tools — they are slow, unreliable, and not what this
project uses.
**Server / sidebar / extension internals:** before editing `browse/src/server.ts`,
`extension/`, the sidebar PTY, any SSE endpoint, or CDP session code, read
[docs/BROWSER_INTERNALS.md](docs/BROWSER_INTERNALS.md) — sidebar message flow,
WebSocket auth, tunnel dual-listener rules, Unicode sanitization at egress,
SSE/CDP helpers, setup symlink hardening, and the sidebar security stack all
live there, each pinned by a CI tripwire.
**Egress receipts at every off-machine sink** (v1.63.0.0+). Every gstack-initiated
send off the machine MUST write a hash-chained receipt to
`~/.gstack/security/egress.jsonl` BEFORE the send: TypeScript callers use
`writeReceipt` from `lib/egress-receipt.ts`; shell scripts source
`bin/gstack-egress-lib.sh` and use `_receipted_curl` / `_receipted_git`. Failure
polarity is per-class: fail-closed for sensitive sinks (brain-sync, memory-ingest,
gbrain-sync, telemetry, ngrok tunnels, mcp-verify, supabase-provision), fail-open
+ stderr warning for user-facing ones (design OpenAI calls, update-check,
dashboards, git-class ops). The new-sink scanner in
`test/egress-receipt-wiring.test.ts` fails CI on an unreceipted `curl` /
`git push` / `fetch` to a non-loopback host unless the file carries a reasoned
entry in its `SCANNER_EXEMPT` list (user-directed page fetches, reachability
probes, instruction strings, skill prose) — if you add a new off-machine sink,
wire it through the helpers and add it to the enumerated sink list. Inspect with
`bin/gstack-egress` (`list` | `verify`, exit 3 on tamper | `grants`). Threat
model: forensic observability of ATTEMPTED egress, not an exfiltration control.
## Dev symlink awareness
When developing gstack, `.claude/skills/gstack` may be a symlink back to this
working directory (gitignored). This means skill changes are **live immediately**,
great for rapid iteration, risky during big refactors where half-written skills
could break other Claude Code sessions using gstack concurrently.
**Check once per session:** Run `ls -la .claude/skills/gstack` to see if it's a
symlink or a real copy. If it's a symlink to your working directory, be aware that:
- Template changes + `bun run gen:skill-docs` immediately affect all gstack invocations
- Breaking changes to SKILL.md.tmpl files can break concurrent gstack sessions
- During large refactors, remove the symlink (`rm .claude/skills/gstack`) so the
global install at `~/.claude/skills/gstack/` is used instead
**Prefix setting:** Setup creates real directories (not symlinks) at the top level
with a SKILL.md symlink inside (e.g., `qa/SKILL.md -> gstack/qa/SKILL.md`), plus
links to each skill's runtime assets (sections/, templates, checklists — everything
except SKILL.md, tests, build output, and `.tmpl` sources). Alias skills
(`_gstack-command`, `connect-chrome`) install as rewritten copies, never symlinks.
This ensures Claude discovers them as top-level skills, not nested under `gstack/`.
Names are either short (`qa`) or namespaced (`gstack-qa`), controlled by
`skill_prefix` in `~/.gstack/config.yaml`. Pass `--no-prefix` or `--prefix` to
skip the interactive prompt.
**Note:** Vendoring gstack into a project's repo is deprecated. Use global install
+ `./setup --team` instead. See README.md for team mode instructions.
**For plan reviews:** When reviewing plans that modify skill templates or the
gen-skill-docs pipeline, consider whether the changes should be tested in isolation
before going live (especially if the user is actively using gstack in other windows).
**Upgrade migrations:** When a change modifies on-disk state (directory structure,
config format, stale files) in ways that could break existing user installs, add a
migration script to `gstack-upgrade/migrations/`. Read CONTRIBUTING.md's "Upgrade
migrations" section for the format and testing requirements. The upgrade skill runs
these automatically after `./setup` during `/gstack-upgrade`.
## Compiled binaries — never commit browse/dist/, design/dist/, or make-pdf/dist/
The `browse/dist/`, `design/dist/`, and `make-pdf/dist/` directories contain
compiled Bun binaries (`browse`, `find-browse`, `design`, ~62MB each). These are
Mach-O arm64 only — they do NOT work on Linux, Windows, or Intel Macs. The
`./setup` script builds from source for every platform.
These directories are **untracked and gitignored** (`.gitignore:3-6`; the
`browse/dist/` binaries were untracked in `64d5a3e4`, v0.11.16.0; the others were
never tracked). They will NOT appear in `git status`. If a dist binary ever does
show up in `git status`, something force-added it (`git add -f`) — do not commit
it; unstage it and find out how it got there.
When staging files, always use specific filenames (`git add file1 file2`) — never
`git add .` or `git add -A`, which can sweep in build outputs and junk.
## Redaction guard (PII / secrets / legal content)
Shared redaction engine catches credentials, PII, and legal/damaging content
before it reaches an external sink (codex dispatch, GitHub issue/PR body, pushed
commit). It is a **guardrail, not airtight enforcement**`git push --no-verify`,
direct `gh issue create`, and `GSTACK_REDACT_PREPUSH=skip` all bypass it. It
catches accidents and carelessness, the 99% case. Do not claim it stops a
determined leaker (a CHANGELOG line that does would fail a hostile screenshotter).
- **Engine + taxonomy:** `lib/redact-patterns.ts` (the single source of truth —
3 tiers; HIGH = genuinely-secret credentials that block, MEDIUM = PII/legal/
internal + high-FP credential shapes that confirm via AskUserQuestion, LOW =
FYI) and `lib/redact-engine.ts` (pure `scan()` + `applyRedactions()`).
Calibration matters: a gate that cries wolf gets ignored, so context-variable
shapes (Stripe `pk_live_`, Google `AIza`, JWT, env `*_KEY=`) sit at MEDIUM.
- **CLI:** `bin/gstack-redact` (exit 0 clean / 2 MEDIUM / 3 HIGH; `--json`,
`--auto-redact`, `--repo-visibility`, `--from-file`). `bin/gstack-redact-prepush`
is the opt-in git hook.
- **Skill docs are generated** from `scripts/resolvers/redact-doc.ts`
(`{{REDACT_INVOCATION_BLOCK:<sink>}}`) so /spec,
/cso, /ship, /document-release, /document-generate never drift from the engine.
- **Scan-at-sink:** always scan the EXACT bytes that will be sent — write to a
temp file, scan that file, pass the SAME file to `gh`/`git`. Never scan a string
then re-render (that reopens a scan-vs-send gap).
- **Visibility (no tier promotion):** resolve once per run, order = local config
(`gstack-config get redact_repo_visibility`, ~/.gstack so never committed) → gh
→ glab → unknown(=public-strict). Public repos get STERNER per-finding
confirmation (no batch-acknowledge, no silent-proceed); MEDIUM is never
auto-promoted to HIGH.
- **Tool-attributed fences:** wrap Codex/Greptile/eval output in ` ```codex-review `
/ ` ```greptile ` fences so example credentials those tools quote WARN-degrade
instead of blocking. A live-format credential inside the fence still blocks.
- **Config keys:** `redact_repo_visibility` (public|private|unknown, local-only
override for repos gh/glab can't read), `redact_prepush_hook` (true|false).
There is intentionally NO key to disable HIGH blocking.
- **Audit:** the /spec semantic pass appends a content-free record (categories +
body sha256, no spec text) to `~/.gstack/security/semantic-reviews.jsonl` (0600).
## Commit style
**Always bisect commits.** Every commit should be a single logical change. When
you've made multiple changes (e.g., a rename + a rewrite + new tests), split them
into separate commits before pushing. Each commit should be independently
understandable and revertable.
Examples of good bisection:
- Rename/move separate from behavior changes
- Test infrastructure (touchfiles, helpers) separate from test implementations
- Template changes separate from generated file regeneration
- Mechanical refactors separate from new features
When the user says "bisect commit" or "bisect and push," split staged/unstaged
changes into logical commits and push.
## Slop-scan: AI code quality, not AI code hiding
We use [slop-scan](https://github.com/benvinegar/slop-scan) to catch patterns where
AI-generated code is genuinely worse than what a human would write. We are NOT trying
to pass as human code. We are AI-coded and proud of it. The goal is code quality.
```bash
npx slop-scan scan . # human-readable report
npx slop-scan scan . --json # machine-readable for diffing
```
Config: `slop-scan.config.json` at repo root (currently excludes `**/vendor/**`).
Before fixing any finding, read [docs/SLOP_SCAN.md](docs/SLOP_SCAN.md):
it separates genuine quality fixes (empty catches around file ops →
`safeUnlink()`, process kills → `safeKill()`) from linter gaming we
reject (string-matching error messages, tightening best-effort cleanup).
Utilities live in `browse/src/error-handling.ts`. Don't chase the score.
## Community PR guardrails
When reviewing or merging community PRs, **always AskUserQuestion** before accepting
any commit that:
1. **Touches ETHOS.md** — this file is Garry's personal builder philosophy. No edits
from external contributors or AI agents, period.
2. **Removes or softens promotional material** — YC references, founder perspective,
and product voice are intentional. PRs that frame these as "unnecessary" or
"too promotional" must be rejected.
3. **Changes Garry's voice** — the tone, humor, directness, and perspective in skill
templates, CHANGELOG, and docs are not generic. PRs that rewrite voice to be
more "neutral" or "professional" must be rejected.
Even if the agent strongly believes a change improves the project, these three
categories require explicit user approval via AskUserQuestion. No exceptions.
No auto-merging. No "I'll just clean this up."
## Checking out PRs from garrytan-agents
When the user says "check out <PR link>" and the PR is from `garrytan-agents/gstack`
(or any other fork that is NOT a collaborator on `garrytan/gstack`), do NOT just
`gh pr checkout`. Fork PRs don't receive base-repo secrets (`ANTHROPIC_API_KEY`,
`OPENAI_API_KEY`, etc.), so the eval/E2E CI jobs fail with empty-env auth errors
regardless of what's set on the base repo.
**Workflow:** push the branch to `garrytan/gstack` (the base repo) and re-target
the PR from there.
Concretely, after `gh pr checkout <N>`:
1. Note the original PR number and head branch name.
2. Push the same branch to the base repo: `git push origin HEAD:<branch-name>`
(origin = `garrytan/gstack`, since the worktree is set up with that remote).
3. Close the fork PR (`gh pr close <N> --comment "moving to base-repo branch for secret access"`).
4. Open a new PR from the base-repo branch: `gh pr create --base main --head <branch-name>`.
5. New PR's workflows will get secrets automatically.
Why not fix it on the fork side? `garrytan-agents` isn't a collaborator on
`garrytan/gstack`. Adding it as a collaborator (option A) or flipping the
repo-wide "send secrets to fork PRs" toggle (option B) would let secrets reach
fork PRs from anyone — broader blast radius than just moving this one branch.
Option C (this section) keeps secret-distribution scope tight.
If the user asks you to skip the move (e.g., "just leave it as a fork PR"),
respect that — eval CI will fail with empty-env auth, but check-freshness,
workflow-lint, and windows-tests will still pass on the fork PR.
## CHANGELOG + VERSION style
**Versioning invariant (workspace-aware ship).** VERSION is a monotonic ordered
release identifier, not a strict semver commitment. The bump level
(major/minor/patch/micro) expresses intent at ship time. Queue-advancing past a
claimed version within the same bump level is explicitly permitted — if branch A
claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0
(still a MINOR relative to main). Downstream consumers must NOT rely on
"MINOR = feature-only, PATCH = fix-only" as a strict contract. This is why
`bin/gstack-next-version` advances within the chosen bump level rather than
repicking the level when collisions happen.
**package.json carries the npm-valid translation, not VERSION verbatim.**
VERSION stays the 4-digit source of truth (e.g. `1.67.0.0`); package.json and
any subdirectory manifests with a `version` field get the 3-digit npm-valid
translation (`1.67.0`), and lockfile `version` fields sync only when the
lockfile already exists. `bin/gstack-version-bump` (via `lib/version-source.ts`)
owns the translation and judges drift on translated forms — do NOT "fix" the
apparent mismatch by hand, and do not write a 4-digit version into
package.json (npm rejects it). Rationale and translation rules live in the
`lib/version-source.ts` header; `test/gstack-version-bump.test.ts` pins the
contract.
**Scale-aware bumps — use common sense.** When the diff is big, bump MINOR (or
MAJOR), not PATCH. PATCH is for bug fixes and small additions; MINOR is for
substantial new capability or substantial reduction; MAJOR is for breaking
changes. Rough guideposts (don't treat as rules, treat as smell-checks):
- **PATCH (X.Y.Z+1.0)**: bug fix, doc tweak, small additive change, single
test/file added. Net diff under ~500 lines, no new user-facing capability.
- **MINOR (X.Y+1.0.0)**: new capability shipped (skill, harness, command, big
refactor), substantial code reduction (compression, migration), or coordinated
multi-file change. Net diff over ~2000 lines added/removed, OR a user-visible
feature you'd put in a tweet.
- **MAJOR (X+1.0.0.0)**: breaking change to public surface (CLI flag rename,
skill removed, config format changed), OR a release big enough to be the
headline of a blog post.
If you find yourself debating "is 10K added + 24K removed really a PATCH?" — it
isn't. Bump MINOR. Same for "this adds a whole new test harness with 6 new E2E
tests + helper utilities" — MINOR. The bump level is communication to the user
about what kind of release this is; don't undersell it.
When merging origin/main brings a higher VERSION, re-evaluate the bump level
against the SCALE of your branch's work, not just whether main moved forward.
If main bumped MINOR and your branch is also a substantial change, you bump
MINOR again on top (e.g., main at v1.14.0.0, your branch lands v1.15.0.0).
**VERSION and CHANGELOG are branch-scoped.** Every feature branch that ships gets its
own version bump and CHANGELOG entry. The entry describes what THIS branch adds —
not what was already on main.
**The CHANGELOG entry is the diff between main and the shipping branch — what users
get when they upgrade. NOT how the branch got there.** A reader landing on the entry
should learn what they can do now that they couldn't before; they should not learn
about the branch's internal version bumps, the bugs we caught and fixed mid-branch,
the plan reviews we ran, or the commits we squashed. That is branch development
narrative. It belongs in PR descriptions and commit messages, not CHANGELOG.
**Never reference branch-internal versions in a CHANGELOG entry.** If your branch
bumped VERSION from v1.5.0.0 → v1.5.1.0 → v1.6.0.0 during development and only the
final v1.6.0.0 ships to main, the entry must read as if v1.5.1.0 never existed.
Concretely, NEVER write:
- "v1.5.1.0 had a bug that v1.6.0.0 fixes" — readers don't know about v1.5.1.0; it's
a branch-internal artifact.
- "The shipping headline of v1.5.1.0 was broken because..." — same reason. From main's
perspective, v1.5.1.0 was never released.
- "Pre-fix tests encoded the broken behavior" — that's a contributor's victory lap,
not a user benefit.
- "Two surgical edits, both in the dispatch path" — micro-narrative of the patch.
Instead, describe the released system: "Browser-skills run end-to-end with the
expected tab-access semantics." If a property of the shipped system is worth calling
out (e.g., "skill spawns get permissive tab access; pair-agent tunnel tokens require
ownership"), document it as a property, not as a fix. The shipped system is what
the user gets; the path to that system is invisible to them.
**When to write the CHANGELOG entry:**
- At `/ship` time (Step 13), not during development or mid-branch.
- The entry covers ALL commits on this branch vs the base branch.
- Never fold new work into an existing CHANGELOG entry from a prior version that
already landed on main. If main has v0.10.0.0 and your branch adds features,
bump to v0.10.1.0 with a new entry — don't edit the v0.10.0.0 entry.
**Key questions before writing:**
1. What branch am I on? What did THIS branch change?
2. Is the base branch version already released? (If yes, bump and create new entry.)
3. Does an existing entry on this branch already cover earlier work? (If yes, replace
it with one unified entry for the final version.)
**Merging main does NOT mean adopting main's version.** When you merge origin/main into
a feature branch, main may bring new CHANGELOG entries and a higher VERSION. Your branch
still needs its OWN version bump on top. If main is at v0.13.8.0 and your branch adds
features, bump to v0.13.9.0 with a new entry. Never jam your changes into an entry that
already landed on main. Your entry goes on top because your branch lands next.
**After merging main, always check:**
- Does CHANGELOG have your branch's own entry separate from main's entries?
- Is VERSION higher than main's VERSION?
- Is your entry the topmost entry in CHANGELOG (above main's latest)?
If any answer is no, fix it before continuing.
**After any CHANGELOG edit that moves, adds, or removes entries,** immediately run
`grep "^## \[" CHANGELOG.md` to verify no duplicates and a sensible reverse-chronological
order. Gaps between version numbers are fine. A branch that ships at v1.6.4.0 without
a prior v1.5.2.0 or v1.5.3.0 entry on main is correct — those were branch-internal
version numbers that never landed. Do not back-fill gaps with placeholder entries.
**Never orphan branch-internal versions.** If your branch bumped VERSION several times
during development (v1.5.1.0 → v1.5.2.0 → v1.6.4.0, say) and those earlier entries were
never released to main, the final ship consolidates ALL of them into a single entry at
the final version (v1.6.4.0). Collapse them — delete the old entries and move their
content into the final entry, re-version table columns accordingly. Readers see one
release, not a branch diary. Gaps are fine (v1.6.3.0 → v1.6.4.0 with no v1.5.x
in between on main is correct).
CHANGELOG.md is **for users**, not contributors. Write it like product release notes:
- Lead with what the user can now **do** that they couldn't before. Sell the feature.
- Use plain language, not implementation details. "You can now..." not "Refactored the..."
- **Never mention TODOS.md, internal tracking, eval infrastructure, or contributor-facing
details.** These are invisible to users and meaningless to them.
- Put contributor/internal changes in a separate "For contributors" section at the bottom.
- Every entry should make someone think "oh nice, I want to try that."
- No jargon: say "every question now tells you which project and branch you're in" not
"AskUserQuestion format standardized across skill templates via preamble resolver."
**Only document what shipped between main and this change.** Readers do not care how
we got here. Keep out of the CHANGELOG, always:
- Branch resyncs, merge commits with main, rebase activity.
- Plan approvals, review outcomes (CEO / eng / design / outside-voice / codex findings),
AskUserQuestion decisions, scope negotiations.
- "Work queued," "plan approved," "in-progress," "will ship later" — the CHANGELOG
documents what DID ship, not what MIGHT ship.
- Version-bump housekeeping when no user-facing work actually landed.
If the diff between the base branch version and this version has no user-facing change
(only merges, only CHANGELOG edits, only placeholder work), the honest entry is one
sentence: "Version bump for branch-ahead discipline. No user-facing changes yet." Stop
there. Do not pad. Do not explain the plan that will ship eventually. Do not narrate
the branch's history. When real work lands, the entry will replace this at /ship time.
### Entry format
Every `## [X.Y.Z]` entry starts with a release summary (two-line bold
headline, lead paragraph, numbers table, closing paragraph) followed by an
`### Itemized changes` section. Read
[docs/CHANGELOG_STYLE.md](docs/CHANGELOG_STYLE.md) for the full format spec
and voice rules BEFORE writing an entry. Always credit community
contributions with `Contributed by @username`.
## AI effort compression
When estimating or discussing effort, always show both human-team and CC+gstack time:
| Task type | Human team | CC+gstack | Compression |
|-----------|-----------|-----------|-------------|
| Boilerplate / scaffolding | 2 days | 15 min | ~100x |
| Test writing | 1 day | 15 min | ~50x |
| Feature implementation | 1 week | 30 min | ~30x |
| Bug fix + regression test | 4 hours | 15 min | ~20x |
| Architecture / design | 2 days | 4 hours | ~5x |
| Research / exploration | 1 day | 3 hours | ~3x |
Completeness is cheap. Don't recommend shortcuts when the complete implementation
is achievable. Boil the ocean — the complete thing is the goal; only genuinely
unrelated multi-quarter migrations are separate scope, never an excuse for a
shortcut. See the Completeness Principle in the skill preamble for the full
philosophy.
## Search before building
Before designing any solution that involves concurrency, unfamiliar patterns,
infrastructure, or anything where the runtime/framework might have a built-in:
1. Search for "{runtime} {thing} built-in"
2. Search for "{thing} best practice {current year}"
3. Check official runtime/framework docs
Three layers of knowledge: tried-and-true (Layer 1), new-and-popular (Layer 2),
first-principles (Layer 3). Prize Layer 3 above all. See ETHOS.md for the full
builder philosophy.
## Local plans
Contributors can store long-range vision docs and design documents in `~/.gstack-dev/plans/`.
These are local-only (not checked in). When reviewing TODOS.md, check `plans/` for candidates
that may be ready to promote to TODOs or implement.
## E2E eval failure blame protocol
When an E2E eval fails during `/ship` or any other workflow, **never claim "not
related to our changes" without proving it.** These systems have invisible couplings —
a preamble text change affects agent behavior, a new helper changes timing, a
regenerated SKILL.md shifts prompt context.
**Required before attributing a failure to "pre-existing":**
1. Run the same eval on main (or base branch) and show it fails there too
2. If it passes on main but fails on the branch — it IS your change. Trace the blame.
3. If you can't run on main, say "unverified — may or may not be related" and flag it
as a risk in the PR body
"Pre-existing" without receipts is a lazy claim. Prove it or don't say it.
## Long-running tasks: don't give up
When running evals, E2E tests, or any long-running background task, **poll until
completion**. Use `sleep 180 && echo "ready"` + `TaskOutput` in a loop every 3
minutes. Never switch to blocking mode and give up when the poll times out. Never
say "I'll be notified when it completes" and stop checking — keep the loop going
until the task finishes or the user tells you to stop.
The full E2E suite can take 30-45 minutes. That's 10-15 polling cycles. Do all of
them. Report progress at each check (which tests passed, which are running, any
failures so far). The user wants to see the run complete, not a promise that
you'll check later.
## Running evals as an agent: always detach (SIGTERM-proof)
When **you (an agent/harness)** launch a long eval/benchmark run, run it through
`bin/gstack-detach` — NEVER as a plain backgrounded Bash task. A plain background
task lives in the harness's process group, so a SIGTERM ("polite quit") on a turn
boundary, a stopped Monitor, or an interruption kills the run mid-flight (observed:
`script "test:gate" was terminated by signal SIGTERM` ~40 min into a run). On macOS
the run can also die to idle-sleep. `gstack-detach` fixes both: a fresh session
(escapes the group SIGTERM) wrapped in `caffeinate -i` (blocks idle-sleep).
- Use the `eval:bg*` scripts (`eval:bg`, `eval:bg:all`, `eval:bg:gate`,
`eval:bg:periodic`) — they wrap the eval command in `gstack-detach` with the
machine-wide `gstack-evals` lock (concurrent worktrees serialize instead of
saturating the shared model API), a per-tier watchdog, and a **run-scoped** log
under `~/.gstack-dev/eval-runs/` (no shared-`/tmp` collision). Each prints its
log path. `eval:bg:gate` / `eval:bg:periodic` run their tier through the
sharded paid runner (`scripts/test-paid-shards.ts`, also exposed as
`test:gate:sharded` / `test:periodic:sharded`): one Bun process per test
file, an external wall-clock timeout that kills the shard's process GROUP
(stray `claude`/`codex` grandchildren included), a per-shard
`GSTACK_EVAL_DIR=<evalDir>/shards/<slug>/` honored by the `EvalCollector`
constructor, and an aggregate that separates failed vs timed-out vs
never-started shards — the detach timeouts (25200s gate / 37800s periodic;
floor enforced against the live shard census by
test/eval-detach-timeout-floor.test.ts)
are sized against worst-case shard wall clock. `EVALS_JOBS` sets the shard
process count (default 4); `EVALS_CONCURRENCY` is bun's --max-concurrency
WITHIN a shard (default 4) — they are deliberately separate knobs. `eval:list` / `eval:compare` /
`eval:summary` read the shard dirs too. Or call
`gstack-detach [--lock NAME] [--timeout SECS] [--label LBL] --
<cmd>` directly for any long agent job. Export `ANTHROPIC_API_KEY` first (never
pass keys in argv).
- Then **poll the printed logfile** with a death-aware watcher: break on the
guaranteed `### gstack-detach EXIT=<code> ###` sentinel (success AND failure are
both marked, so silence is never mistaken for success). The detached run survives
even if your watcher gets reaped, so re-checking the log always works.
- Why the lock: a shared dev box with several Conductor worktrees will rate-limit
the model API if two eval suites run at once (15-way concurrency each), which
mass-times-out E2E tests. The lock makes the second run WAIT, not collide.
- Humans running `bun run test:evals` foreground in their own terminal don't need
this — Ctrl-C is intended there. Detachment is for agent-launched runs only.
## E2E test fixtures: extract, don't copy
**NEVER copy a full SKILL.md file into an E2E test fixture.** SKILL.md files are
1500-2000 lines. When `claude -p` reads a file that large, context bloat causes
timeouts, flaky turn limits, and tests that take 5-10x longer than necessary.
Instead, extract only the section the test actually needs:
```typescript
// BAD — agent reads 1900 lines, burns tokens on irrelevant sections
fs.copyFileSync(path.join(ROOT, 'ship', 'SKILL.md'), path.join(dir, 'ship-SKILL.md'));
// GOOD — agent reads ~60 lines, finishes in 38s instead of timing out
const full = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
const start = full.indexOf('## Review Readiness Dashboard');
const end = full.indexOf('\n---\n', start);
fs.writeFileSync(path.join(dir, 'ship-SKILL.md'), full.slice(start, end > start ? end : undefined));
```
Also when running targeted E2E tests to debug failures:
- Run in **foreground** (`bun test ...`), not background with `&` and `tee`
- Never `pkill` running eval processes and restart — you lose results and waste money
- One clean run beats three killed-and-restarted runs
## Publishing native OpenClaw skills to ClawHub
Native OpenClaw skills live in `openclaw/skills/gstack-openclaw-*/SKILL.md`.
The command is `clawhub publish` (NOT `clawhub skill publish`) — full
workflow, auth, and verification:
[docs/OPENCLAW_PUBLISHING.md](docs/OPENCLAW_PUBLISHING.md).
## Deploying to the active skill
The active skill lives at `~/.claude/skills/gstack/`. After making changes:
1. Push your branch
2. Fetch and reset in the skill directory: `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main`
3. Rebuild: `cd ~/.claude/skills/gstack && bun run build`
**If you use gbrain:** the `git reset --hard` in step 2 reverts the brain-aware
(`GBRAIN_CONTEXT_LOAD` / `GBRAIN_SAVE_RESULTS`) blocks that `gstack-config
gbrain-refresh` renders into the install (those generated blocks differ from
`main` by design). After deploying, re-run `gstack-config gbrain-refresh` to
restore them across all your projects' Claude sessions. It's idempotent.
Or copy the binaries directly:
- `cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse`
- `cp design/dist/design ~/.claude/skills/gstack/design/dist/design`
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
## Cross-session decision memory
Durable decisions and their rationale are captured in an append-only, event-sourced
store at `~/.gstack/projects/<slug>/decisions.jsonl` so neither you nor the user
re-litigates a settled call or loses the "why" across sessions. This is the reliable,
file-only path: it works with gbrain OFF. (gbrain semantic recall is an optional
enhancement layered on top, never a dependency.)
- **Resurface** active decisions before re-deciding: `bin/gstack-decision-search`
(`--recent N`, `--scope repo|branch|issue`, `--query KW`, `--all`, `--json`).
Add `--semantic` (with `--query`) to append related hits from gbrain memory when
it's up; it degrades silently to the reliable file results when gbrain is off.
Session start already surfaces scope-relevant active decisions via Context Recovery.
If a decision is listed, treat it as settled with its rationale; if you're about to
reverse it, say so explicitly.
- **Capture** a DURABLE decision when you or the user make one:
`bin/gstack-decision-log '{"decision":"...","rationale":"...","scope":"repo|branch|issue","source":"user|skill|agent","confidence":1-10}'`.
Reverse a prior call with `--supersede <id>`; expunge an accidental secret with
`--redact <id>`; rewrite the log to the active set with `--compact`. Non-interactive
(never prompts), injection-sanitized, and HIGH-secret-blocking on write.
- **Durable means:** architecture choice, scope cut, tool/vendor choice, or a reversal
of a prior call. NOT a turn-level edit, a phrasing tweak, or anything trivially
re-derivable. Capture is curated at the source — log durable decisions only, or the
store becomes noise.
## GBrain Search Guidance (configured by /sync-gbrain)
<!-- gstack-gbrain-search-guidance:start -->
GBrain is set up and synced on this machine. The agent should prefer gbrain
over Grep when the question is semantic or when you don't know the exact
identifier yet.
**This worktree is pinned to a worktree-scoped code source** via the
`.gbrain-source` file in the repo root (kubectl-style context). Any
`gbrain code-def`, `code-refs`, `code-callers`, `code-callees`, or `query`
call from anywhere under this worktree routes to that source by default —
no `--source` flag needed. Conductor sibling worktrees of the same repo
each have their own pin and their own indexed pages, so semantic results
match the actual code on disk in this worktree.
Two indexed corpora available via the `gbrain` CLI:
- This worktree's code (auto-pinned via `.gbrain-source`).
- `~/.gstack/` curated memory (registered as `gstack-brain-<user>` source via
the existing federation pipeline).
Prefer gbrain when:
- "Where is X handled?" / semantic intent, no exact string yet:
`gbrain search "<terms>"` or `gbrain query "<question>"`
- "Where is symbol Y defined?" / symbol-based code questions:
`gbrain code-def <symbol>` or `gbrain code-refs <symbol>`
- "What calls Y?" / "What does Y depend on?":
`gbrain code-callers <symbol>` / `gbrain code-callees <symbol>`
- "What did we decide last time?" / past plans, retros, learnings:
`gbrain search "<terms>" --source gstack-brain-<user>`
Grep is still right for known exact strings, regex, multiline patterns, and
file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
auto-sync across all worktrees, run `gbrain autopilot --install` once per
machine — gbrain's daemon handles incremental refresh on a schedule.
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
orchestrator refuses destructive source ops when it detects a running autopilot
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
sync code walk for them requires an explicit `--allow-reclone` opt-in.
<!-- gstack-gbrain-search-guidance:end -->