Files
gstack/test/gen-skill-docs.test.ts
T
2be6c06ba8 v1.65.0.0 feat: fork port wave 2 — feature fixes, session persistence, Apple releases, supply-chain CI (#2577)
* fix(memory-ingest): pass --include-gitignored to gbrain import

gstack-artifacts-init writes an ignore-everything .gitignore (a bare `*`,
headed "Do not edit") at the root of ~/.gstack. The memory ingest stages
pages into ~/.gstack/.staging-ingest-<pid>-<ts>/, which is inside that
repo, and gbrain's markdown collector honours .gitignore. The collector
therefore matches every staged file against `*` and collects zero.

The failure is silent. gbrain import exits 0 having imported nothing while
the ingest prints `written: N` from the STAGED count rather than the
imported count, so a run that indexes nothing looks identical to a healthy
one and the memory corpus quietly stops growing.

Reproduction, using git's own ignore machinery (no gbrain needed):

  git init .
  mkdir -p .staging-ingest-12345/learnings
  echo x > .staging-ingest-12345/learnings/page.md
  printf '*\n' > .gitignore
  git ls-files --others --exclude-standard   # -> empty

Passing --include-gitignored makes the import independent of whatever
.gitignore sits above the staging directory. Adding a negation to the
generated .gitignore is the alternative, but that file is gstack-owned and
marked "Do not edit", so any regeneration silently reintroduces the bug.

Adds a regression pin in the shape of memory-ingest-no-put_page.test.ts,
plus a behavioural test for the collision itself. Both source pins fail
against the unpatched file.

* fix(memory-ingest): GIT_CEILING_DIRECTORIES defense-in-depth on the import child (#2144)

Second layer under #2560's --include-gitignored: a realpath'd ceiling at the
staging dir's parent pushes any git-enumerating collector off the git fast
path (which sees zero files under ~/.gstack's ignore-everything root) onto
its plain FS walk, even on gbrain builds whose flag semantics drift. Ceiling
is realpath'd because git compares canonicalized directories during
discovery — a staging dir reached through a symlink (macOS /var ->
/private/var, symlinked $GSTACK_HOME) otherwise never matches.

Behavioral tests prove discovery stops at the ceiling from the staging dir,
including through a symlinked path, using git itself — no gbrain required.

Mechanism ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autoplan): Phase 4 task aggregator emitted zero tasks on every run (#2018)

The branch+commit jq filter piped to the split commit array and then
referenced .commit — jq rebinds context across a pipe, so .commit indexed
the ARRAY with a string, every input line errored into 2>/dev/null, and
|| true swallowed the exit. The aggregate table has been empty for every
user since the feature shipped. Bind .commit to a variable before the pipe.

Functional pin extracts the ACTUAL emitted jq program from the resolver and
runs it against fixture JSONL (verified RED against the broken filter), plus
a source-shape guard against reintroducing a context-rebinding reference.

Fix mechanism from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(codex): BSD mktemp templates broke /codex on every macOS install (#2091)

macOS mktemp requires the X's to end the template; the five
"codex-*-XXXXXX.txt" sites failed with "mkstemp failed ... File exists"
before Codex ever ran (reproduced live on this machine). Same class fixed
in claude/SKILL.md.tmpl's three sites. bin/gstack-paths now strips macOS's
trailing slash from TMPDIR so TMP_ROOT-built paths stop carrying "//".

Static tripwire scans every tracked .tmpl for characters after the X-run in
a mktemp template (longer X-runs stay valid), plus a live portability check
of the emitted shape.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(decision-log): --supersede silently discarded the replacement decision

The supersede/redact branch appended the retirement event and exited before
the JSON argument was ever read — a user recording a reversal WITH its
replacement lost the replacement, and the payload finder's first-non-flag-arg
predicate would have mistaken the target id for JSON anyway.

Payloads are now identified by their leading brace, validated BEFORE any
write, and appended FIRST (retirement second), so the only visible
interleaving under a crash is both-active — recoverable, never lost. The
replacement carries supersedes:<old-id> provenance. Bare --supersede <id>
(the documented reversal-without-replacement) stays legal; --redact with a
payload now refuses instead of dropping it.

Ported from time-attack/gstack (GStack 2), tests included.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(brain-context): cold-start probe latency permanently disabled gbrain context

gbrainAvailable() spawned gbrain --version under a 500ms budget; a cold CLI
start on a loaded machine blew the timeout, misclassified gbrain as missing,
and every skill session silently ran brainless — plus the per-query re-probe
burned 3x the budget before any real work. Replaced with a memoized
stat-based PATH scan (PATHEXT-aware on Windows) and made the query timeout
overridable via GSTACK_BRAIN_TIMEOUT_MS for loaded CI environments.

Also picks up the fork's manifest-filter coverage (#1687 shape) against the
fake-gbrain harness — passes against our existing filter support.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup-gbrain): voyage-code-3 flags were silently dropped under zsh (#1798)

zsh does not word-split an unquoted $VAR, so all three PGLite-init sites
passed the entire flag string as ONE argv word — gbrain ignored it and
silently fell back to its default embedding model, downgrading code
retrieval for every zsh user (macOS default shell). Flags now ride the
positional params (set -- ...; "$@").

Tests run the shape under BOTH bash and zsh against the fake-gbrain argv
recorder (per-word argc log distinguishes one-blob from split), include a
demonstration of the zsh collision on the old shape, and pin the template's
three sites statically.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(model-benchmark): recognize macOS Keychain auth in the claude adapter (#1890)

The default macOS Claude Code install stores OAuth under the Keychain
generic-password service "Claude Code-credentials" and never writes
~/.claude/.credentials.json, so available()'s file-or-env sniff reported
"No Claude auth found" while claude -p worked fine. On darwin the sniff
now also probes the Keychain entry — metadata only (no -w, the secret is
never read), 5s timeout, any security(1) failure degrades to not-found.

Verified live on this machine (subscription install, no creds file,
Keychain entry present).

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

* fix(upgrade): v1.27 migration no longer auto-proceeds without a TTY or records a failed rename as done (#1383)

Two silent-failure shapes in one script. Non-interactive runs (Claude Code
Bash tool, CI) blanket-auto-proceeded into a REMOTE repo rename — now they
skip-for-now by default and ask again next upgrade; unattended runs opt in
with GSTACK_MIGRATE_ASSUME_YES=1. And a failed gh rename was journaled as
done and the done-touchfile written, permanently stranding a half-renamed
install — the failed step now stays PENDING with the manual command printed,
finalize refuses the done-marker while any step is unjournaled, and the
migration exits 1 with a re-run pointer while completed steps still skip on
retry.

Harness updated to opt in explicitly; new tests pin the default-skip and
failure-stays-pending-then-retry-succeeds contracts (13/13).

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

* fix(ship): REST fallback when gh pr edit hits the Projects-classic GraphQL deprecation (#1079)

On repos where GitHub enforces the Projects-classic sunset, gh pr edit
hard-errors on repository.pullRequest.projectCards and Step 19's PR body
update dies. The template now names the error shape, says it is not an auth
problem, and falls back to the REST endpoint (gh api pulls/N -X PATCH) with
the SAME already-redaction-scanned temp file for body and title. Generated
SKILL.md regen rides the cluster regen commit.

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

* fix(ship): test-command detection was blind to Django and config-less-but-tested projects

The Test Framework Bootstrap detected Python only via requirements.txt or
pyproject.toml and treated missing config files as no-tests, so a green
'python manage.py test' Django app, a Go project with *_test.go beside the
source, in-source Rust #[test] blocks, or a package.json with only a test
script all got offered a SECOND test framework over a working one.

Detection now enumerates definitive per-ecosystem markers (manage.py,
tox.ini/setup.cfg, pom.xml/gradle, Makefile test targets, a tracked-file
test census, in-source Rust tests) as EVIDENCE for the question it asks —
never a command to run blind — preserving the read-CLAUDE.md-or-ask
contract, with a marker→candidate-command table and ask-once persistence.
The shared coverage-audit detection block gains the same markers.

Test runs the resolver's emitted detection bash against Django / Go / Rust /
Node fixtures in throwaway git repos.

Ported from time-attack/gstack commit e3259078 (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md files for cluster A (autoplan jq, codex mktemp, setup-gbrain zsh, ship detection + REST fallback)

Atomic regen of the 9 generated files whose templates/resolvers changed in
the A-cluster commits. bun run gen:skill-docs, no hand edits.

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

* test: refresh ship goldens + parity ratios for cluster A growth

Codex/Factory hosts render single-file ship skills whose committed goldens
must track template changes; refreshed from the regenerated renders. Parity
size guards bumped with the growth itemized — ship (carve-guards) 1.08 ->
1.10 for the detection-evidence + REST-fallback growth measured at 1.090x,
qa (parity-harness monolith invariant) 1.07 -> 1.12 for the shared
coverage-audit markers measured at 1.111x. Kept tight so the next growth is
a deliberate decision, not drift; the Apple adapter raises ship again with
its own justification.

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

* fix(gbrain-sync): enforce the per-repo policy at the code-import chokepoint (#2140 sync path)

The deny/read-only tiers in ~/.gstack/gbrain-repo-policy.json were stored
by gstack-gbrain-repo-policy but enforced only in /sync-gbrain skill prose —
a direct or cron invocation of gstack-gbrain-sync ingested repo code
regardless. Worse: the code stage's egress receipt has cited 'per-repo
policy chokepoint (repoPolicyTier)' as its consent since v1.63 while no such
function existed. repoPolicyTier() now gates the stage before the dry-run
branch: deny → refused-policy-deny (exit 1, loud), read-only → clean
skipped-policy-read-only (code ingest writes pages), unreadable store →
fail-closed refused-policy-unreadable, no store → unchanged fail-open.

Subprocess tests pin all four paths against real git repos and a
permission-blocked store (verified RED against the ungated binary). The
receipt's consent string is truthful from this commit. #2140's ingest-path
source-isolation ask remains open — partial-progress comment at ship.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process

The loopback sessions list echoed live tokens — a harvest-and-replay
primitive for anything on the machine (same class as the /health token leak
fixed in v1.63). The list now returns a device-salted 16-hex token_id plus
metadata; the salt is shared with the attempts log so identifiers correlate.
/auth/revoke keeps the list→revoke workflow alive by accepting token_id
alongside the caller's own raw token and identity. saltedHash() is exported
from audit.ts and writeAttempt now reuses it (was inlined).

Integration tests pin raw-token absence, the id shape/metadata, and the
token_id revoke round-trip (verified RED against the leaking handler).

List fix ported from time-attack/gstack (GStack 2); token_id revoke is ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ios-qa): boot token out of os_log entirely; IPv4 listener pinned to loopback at the socket

The StateServer's bootstrap announce logged the live boot token with
privacy: .public — and nothing consumed it: the daemon has read the token
from the 0600 app-container file since the devicectl copy flow landed. The
log line handed a credential to anything reading the unified log during the
launch window. It now announces port/build only.

The IPv4 listener bound the wildcard interface and relied on the
per-connection peer check alone; IPv4 has no CoreDevice tunnel path, so it
now binds 127.0.0.1 via requiredLocalEndpoint at the socket level. IPv6
keeps the wildcard bind for CoreDevice ULA peers by design.

Static pins cover both the template and the fixture app copy.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(make-pdf): close the offline-gate bypass via raw-HTML fetch vectors

With --allow-network off, the sanitizer stripped script/iframe/link but let
Chromium fetch remote resources at print time through four raw-HTML vectors:
<style> @import (any form), remote url() in <style> blocks and inline style
attributes (incl. protocol-relative //), srcset with a remote candidate
(Chromium prefers srcset over the inlined src), and remote src/poster on
video/audio/source/track. All neutralized at the sanitizer; remote <img src>
is deliberately left for the image inliner so its blocked-remote placeholder
still fires, and url() mentions in prose/code spans stay untouched.

Fork's test suite ported verbatim (12 cases incl. the end-to-end render
assertion), verified RED against the old sanitizer.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): tunnel activation is consent-gated — and the receipt's consent claim is now real

The tunnel egress receipts have claimed consent: 'pair_agent=on' since v1.63
while no such key or gate existed — ngrok installed+authed was enough for
the CLI to auto-start an internet-facing tunnel. isPairAgentEnabled() (fail-
closed, env-overridable) now gates all three activation points: CLI
auto-start, POST /tunnel/start (refuses with the enable hint), and the
BROWSE_TUNNEL=1 startup bind. Consent-on-first-use, not silent breakage:
the /pair-agent skill asks once (one-way-door posture), sets pair_agent via
gstack-config (registered with on|off validation, default off), and never
asks again; direct API callers get the same hint in the refusal.

Adapted from the fork's gate: their reader targeted config.json, which on
main would have made the gate silently un-enableable — ours reads the
canonical ~/.gstack/config.yaml with the JSON shape as fallback, pinned by
tests either way (11 cases, gate wiring tripwires included).

Ported from time-attack/gstack (GStack 2), store adaptation ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate pair-agent SKILL.md for cluster B (consent gate)

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

* fix(browse): cancel the parent watchdog when handoff promotes a daemon to headed

The parent-process watchdog assumes connection mode is fixed at boot: headless
daemons outlive their parent, headed ones do not. The env guards
(BROWSE_PARENT_PID=0, BROWSE_HEADED=1) only cover daemons that were headed when
they started.

handoff breaks that assumption. It swaps in a headed context on a RUNNING daemon
and sets connectionMode = 'headed' without a restart, so a daemon that
legitimately registered a watchdog lands on the fatal side of the branch. The
parent is usually a short-lived shell, and Claude Code's Bash tool kills one after
every invocation, so the next 15s poll shuts the daemon down.

The user-visible effect is that handoff destroys the thing it just created. It
exists so a human can log in, solve a CAPTCHA, or clear an MFA prompt; the browser
disappears about fifteen seconds later and takes the session with it. Observed
while driving two registrar control panels: five daemon deaths and three logins,
each one discarding the authenticated session.

BrowserManager now exposes onHeadedPromotion, fired only on runtime promotion and
not on a headed boot, and the server binds it to a canceller for the interval it
already owned but previously discarded. Bound on both the module-level manager and
any embedder-supplied one, since the watchdog reads activeBrowserManager and
binding only the default would let embedders promote silently.

The binding sits next to the browserManager declaration rather than next to
clearParentWatchdog. Placing it with the function, which lives with the watchdog it
cancels, reads better but touches browserManager in its temporal dead zone, which
aborts module evaluation and leaves every later const uninitialized. findport
tests catch that immediately.

Tests: watchdog.test.ts already noted in its header that its three cases all fix
mode via env at spawn time, so none reaches the headed branch. Driving a real
handoff needs a headed Chromium, so the wiring is pinned with static tripwires
instead, matching cdp-session-cleanup.test.ts and server-auth.test.ts. Verified
they fail when the notification call is removed and pass when restored.

Full `bun test` shows the same 6 pre-existing failures on this branch and on main
(gstack-gbrain-detect, gstack-artifacts-init), which pass in isolation on both, so
they are test-order pollution rather than a regression here.

* fix(browse): pass windowsHide so the daemon stops popping console windows

On Windows, `browse` leaves empty black console windows on top of whatever the
user is doing — they pop up every few minutes for as long as any browser skill
is alive, and outlive the process that created them.

Cause: `bun-polyfill.cjs` maps `Bun.spawn`/`Bun.spawnSync` onto node's
`child_process`, and node defaults `windowsHide` to **false**. Bun never creates
these windows, so nothing in the daemon's own code looks wrong — the behaviour
only appears on the node fallback path.

The one users notice is `spawnTerminalAgent()`, which launches
`bun run terminal-agent.ts` through this shim. The daemon respawns it on a
watchdog, so closing the window is not enough — a new one arrives shortly after.
Ten `bun.exe` processes were live on the machine this was diagnosed on.

Why they linger after the child exits: with the default terminal application set
to "Let Windows decide", the console is brokered through Windows Terminal via
svchost, and WT leaves the empty frame behind when its only child exits. The
frame has no child process at all, which is why it looks like a dead terminal.

Setting `windowsHide: true` on both wrappers fixes every console child routed
through the shim — the bun agent plus the `tasklist`, `git` and `powershell`
calls elsewhere in the daemon. No behaviour change on macOS or Linux, where the
option is ignored.

Not covered by this commit: `chromium.launch()` goes through playwright's own
process launcher rather than this shim, so it still creates one window per daemon
start. Worth a follow-up.

* test(browse): make bun-polyfill tests runnable on Windows, and cover windowsHide

`bun test browse/test/bun-polyfill.test.ts` was **0 pass / 4 fail on Windows**
before this — every test in the file, on the platform the polyfill exists to
support.

Each test interpolates the polyfill's absolute path into a single-quoted JS
string passed to `node -e`. On Windows that path has backslashes, so JS eats
them as escapes:

    'C:\Users\jwilk\dev\gstack-fork\browse\src\bun-polyfill.cjs'
      ->  C:Usersjwilkdevgstack-forkrowsesrcun-polyfill.cjs

(`\b` is a real escape, so it deletes a character too.) `require()` throws, the
subprocess dies, stdout is empty, and every assertion compares against "". The
tests pass on macOS and Linux purely because those paths have no backslashes.

Fixed by interpolating with `JSON.stringify(polyfillPath)`, which quotes and
escapes correctly on all platforms.

Also adds a regression test for the windowsHide fix in the previous commit. It
stubs `child_process.spawn`/`spawnSync` *before* the polyfill destructures them
and asserts the captured options, so it is deterministic and needs no window —
it verifies the contract on macOS and Linux too, where the option is a no-op.

Verified on Windows: 5 pass / 0 fail with the fix, and the new test alone fails
("VISIBLE" instead of "HIDDEN") when the previous commit is reverted.

* fix(browse): forward windowsHide through the Bun polyfill spawn shims

The Node fallback shim accepts a Bun.spawn options object and forwards
only stdio, env and cwd to child_process.spawn. windowsHide is dropped,
and because Node defaults it to false while Bun.spawn hides the console
window, the omission inverts the behavior on the one platform the shim
exists to support.

Symptom: the terminal-agent respawn in server.ts (60s watchdog ticker)
pops a visible bun.exe console window on Windows every time it fires,
so the window keeps coming back with no scheduled task or startup entry
behind it. stdio:'ignore' silences the child's output but does not
suppress its window.

Both shims now forward the option and default it to true, matching the
Bun API being emulated; an explicit windowsHide:false still passes
through. spawnTerminalAgent also sets it explicitly at the call site.

Tests: three cases in browse/test/bun-polyfill.test.ts assert the
default for spawn and spawnSync and that an explicit false is honored.
Each was confirmed to fail against the unpatched shim.

Drive-by, required to run the suite at all on Windows: the tests
interpolated an absolute path into a JS string literal, so backslashes
were consumed as escapes and every require() failed with
MODULE_NOT_FOUND. The path is now normalized to forward slashes. On
Windows this file went from 0/4 passing to 7/7.

* fix(browse): headed mode on macOS 26 — stop mutating the signed Chromium bundle, heal the ones we already broke (#2242, #2138, #2139)

The in-place rebrand rewrote the Chrome-for-Testing bundle's Info.plist
(global name replace — which also renamed CFBundleExecutable to a binary
that doesn't exist) and overwrote its Resources/*.icns, breaking the
codesign seal: GPU process exit_code=5, headed mode dead on macOS 26. The
mutation lived in the SHARED Playwright cache, so it also poisoned the
user's other Playwright projects.

Three layers land together: (1) the rebrand block is gone — branding lives
in the GStack Browser.app wrapper via GSTACK_CHROMIUM_PATH, with a tombstone
and a static tripwire (no plist/icns writes into the bundle; the tripwire
allows the read-only probe below); (2) a launch-time self-heal detects an
already-poisoned cache bundle, removes it, and errors with the exact
re-fetch command — covering deploy paths that never run migrations;
(3) migration v1.64.0.0 sweeps every cached bundle, removes poisoned ones,
and re-fetches clean Chromium immediately (migrations run after ./setup, so
without the re-fetch an upgrade would end with zero working browser).
Functionally verified against fixture caches: poisoned removed, clean
untouched, rerun no-op. Migration filename tracks the final VERSION at ship.

The #2242 watchdog half is the absorbed PR #2565 (thanks @Screddyice).
Tombstone/tripwire ported from time-attack/gstack (GStack 2); self-heal and
migration are ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): 'browse stop' no longer restarts the daemon it was asked to stop

The stop handler awaited shutdown() — which ends in process.exit — before
returning, so the acknowledgement never egressed. The CLI's fetch reset,
which its crash path reasonably interpreted as a dead daemon: it relaunched
Chromium, re-sent stop, watched the daemon exit again, and errored 'Server
crashed twice in a row'. Every stop cost a wasted Chromium launch and a
nonzero exit. The ack now returns first; shutdown fires on a 25ms unref'd
timer. Same fix for restart. Fork's test pins ack-before-teardown for both.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): lock acquisition reports real errors instead of phantom contention (#1084)

acquireServerLock's bare catch treated EVERY failure as 'another process
holds the lock' — a missing state dir, EACCES, or ENOSPC read as permanent
phantom contention with nothing to debug. Now only EEXIST is contention:
ENOENT self-heals with one mkdirSecure retry, everything else throws
ServerLockError carrying the real errno, and the stale-lock unlink/retry
loop is depth-capped so it can't livelock. Fork's five-case test ported.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(browse): integration coverage for #1781 busy-vs-dead recovery

Fork's wedged-daemon fixture: first /command connection drops, daemon PID
stays alive. Pins the whole contract — CLI retries the same daemon instance
without a kill, state file untouched, no restart, exactly two command
requests. Message-text assertion adapted: our CLI retries silently at the
probe layer where the fork announces on stderr; the behavior, not the
message, is the invariant.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): windowsHide on every Windows-reachable spawn (#1835)

Console windows flashed (and stole focus) on every daemon relaunch,
taskkill, tasklist poll, and powershell DPAPI call — node-level spawns
default windowsHide to false. Covered: the node -e launcher (outer spawnSync
AND the inner detached daemon spawn inside the launcher string), the
dev-mode bun fallback, killServer's taskkill, isProcessAlive's tasklist,
and cookie-import's powershell + tasklist. The Bun-polyfill shims were
covered by absorbed PRs #2523 + #2539 (thanks @jwilk-hrep,
@jerrynicholsai); this closes the sites those PRs didn't reach. The icacls
sites land with the #1605 DACL commit alongside the static tripwire that
pins all of them. R8's planned spawnHidden() helper is deliberately NOT
built: the polyfill default plus the tripwire achieve the no-drift goal
without indirection over seven heterogeneous call shapes. The polyfill +
spawn-hide tests join the Windows CI shard.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): self-repair broken Windows DACLs on state dirs (#1605)

icacls '/inheritance:r /grant:r' can partially fail on localized or domain
accounts: inheritance strips but the user grant doesn't resolve, leaving a
machine-SID-only DACL the owner can't even list — the sidebar/PTY failure
chain in #1605, caused by the very hardening call meant to protect the dir.
mkdirSecure now verifies listability after hardening (a real readdir —
fs.accessSync doesn't consult NTFS ACLs) and repairs via icacls /reset,
re-hardens, and if hardening breaks access again leaves inherited ACLs:
functional-but-unhardened beats hardened-but-unusable. The icacls calls
carry windowsHide (#1835's last two sites) and the fork's static spawn-hide
tripwire lands here, pinning every covered site. file-permissions.test.ts
is already in the windows-free-tests curated shard, so the DACL contract
executes on windows-latest.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(browse): opt-in session persistence — auth survives daemon restarts (#778, #2193)

BROWSE_PERSIST_STATE=1 snapshots cookies + per-tab URL/localStorage/
sessionStorage to <stateDir>/session-state.json (0600) on a 30s unref'd
interval and at clean shutdown, and restores on the next launch — killing
the top-complained auth-lost-on-restart class (#778, #2193, #1128, #1129).

Security invariants mirror state save|load: loadedHtml and owner are never
persisted and never accepted from disk; restored cookies pass the same
hygiene filter (localhost/.internal/metadata domains dropped); restoreState
re-validates every URL. Default OFF; headed mode excluded (the persistent
profile owns that state). Hardened past the fork's shape per review R3:
corrupt state quarantines to .corrupt (forensic artifact, boots fresh, one
log line), snapshot failures warn once and never kill the daemon, and the
boot log reports restored counts or fresh-session status.

Module + 10 tests ported (MIT header retained); server wiring at launch,
interval, and shutdown; skill docs section added (regen rides the cluster
regen commit).

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate browse SKILL.md for cluster C (session persistence docs)

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

* feat(skills): third-party web-actions contract — offer to drive vendor-site steps, never just dump a manual list

When a workflow needs something done on an external website the user
controls (register an API key, create a vendor account, configure a
dashboard/webhook/OAuth app), five skills (ship, spec, office-hours,
setup-deploy, land-and-deploy) now follow one contract: offer to drive it
in a visible browser via gstack's own stack ($B headed + handoff/resume,
GStack Browser) behind ONE per-task consent question naming the exact site
and actions; passwords, payment, CAPTCHA, and identity stay user-performed;
captured secrets go to owner-only files or the user's secret store, never
chat/logs/history; and the credential is verified with one non-mutating API
call before any success claim — dashboards show masked placeholders, and a
401 catches them. Declining yields manual steps and a blocked-on-user mark;
nothing new is ever installed to close the gap.

New resolver token {{THIRD_PARTY_ACTIONS}} (adapted from the fork's
contract — their Aside-browser detection swapped for our own driver stack;
MIT portions noted). Parity guards bumped with growth itemized (ship
1.10->1.12 at measured 1.103x; office-hours skeleton 101K / 1.09 at
measured 1.079x); ship goldens refreshed.

Ported from time-attack/gstack (GStack 2), driver adaptation ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(office-hours): design docs land in the repo, written as decision records (#703, #2000)

Office-hours Phase 5 now dual-writes the design doc: the docs/designs/ copy
is what teammates and plan reviews read (committable, visible), while the
~/.gstack copy keeps memory ingest and cross-session discovery working. The
repo copy leaves the private store, so it passes the redaction scan-at-sink
first (HIGH blocks the repo copy, MEDIUM confirms per finding), and any
failure — read-only checkout, non-git dir, unconfirmed finding — degrades
to the private copy with a one-line reason, never blocking the handoff.

The doc itself is now a decision record, not a transcript: one bullet per
decision with its why, ruled-out approaches collapsed to a single line with
the rejection reason, settled/empty template sections omitted. No page cap;
extra length must come from genuinely open questions.

Plan reviews (ceo/eng/devex + the shared review resolver) prefer the
repo-local doc (DESIGN.md, then newest docs/designs/*.md) when it's at
least as fresh as the private copy — a stale old repo doc never shadows a
newer session. Parity guards bumped with measured values (three plan-review
skeletons +~0.7KB each; office-hours 1.092x).

Judgment ported from time-attack/gstack (GStack 2); scan-at-sink and
freshness-preference adaptations ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(office-hours): 'never show me these again' for the founder-resources pitch (#538)

The Phase 6 resources offer (34 PG essays + Garry/YC videos) had no
permanent decline — the reporter showed memory instructions kept being
overridden on every update, so people who said no got re-pitched forever.
The offer now closes with a standing choice; opting out runs
gstack-config set founder_resources false (new key, default true, true|false
validated), the write is VERIFIED before any promise (a failed write says so
and skips this session only), and every future session skips the entire
section silently — no resources, no 'skipped as requested' mention. Config
outlives session context, so never means never. Re-enable anytime:
gstack-config set founder_resources true. The pitch stays default-ON for
everyone who never opted out.

Tests pin the key's default/persistence/validation through the real config
bin and the generated section's gate-before-content + write-verify contract.

Approved as a promo-surface change (CEO review D3.4, 2026-08-14).
Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ship): the Apple App Store release journey — working tree to Submit for Review

Point /ship at a repo with an .xcodeproj, .xcworkspace, or app-product Swift
package and ask to release: the adapter runs the whole journey with ONE
authorization moment (membership + pricing + in-session sign-in, decision-
store persisted so repeat releases ask nothing) and one store-assets question
only when assets are missing. fastlane is the single tool (produce/cert/
sigh/gym/pilot/deliver/frameit); credential vocabulary never reaches the
user.

The adapter carries 21 live releases' worth of paid-for Apple knowledge:
the web session mints the permanent upload key itself (iris POST
/v1/apiKeys; privateKey is base64-of-PEM, downloadable only at creation) so
nobody ever types an app-specific password; error -22938 is Transporter
asking for a key, not a user task; errors are CLASSIFIED before credentials
are touched (validation/UnexpectedResponse = metadata, incl. Apple's
expanded age-rating attributes); pricing goes through POST
/v1/appPriceSchedules because fastlane's price_tier is broken against the
current API; and store distribution NEVER routes through the branch gate —
a clean tree on main is the solo shipper's normal case (Step 0.9 loads the
adapter BEFORE the gate, pinned by test with the non-Apple gate
byte-unchanged and unique). Uploads/submissions follow an idempotency-log
contract (inspect App Store Connect before any re-run). Non-Mac hosts get
the honest split: build legs via a macOS CI runner with the minted key as a
secret, API legs local. Browser use inside the journey is banned except the
named paid-app banking/tax residue. Redaction dry-run clean.

Ship's parity ratio raised 1.12 -> 1.22 deliberately: the 14.8KB section is
on-demand (Apple store targets only), one manifest line otherwise.

Ported from time-attack/gstack (GStack 2), refined across its 21 live
releases; architecture adaptation (carved section, decision-store paths,
idempotency log, third-party-actions handoff) ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(code-intelligence): provider contract Phase 1 — GBrain, Sourcebot, Graphify behind one ask-once offer

Open a large repo (1,000+ tracked files) and gstack can offer code
intelligence ONCE, with the trade-offs stated: GBrain (semantic memory +
code, sends content to YOUR gbrain DB, per-repo consent), Sourcebot
(self-hosted whole-repo search, local on localhost), Graphify (local
tree-sitter graph, nothing leaves the machine, user-installed), or No
indexing — a decline persists machine-wide so no skill ever asks again.
Small repos never see the question; grep stays the always-working default
and provider-OFF degrades silently (PROVIDER_UNAVAILABLE -> file-only).

Ported: lib/code-intelligence/ (contract + 3 verified adapters + picker +
selection + suggest, MIT headers), the gstack-code-intelligence CLI
(suggest/select/consent/index/search/status), 31 offline tests (fake CLI
shims + injected fetch), and the provider-contract design doc. Verified
live on this repo: suggest fires at 1,233 files with real availability
detail per provider.

Hardened per review: the per-remote trust store is the SINGLE consent
authority — a gstack-gbrain-repo-policy deny tier vetoes any recorded
code-intelligence consent (fail-closed on an unreadable store, pinned by
three tests); both send-capable adapters are registered as fail-closed
MODULE_SINKS in the egress tripwire so a refactor can't drop their
receipts; and local-compute vs remote-send consents are never bundled.
setup-gbrain gains the provider-choice Step 0. The fork's Phases 2-4
glue-collapse is explicitly NOT ported.

Ported from time-attack/gstack (GStack 2); consent unification ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): supply-chain hygiene — secret gate on every PR diff, dependency review, OSV, dependabot, evidence-bar PR template

The repo owned a redaction engine and had zero CI-side secret scanning.
quality-gate.yml now pipes every PR diff's ADDED lines through our own
bin/gstack-redact (gate-secret-scan.mjs, taken from the fork — it dogfoods
the engine): HIGH findings fail the check, MEDIUM prints an advisory count
only (no human in CI to confirm), planted-bug fixtures excluded by pathspec.
Live-verified both directions: PEM key fails, clean diff and MEDIUM shapes
pass; ShellCheck (errors) covers the setup/build shell boundary and passes
today; bun audit gates critical advisories. Trigger is pull_request, never
pull_request_target.

dependency-review.yml adopts the hardened never-merged prior-art branch
(fail-on-severity high, workflow paths watched, tight perms) — verify the
dependency graph parses bun.lock with a canary bump before trusting the
gate. dependabot: weekly, grouped per ecosystem, capped PR counts; and
evals.yml image build/push now skips dependabot actors, whose read-only
GITHUB_TOKEN made every lockfile bump a permanently red check. OSV scans
weekly with a reasoned ignore file. All new workflow actions SHA-pinned.
Scorecard deliberately not taken (no consumer for the score).

The PR template front-loads the evidence bar (live proof, liveness
screenshot, no-ETHOS/voice-changes checklist); the unenforced DCO line is
dropped. bin/gstack-verify-gate ships OPT-IN (never registered by ./setup —
a Stop hook running the project's verify command after every turn is the
user's call), with the fork's tests adapted to pin exactly that.

Ported from time-attack/gstack (GStack 2) + our own prior-art branch.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: remove dead bins; extend the stale-ref scan to docs (the 36-release gap)

bin/chrome-cdp, bin/gstack-open-url, and bin/gstack-platform-detect were
referenced only by an audit test and CHANGELOG history — dead weight that
the stale-ref scanner should police, which required removing them FIRST.
The scanner now also sweeps docs/, README.md, and USING_GBRAIN_WITH_GSTACK
— the deliberate exclusion that let a dead command survive ~36 releases as
a command-not-found instruction. Scan is green on the extended surface.

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

* fix(bins): detect the default branch instead of hardcoding main

gstack-diff-scope fell to an empty diff (all-false SCOPE_*) and
gstack-next-version mis-based its bump math on any repo whose default
branch isn't main (trunk, master, local-only). Both now resolve
origin/HEAD -> origin/main -> origin/master -> main.

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

* fix: housekeeping sweep — telemetry integrity, persistent opt-out, context-bill accuracy, setup hang, dev-server discovery, model resolution (#2136 + v1.63 polish)

Seven small fixes, one theme (claims matching code):
- telemetry-sync strips local-only fields with jq del() (structural) instead
  of quote-fragile sed regexes; unparseable lines are dropped, never
  forwarded unstripped. Sed survives only as a jq-less fallback.
- telemetry-log rejects non-integer durations BEFORE the range caps, whose
  test(1) comparisons silently no-op on non-numerics — a malformed duration
  spliced raw text into the JSONL stream.
- browse's local telemetry honors the persistent tier (config.yaml
  telemetry: off), not just the preamble's env hint — direct $B use and
  embedders now respect the opt-out.
- gstack-context-bill --exact sees GSTACK_-promoted keys inside Conductor
  (conductor-env-shim wired at the CLI entry), and the TOTAL line no longer
  double-counts every nested skill through the root skill's walk (v1.63
  deferred polish; the telemetry-sync HTTP-status outcome deferred alongside
  it turned out already shipped).
- setup's Chromium probe is deadline-bounded (90s, background + poll-kill —
  macOS has no GNU timeout) and prefers Node for the launch probe everywhere
  (the bun --eval hang family behind #2136); the install is single-flight
  behind a lock dir with an actionable stale-lock message. Probe verified
  live on this Mac.
- the review resolver's dev-server check reads CLAUDE.md and the plan file
  before falling back to an expanded port probe, and says how to make
  itself smarter next time.
- eval/harness model IDs resolve through lib/eval-model.ts
  (GSTACK_EVAL_MODEL[_KIND] env overrides, per-kind defaults, tested) at the
  SDK-capture and PTY-warmup sites; the bash-embedded distill snippet
  mirrors the resolution inline.
- memory-ingest's silent-zero shape (staged>0, imported+unchanged==0,
  errors==0) warns even under --quiet — a run that indexes nothing must
  never look healthy again.

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

* test: wire ios-qa/daemon/test into the free suite and shard runner (E2)

The daemon's 5 test files (allowlist, audit, auth-mint, cli-mint,
daemon-integration — now 6 with session hardening) were invisible to every
runner: not in the bun test glob, not in TEST_ROOTS. The same
silent-coverage-hole class as the tracked design/test P2 — and it meant
B2's auth regression tests would never have gated. All files are hermetic
(stub state-servers on ephemeral ports, no devices); verified green in the
shard census.

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

* feat(skills): claimed limitations now require evidence, everywhere + wave follow-ups filed

Every tier-2+ skill's preamble gains one directive distilled from nine live
release failures in two days on the fork: a claimed limitation or
requirement ('the API can't do this', 'X requires a credential',
'impossible on this platform') is a material claim, stated only with the
verbatim error, the documented statement, or a live probe in hand —
pattern-matching a failure to a familiar story is not evidence, and a cheap
probe runs BEFORE asking the user or declaring a step blocked. ONE directive
adapted into the preamble resolver; the fork's full judgment contract is
deliberately not imported. Full regen (46 files), ship goldens refreshed,
parity guards bumped with the measured ~0.45KB/skill (investigate, autoplan,
plan-design-review, office-hours), Step 0.9 registered as an intentional
sub-step.

Approved deferrals filed: persona-fleet hostile-user harness + answer-key
methodology in TODOS; the fork's question-budget ACCOUNTING judgment (never
its 5/8/12 constants) folded into the V1.1 pacing design doc; the Apple
adapter added to #1882's coverage note.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(make-pdf): close offline-gate bypasses via unquoted style attrs, CSS-escape and HTML-entity obfuscation

Three live vectors found by the ship review army, all red-first tested:
unquoted style attributes skipped the remote-url neutralizer entirely;
CSS ident/string escapes (@\69mport, url(\68ttps://…)) defeated the
literal-match patterns Chromium happily decodes; and HTML entities in
style attribute values (&#104;ttps) decoded to fetchable schemes before
CSS parsing. Style-attr values are now entity-decoded in one browser-
faithful pass, escape-bearing at-rules and function tokens are dropped
fail-closed, and output is re-encoded double-quoted. 21 new test rows.

* fix(migrations): v1.65 Chromium re-fetch actually re-downloads, and success is verified before .done

The migration (renamed from the provisional v1.64.0.0 slot, which open
PR #2564 claims) deleted only the poisoned .app while Playwright's
INSTALLATION_COMPLETE marker survived in the revision dir — so the
advertised 'bunx playwright install chromium' re-fetch no-opped and the
user finished the upgrade with no browser and a success message. Now:
the whole chromium-<rev> dir goes, bunx runs cwd-pinned to the install
root, .done is gated on a verified executable, and a needs-refetch
sentinel makes re-runs retry a failed download. Stranded rev dirs
(markers without .app) also re-trigger. 6 hermetic tests, red-first.

* fix(migrations): v1.27 remediation prints a real command instead of a fictional flag

Every skip/failure path referenced '/setup-gbrain --rerun-migration',
which is implemented nowhere, and promised the migration 'will ask
again next upgrade', which the version-window runners make false. All
five sites now print the direct GSTACK_MIGRATE_ASSUME_YES=1 bash
invocation. Runner-side re-offer tracking is filed in TODOS.

* fix(browse): poisoned-bundle self-heal removes the revision dir, probes handoff too, and throws typed

Same marker flaw as the migration: rmSync of the .app alone left
INSTALLATION_COMPLETE behind, so the error message's own remediation
no-opped and the user was hard-stuck. The probe is now an exported,
unit-tested helper (probePoisonedChromiumBundle) that removes the whole
chromium-<rev> dir, never touches GSTACK_CHROMIUM_PATH custom bundles,
throws PoisonedBundleError (instanceof, not string-match), and runs on
BOTH headed entry points — launchHeaded and handoff. 7 tests.

* fix(browse): session snapshots are atomic and the cookie filter drops loopback IP literals

A crash mid-write destroyed the previous good snapshot — the exact
scenario persistence exists to survive; writes now go tmp+rename. The
internal-network cookie filter gains 127.*/::1/169.254.* (a tampered
state file could previously hand loopback-service cookies back to the
browser), and 'state load' imports the shared filter instead of
maintaining a comment-synced copy. Test cleanup made exception-safe.

* fix(browse): server runtime — restore off the boot path, shutdown that cannot hang, watchdog that still reaps tunnels

Four review findings on the wave's own new wiring: session restore ran
before Bun.serve with sequential 15s gotos while the CLI gives up at 8s
(one slow saved URL bricked every $B command) — restore now runs in the
background after bind; the shutdown snapshot gets a 2s deadline so a
wedged page.evaluate can't hold the port forever behind the new
ack-first stop; the persistence ticker gets in-flight + shutdown gates
and is cleared before the final snapshot; and the absorbed #2565
handoff fix no longer clears the whole parent watchdog — a suppress
flag keeps the tunnel-orphan reaper alive (handoff→resume→tunnel is no
longer an unreapable internet-exposed daemon). pair-agent with consent
off now names the real remedy instead of ngrok install instructions.
Lock-acquisition edge branches (garbage pidfile, vanish-race depth cap)
pinned.

* fix(browse): telemetry defaults to off like every other surface

The persistent tier defaulted ON when the config key was absent, while
gstack-config's DEFAULTS table answers 'off' for the same question —
preamble-spawned daemons and direct $B daemons disagreed about consent.
Absent key/file now means disabled; community/anonymous enable; env
kill-switch still beats everything. Both config.yaml consumers now
share one readGstackConfigYamlKey reader. 12-case consent suite.

* fix(code-intelligence): consent that means what it says — polarity, receipts, read-only veto

Four review findings on the wave's own Phase 1 port, all red-first:
'consent <repo> no' recorded consent GRANTED (the CLI ignored the
argument and always wrote true) — yes|no is now required and garbage
records nothing; Sourcebot egress receipts claimed consented=true on
paths that never checked consent — the actual consent state is threaded
into every receipt, search is fail-closed on non-loopback, and the
liveness probe's receipt says truthfully that it sends no repo content;
repoPolicyVeto only honored the deny tier while gbrain refresh writes
pages — write-class ops now veto on read-only too, matching the sync
chokepoint, via one shared lib/gbrain-repo-policy-client.ts (win32
bash invocation, spawn-vs-unreadable error distinction) used by both
call sites. Also: source ids get a host+path hash (same-name repos no
longer collide), refresh timeout raised to 120s, availability probes
run concurrently at 3s, graphify status stops JSON.parsing 100MB graphs
for a count, and every ported file carries the fork MIT notice.
+15 tests across the two suites.

* fix(verify-gate): trust before eval, re-check on re-entry, audit every grant

The opt-in Stop hook eval'd whatever command the first CLAUDE.md up the
tree declared — any cloned repo got arbitrary shell at turn end. Now a
per-repo trust store (path+command hash, 0600) gates execution: an
untrusted or changed command never runs (exit 0 with the --trust
invocation printed), stop_hook_active re-entry re-runs the trusted
check instead of rubber-stamping (bounded at 3 blocks per episode), and
every grant appends a forensic line to
~/.gstack/security/verify-gate-trust-grants.jsonl. 20 tests, red-first.

* fix(setup): EXIT traps chain instead of clobbering; timed-out probes reap their whole tree

The Playwright-lock trap replaced the copied-bun cleanup trap and then
cleared ALL exit handling, leaking .tmp-bun-bin on every Chromium
install; and _wait_with_deadline killed only the subshell, orphaning
the wedged node→Chromium tree it exists to escape — re-creating the
#2136 pile-up on every timed-out re-run. Traps now chain; timeouts
walk pgrep -P descendants leaves-first.

* refactor(resolvers): one source for the design-doc discovery block

The #703 repo-doc-preference bash was pasted byte-identically into
three plan-review templates and a fourth copy embedded in review.ts —
drift there means plan reviews disagree about which design doc wins.
Now a {{DESIGN_DOC_DISCOVERY}} resolver; generated output is
byte-identical, so no SKILL.md changes ride along.

* fix(ship): finish the Apple upload idempotency sentence

The durable-effect contract dropped its consequence clause mid-sentence
— the instruction for what to DO when the idempotency key already
exists (treat the upload as possibly-done, never re-run it) was
missing from the one rule governing whether a binary uploads twice.

* fix(ci): SHA-pin dependency-review; the secret gate fails closed without a report

dependency-review.yml rode mutable refs (@v4 resolves to a BRANCH on
that repo) inside the one workflow whose job is supply-chain hygiene —
now commit-pinned like its siblings, with dependabot keeping the pins
fresh. gate-secret-scan.mjs crashed with an unhandled EPIPE on
oversize diffs (the designed report.oversize branch was unreachable:
the scanner emits no JSON on refusal) — the pipe write now tolerates
early exit and a missing report is an explicit fail-closed exit 1.
Oversize + broken-scanner legs pinned.

* fix(bins): Windows-safe GIT_CEILING join; next-version probes the full default-base chain

GIT_CEILING_DIRECTORIES was joined with ':' — git on Windows splits on
';' and drive letters contain ':', silently disabling the #2144
second-layer defense there; now path.delimiter. next-version's
default-base detection only tried origin/HEAD then 'main', diverging
from the canonical 4-step chain diff-scope uses — origin/main and
origin/master probes added, pinned by fixture repos.

* fix(eval-model): kinds are a literal union, not string

Record<string,string> widened EvalModelKind to string, so a typo'd
kind only failed at runtime; as const satisfies keeps the closed set
the doc comment promises.

* test: coverage backfill from the ship review

The telemetry-strip invariant only validated the sed FALLBACK while
the live jq path went unchecked — the jq del() lists are now held to
the same every-emitted-field bar, plus a behavioral pipe-through. The
context-bill nested-skill double-count fix gets a regression pin (a
revert shipped green before). The windowsHide tripwire gains
terminal-agent-control.ts — the exact file the fix commit names. The
ios-qa revoke-by-token_id branch gets its negative case: unknown ids
revoke nothing and leave live sessions alone.

* docs: SLATE_HOST no longer cites the deleted platform-detect bin

Host detection lives in the hosts/ registry via host-config-export.ts;
the doc's known-gaps list now says so instead of pointing at a bin this
branch removed.

* test(e2e): headroom for the two plan-ceo-review budget-edge tests

Both rode their 360s runner budget at the edge (main clears at 243s of
360s), and the wave legitimately adds work to the review: the evidence
directive tells the agent to probe before claiming, and the design-doc
discovery block adds bash steps. Under concurrent in-file children the
API queuing tipped all retry attempts past the ceiling — the runner then
reports $0.00/0 turns for a timed-out child, which reads like a dead
spawn but is a healthy child killed at the deadline. 540s runner / 660s
test for these two only; verified 2/2 green at 228s and 315s.

* fix(code-intelligence): gbrain search/export are consent-gated and receipted

The Sourcebot side got this in the last round; gbrain had the same hole —
search() and export() sent repo-derived query text into a possibly-remote
DATABASE_URL with no consent check and no egress receipt, bypassing the
deny-tier veto. Both now assert consent before any bytes move, receipts
record the actual consent state (never a hardcoded true), and search
receipts carry the query's sha256. gbrain stays fail-closed: the adapter
cannot see where DATABASE_URL points, so every send requires consent.
7 new tests, red-first.

* fix(make-pdf): SVG remote refs and image-set can no longer fetch offline

<svg><image href=https://…> and <use xlink:href=…> survived the gate (only
javascript: schemes were stripped from svg hrefs), and bare-string
image-set("https://…" 1x) dodged the url()-shaped neutralizer. Remote
svg hrefs rewrite to '#' (entity-decode-aware, unclosed-svg smuggle
closed) and remote image-set args neutralize to url(#). Local fragments,
local image-set, and plain <a> links pinned intact. 12 new rows, red-first.

* fix(browse): duplicate config keys read last-wins, matching gstack-config

readGstackConfigYamlKey took the FIRST match while gstack-config's get
takes the LAST — a duplicated pair_agent or telemetry line made the two
consent surfaces disagree about what the user chose.

* fix(setup): stale Chromium-install lock self-heals

The mkdir mutex had no owner: a SIGKILL'd setup left the lock behind and
every later run exited with manual rmdir instructions. The holder pid is
recorded in the lock; a dead holder is reclaimed automatically.

* fix(setup-gbrain): the code-intelligence offer gate skips when the bin is absent

The new Step 1.7 told the agent to run gstack-code-intelligence before
the path pick — on installs predating the CLI (and hermetic E2E
children) the bin doesn't exist and setup derailed before doing any
setup. The gate now probes for the bin and reports offer:false
reason:bin-absent, with explicit instructions to proceed: the user asked
for gbrain, so set up gbrain. Never block setup on an optional gate.

* test(e2e): periodic-tier repairs from the failure triage

Each fix traces to a receipt: brain-privacy-gate staged config never
reached the hermetic child (ambient GSTACK_HOME is scrubbed) and the
operator's remote-mode gbrain suppressed the gate — both now injected
per-test; ship-idempotency threw away its evidence on the timeout path
and ran a 600s budget its own subject can exceed (now 900s, evidence
captured); auto-decide-preserved gets the same headroom its sibling
plan-ceo tests got; context-skills' hides-checks scanned bash output
where an ls legitimately names old checkpoints (final-text scope now);
design names the missing section instead of a bare count and learns the
easing/duration/micro-interaction synonyms; qa-workflow's collector
afterAll gets an explicit 60s hook timeout.

* fix(eval-harness): eng-review phase boundary fires on qid-tagged questions

The Step 0 boundary only matched two prose phrases, but plan-eng-review
may legitimately reach the review phase without either — every
per-finding AskUserQuestion then counted as pre-review and the batching
regression test read 0 questions while watching the agent ask them one
by one. The boundary now also fires on the first answered question
carrying a gstack-qid:eng-review- marker. Additive only; 119 runner
unit tests green.

* chore: bump version and changelog (v1.65.0.0)

Fork port wave 2: the release-summary entry credits Sina Matian
(time-attack/gstack) and the four absorbed community PRs. TODOS gains
three review-round follow-ups (dual-write E2E, migration runner
re-offer, gbrain-adapter op coverage).

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

* fix(eval-harness): eng-review qid boundary matches the real skill-name prefix

Live qids render as gstack-qid:plan-eng-review-<slug> ({skill}-{slug}
convention); the boundary anchored eng-review- immediately after the
colon and never matched, leaving the batching counter blind while the
transcript showed per-finding questions being asked one by one.

* fix(setup-gbrain): never ask the provider question inside /setup-gbrain

Invoking /setup-gbrain IS the provider choice. Step 1.7 now records
'select gbrain' best-effort and proceeds straight to setup; the offer
ceremony is reserved for entry points where no provider was named. On
machines where the code-intelligence CLI exists, the offer:true path
was hijacking setup into the provider ceremony and the E2E child never
reached MCP registration.

* chore: file the three documented-red periodic tests as structural-repair TODOs

Sidebar trio exercises endpoints removed on every tree; ship-idempotency's
PTY child never receives its typed command; brain-privacy-gate has never
been green anywhere. Each carries its triage receipt in the entry.

* test(e2e): setup-gbrain remote — hermetic env via opts, evidence on failure, output-scoped classifier

Three separate defects stacked on this one test: the ambient
GBRAIN_MCP_TOKEN/GSTACK_HOME/PATH mutations never reached the child
(hermetic-env scrubs them by allowlist — broken since hermetic env
landed; the child correctly stopped at Step 4c with NEEDS_CONTEXT),
failures discarded the in-memory transcript so every triage started
blind, and the wrote-findings-before-asking classifier scanned the full
event stream where the child's own Read of the skill file always
contains the review-report phrase. Env now goes via opts.env, failures
dump bash commands + final text, and the classifier scans assistant
output only. Green in 67s with all seven asserts.

* test: final coverage pass — CLI rendering, revert traps, keychain probe, gbrain doc ops

The user-directed third generation pass closes the audit's remaining
tail: the code-intelligence CLI's options/status/suggest surfaces get
behavioral coverage through the fake-shim chain; brain-context-load
gains an argv-logging trap that goes red if anyone reverts the memoized
PATH scan back to the spawn probe (receipt: simulated revert failed
exactly these tests); the darwin Keychain auth branch (#1890) gets its
first free-tier tests via a PATH-shimmed security binary; and the gbrain
add/delete/export ops are pinned (body piped byte-for-byte, receipt
sha256, stdin-EOF prompt guard, PROVIDER_UNAVAILABLE degradation) —
retiring their TODOS entry.

* test: assemble the planted PEM at runtime so the fixture never trips the prepush guard

The repo's own credential guard scans pushed diffs and correctly
blocked these fixtures: the engine flags any one-line BEGIN…END
spelling regardless of body. Header, body, and footer are now joined
at runtime, so the file and every diff of it stay clean while the
scanner under test still receives the true live shape.

* docs: update project documentation for v1.65.0.0

README gains the two wave-2 CLIs (gstack-code-intelligence,
gstack-verify-gate) in the standalone-binaries table, BROWSER.md
documents BROWSE_PERSIST_STATE next to manual state save/load,
CONTRIBUTING's CI section lists the new supply-chain gates, and
CLAUDE.md's project tree reflects lib/code-intelligence/ and the
added workflows.

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

* docs: apply cross-model doc-review fixes for v1.65.0.0

Findings from the release doc review, verified against source:
verify-gate's README row gains the actual install one-liner (setup
never registers the Stop hook; test/verify-gate.test.ts pins that)
and the 3-blocked-re-entries yield behavior; code-intelligence's row
gains the suggest subcommand and the search-side consent gate;
CONTRIBUTING scopes the SHA-pin claim to the supply-chain workflows
and widens the dependency-review trigger; BROWSER.md's restore-time
cookie drop list matches isInternalCookieDomain; CLAUDE.md's
workflows comment stops implying six workflows are all of them.

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

* docs: CHANGELOG accuracy pass — scope the SHA-pin claim, restore-time cookie filter, exact test counts

* test: env restore runs per-test, not per-suite — the leak that failed 30 strangers

gstack-memory-helpers saved HOME/GSTACK_HOME/PATH in beforeEach but
restored in afterAll, so the last beforeEach's snapshot won and a
gstack-test-engine temp dir leaked into every later file in the same
process: gstack-config read the wrong store, make-pdf's child resolved
Chromium under the temp cache, update-check and artifacts-init lost
their real homes. afterAll is now afterEach; the config and
update-check harnesses also strip GSTACK_HOME/GSTACK_STATE_ROOT from
child env as a belt.

* fix(browse): restore the #1846 start-timeout resolution the merge dropped

The v1.64.1.0 merge kept this branch's lock design in cli.ts and
silently lost main's resolveStartTimeout + late health re-check while
their test survived — ported both back in alongside the kept design.

* test: adapt main's diagnostics tests to the merged designs

cli-lock asserts typed ServerLockError (errno + lock path) instead of
the log-and-return shape the merge didn't keep, dropping only the one
duplicate of server-lock-errors coverage; the liveness tripwire exempts
error-handling.ts as the sanctioned tasklist site; snapshot and
compare-board wrappers pass the now-mandatory browser-manager arg;
background.js's test pins that the retired sidebar-command type is
rejected pre-gate with no response fields.

* chore: gitignore the gen-accessors tool's SPM build output

skill-e2e-ios-swift-build compiles the Swift package in place, leaving
.build/ (2,800+ files) and Package.resolved untracked after every
periodic run — the workspace read as ~100 dirty changes with a clean
tree. Same class as the dist/ binaries: build output, never committed.

* test(browse): subprocess budget for the polyfill suite on Windows CI

Every test here spawnSync's a node child; cold-start on the Windows
runner (AV scan, first node.exe touch) blew bun's 5s default by 7ms on
a 50ms sleep test. File-level 20s default — subprocess budget, not
assertion looseness.

* test: make the Darwin migration path and the query-timeout SKIP deterministic on Linux CI

The v1.65 migration suite relied on the host being macOS — on the
ubicloud runner the script's uname gate early-exited every test with
empty output; a Darwin uname shim in the shared setup runs the real
path everywhere (the non-Darwin test still overrides it with Linux).
The 1ms-budget brain-context test assumed 1ms is always too short; the
runner's fake gbrain answered in 0ms and no SKIP printed — the fake now
sleeps 300ms so the timeout is a certainty, while --version stays
instant for the detection assertion.

---------

Co-authored-by: Gawie van Blerk <gawievanblerk@gmail.com>
Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Shawn Reddy <19191746+Screddyice@users.noreply.github.com>
Co-authored-by: Jake Wilk <jwilk@highlinerepartners.com>
Co-authored-by: Jerry Nichols <jerrynicholsai@users.noreply.github.com>
2026-08-15 11:42:19 -07:00

3498 lines
152 KiB
TypeScript

import { describe, test, expect, beforeAll } from 'bun:test';
import { COMMAND_DESCRIPTIONS } from '../browse/src/commands';
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
// Carved-skill aware (v2 plan T9): ship is now a skeleton SKILL.md + sections/*.md.
// Read the union so assertions about content that MOVED into a section still pass.
// The skeleton is a subset of the union, so skeleton-only assertions also hold,
// and negative assertions stay safe (the absent phrases live in neither file).
function readSkillUnion(skill: string): string {
let t = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
const secDir = path.join(ROOT, skill, 'sections');
if (fs.existsSync(secDir)) {
for (const f of fs.readdirSync(secDir).sort()) {
if (f.endsWith('.md')) t += '\n' + fs.readFileSync(path.join(secDir, f), 'utf-8');
}
}
return t;
}
function readShipUnion(): string {
return readSkillUnion('ship');
}
function extractDescription(content: string): string {
const fmEnd = content.indexOf('\n---', 4);
expect(fmEnd).toBeGreaterThan(0);
const frontmatter = content.slice(4, fmEnd);
const lines = frontmatter.split('\n');
let description = '';
let inDescription = false;
const descLines: string[] = [];
for (const line of lines) {
if (line.match(/^description:\s*\|?\s*$/)) {
inDescription = true;
continue;
}
if (line.match(/^description:\s*\S/)) {
return line.replace(/^description:\s*/, '').trim();
}
if (inDescription) {
if (line === '' || line.match(/^\s/)) {
descLines.push(line.replace(/^ /, ''));
} else {
break;
}
}
}
if (descLines.length > 0) {
description = descLines.join('\n').trim();
}
return description;
}
function extractMarkdownSection(content: string, heading: string): string {
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const startMatch = content.match(new RegExp(`^${escaped}.*$`, 'm'));
expect(startMatch?.index).toBeDefined();
const start = startMatch!.index!;
const afterHeading = start + startMatch![0].length;
const nextSection = content.slice(afterHeading).match(/\n## /);
const end = nextSection?.index === undefined
? content.length
: afterHeading + nextSection.index;
return content.slice(start, end).trim();
}
function extractPreambleBeforeWorkflow(content: string, workflowMarkers: string[]): string {
const markerIndexes = workflowMarkers
.map(marker => content.indexOf(marker))
.filter(index => index >= 0);
expect(markerIndexes.length).toBeGreaterThan(0);
return content.slice(0, Math.min(...markerIndexes));
}
function isRepoRootSymlink(candidateDir: string): boolean {
try {
return fs.realpathSync(candidateDir) === fs.realpathSync(ROOT);
} catch {
return false;
}
}
// Dynamic template discovery — matches the generator's findTemplates() behavior.
// New skills automatically get test coverage without updating a static list.
const ALL_SKILLS = (() => {
const skills: Array<{ dir: string; name: string }> = [];
if (fs.existsSync(path.join(ROOT, 'SKILL.md.tmpl'))) {
skills.push({ dir: '.', name: 'root gstack' });
}
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
if (fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) {
skills.push({ dir: entry.name, name: entry.name });
}
}
return skills;
})();
// hosts/claude.ts generation.skipSkills entries would filter here; the set is
// currently empty (the /claude outside-voice template was removed).
// The claude host deliberately skips some skills (skipSkills — e.g. the
// /claude outside-voice skill exists only for non-Claude hosts), so those
// dirs have a SKILL.md.tmpl but no generated claude-host SKILL.md on a fresh
// checkout. Every generated-file assertion must exclude them or it is red on
// every clean clone (it was, invisibly, until the free suite ran in CI).
import { getHostConfig as __getHostConfig } from '../hosts/index';
const CLAUDE_SKIPPED = new Set(__getHostConfig('claude').generation.skipSkills ?? []);
const CLAUDE_GENERATED_SKILLS = ALL_SKILLS.filter(s => !CLAUDE_SKIPPED.has(s.dir));
describe('gen-skill-docs', () => {
test('generated SKILL.md contains all command categories', () => {
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
const categories = new Set(Object.values(COMMAND_DESCRIPTIONS).map(d => d.category));
for (const cat of categories) {
expect(content).toContain(`### ${cat}`);
}
});
test('generated SKILL.md contains all commands', () => {
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
const display = meta.usage || cmd;
expect(content).toContain(display);
}
});
test('command table is sorted alphabetically within categories', () => {
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
// Extract command names from the Navigation section as a test
const navSection = content.match(/### Navigation\n\|.*\n\|.*\n([\s\S]*?)(?=\n###|\n## )/);
expect(navSection).not.toBeNull();
const rows = navSection![1].trim().split('\n');
const commands = rows.map(r => {
const match = r.match(/\| `(\w+)/);
return match ? match[1] : '';
}).filter(Boolean);
const sorted = [...commands].sort();
expect(commands).toEqual(sorted);
});
test('generated header is present in SKILL.md', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('AUTO-GENERATED from SKILL.md.tmpl');
expect(content).toContain('Regenerate: bun run gen:skill-docs');
});
test('generated header is present in browse/SKILL.md', () => {
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
expect(content).toContain('AUTO-GENERATED from SKILL.md.tmpl');
});
test('snapshot flags section contains all flags', () => {
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
for (const flag of SNAPSHOT_FLAGS) {
expect(content).toContain(flag.short);
expect(content).toContain(flag.description);
}
});
test('every skill has a SKILL.md.tmpl template', () => {
for (const skill of ALL_SKILLS) {
const tmplPath = path.join(ROOT, skill.dir, 'SKILL.md.tmpl');
expect(fs.existsSync(tmplPath)).toBe(true);
}
});
test('every skill has a generated SKILL.md with auto-generated header', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const mdPath = path.join(ROOT, skill.dir, 'SKILL.md');
expect(fs.existsSync(mdPath)).toBe(true);
const content = fs.readFileSync(mdPath, 'utf-8');
expect(content).toContain('AUTO-GENERATED from SKILL.md.tmpl');
expect(content).toContain('Regenerate: bun run gen:skill-docs');
}
});
// #1778: strict YAML parsers (Codex/OpenAI skill loading) reject frontmatter
// whose plain `description:` scalar contains an interior ": " (read as a nested
// mapping). Parse EVERY generated frontmatter block with a strict YAML parser,
// not just string-check that name:/description: exist.
function frontmatterBlock(content: string): string {
expect(content.startsWith('---\n')).toBe(true);
const end = content.indexOf('\n---', 4);
expect(end).toBeGreaterThan(0);
return content.slice(4, end);
}
test('every generated SKILL.md frontmatter parses as strict YAML', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
const fm = frontmatterBlock(content);
let parsed: any;
expect(() => { parsed = Bun.YAML.parse(fm); },
`frontmatter for ${skill.dir} must be valid YAML`).not.toThrow();
expect(typeof parsed?.name).toBe('string');
expect(typeof parsed?.description).toBe('string');
}
});
test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const mdPath = path.join(agentsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(mdPath)) continue;
const fm = frontmatterBlock(fs.readFileSync(mdPath, 'utf-8'));
expect(() => Bun.YAML.parse(fm),
`Codex frontmatter for ${entry.name} must be valid YAML`).not.toThrow();
}
});
test(`every generated SKILL.md description stays within ${MAX_SKILL_DESCRIPTION_LENGTH} chars`, () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
const description = extractDescription(content);
expect(description.length).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH);
}
});
test(`every Codex SKILL.md description stays within ${MAX_SKILL_DESCRIPTION_LENGTH} chars`, () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return; // skip if not generated
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(agentsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
const description = extractDescription(content);
expect(description.length).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH);
}
});
test('every Codex SKILL.md description stays under 900-char warning threshold', () => {
const WARN_THRESHOLD = 900;
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return;
const violations: string[] = [];
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(agentsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
const description = extractDescription(content);
if (description.length > WARN_THRESHOLD) {
violations.push(`${entry.name}: ${description.length} chars (limit ${MAX_SKILL_DESCRIPTION_LENGTH}, ${MAX_SKILL_DESCRIPTION_LENGTH - description.length} remaining)`);
}
}
expect(violations).toEqual([]);
});
test('package.json version matches VERSION file', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
const version = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf-8').trim();
expect(pkg.version).toBe(version);
});
test('generated files are fresh (match --dry-run)', () => {
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--dry-run'], {
cwd: ROOT,
stdout: 'pipe',
stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
// Every skill should be FRESH
for (const skill of CLAUDE_GENERATED_SKILLS) {
const file = skill.dir === '.' ? 'SKILL.md' : `${skill.dir}/SKILL.md`;
expect(output).toContain(`FRESH: ${file}`);
}
expect(output).not.toContain('STALE');
});
test('no generated SKILL.md contains unresolved placeholders', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
const unresolved = content.match(/\{\{[A-Z_]+\}\}/g);
expect(unresolved).toBeNull();
}
});
test('templates contain placeholders', () => {
// P2 (v1.2.0): the root template is a pure router — only {{PREAMBLE}}.
// The browse command/snapshot placeholders live in browse/SKILL.md.tmpl now.
const rootTmpl = fs.readFileSync(path.join(ROOT, 'SKILL.md.tmpl'), 'utf-8');
expect(rootTmpl).toContain('{{PREAMBLE}}');
expect(rootTmpl).not.toContain('{{COMMAND_REFERENCE}}');
expect(rootTmpl).not.toContain('{{SNAPSHOT_FLAGS}}');
const browseTmpl = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md.tmpl'), 'utf-8');
expect(browseTmpl).toContain('{{COMMAND_REFERENCE}}');
expect(browseTmpl).toContain('{{SNAPSHOT_FLAGS}}');
expect(browseTmpl).toContain('{{PREAMBLE}}');
});
test('generated SKILL.md contains operational self-improvement (replaced contributor mode)', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).not.toContain('Contributor Mode');
expect(content).not.toContain('gstack_contributor');
expect(content).not.toContain('contributor-logs');
expect(content).toContain('Operational Self-Improvement');
expect(content).toContain('gstack-learnings-log');
expect(content).toContain('gstack-learnings-search --limit 3');
});
test('generated SKILL.md with LEARNINGS_LOG contains operational type', () => {
// Check a skill that has LEARNINGS_LOG (e.g., review)
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('operational');
});
test('generated SKILL.md contains session awareness', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('_SESSIONS');
expect(content).toContain('RECOMMENDATION');
});
test('generated SKILL.md contains branch detection', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('_BRANCH');
expect(content).toContain('git branch --show-current');
});
// #2001: update_check: false silences the binary but the upgrade-handling
// instruction prose used to ship unconditionally. Every skill that carries
// the runtime config-echo cluster must (a) echo UPDATE_CHECK so the
// instruction layer can read it, and (b) gate the UPGRADE_AVAILABLE /
// JUST_UPGRADED prose on it — the same echo-then-gate convention every other
// flag (PROACTIVE, SKILL_PREFIX, EXPLAIN_LEVEL, QUESTION_TUNING) follows.
test('update_check opt-out gates preamble echo and upgrade-handling prose (issue #2001)', () => {
let checked = 0;
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
// Scope: only skills that render the runtime config-echo cluster.
if (!content.includes('echo "QUESTION_TUNING: $_QUESTION_TUNING"')) continue;
checked++;
expect(content, `${skill.dir} must echo UPDATE_CHECK`).toContain('echo "UPDATE_CHECK: $_UPDATE_CHECK"');
expect(content, `${skill.dir} must read update_check config`).toContain('_UPDATE_CHECK=$(');
// Whenever the upgrade-handling prose ships, it must gate on the flag.
if (content.includes('UPGRADE_AVAILABLE <old> <new>')) {
expect(content, `${skill.dir} upgrade prose must gate on UPDATE_CHECK`)
.toContain('If `UPDATE_CHECK` is `"false"`');
}
}
// Guard against the scope filter silently matching nothing.
expect(checked).toBeGreaterThan(0);
});
test('tier 2+ skills contain ELI10 simplification rules (AskUserQuestion format)', () => {
// Root SKILL.md is tier 1 (no AskUserQuestion format). Check a tier 2+ skill instead.
// v1.7.0.0 Pros/Cons format uses "ELI10 (ALWAYS)" rather than "Simplify (ELI10".
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
expect(content).toContain('ELI10');
expect(content).toContain('plain English');
expect(content).toContain('not function names');
});
test('tier 1 skills do NOT contain AskUserQuestion format', () => {
// Use benchmark (tier 1) instead of root — root SKILL.md gets overwritten by Codex test setup
const content = fs.readFileSync(path.join(ROOT, 'benchmark', 'SKILL.md'), 'utf-8');
expect(content).not.toContain('## AskUserQuestion Format');
expect(content).not.toContain('## Completeness Principle');
});
test('generated SKILL.md contains telemetry line', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('skill-usage.jsonl');
expect(content).toContain('~/.gstack/analytics');
});
test('plan-review generated preambles stay under the Option A budget', () => {
const reviewSkills = [
{
path: path.join(ROOT, 'plan-ceo-review', 'SKILL.md'),
markers: ['# Mega Plan Review Mode', '## Step 0: Detect platform and base branch'],
},
{
path: path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
markers: ['# Plan Review Mode'],
},
];
// Plan skills carry the same preamble surface as other tier-≥2 skills
// (Artifacts Sync, Context Recovery, Routing Injection are load-bearing
// functionality, not optional). Budget is set to current size + small
// headroom; ratchet down if a future slim trims real bytes.
// Ratcheted from 33000 → 35000 when the gbrain context-load block was
// added (per /sync-gbrain plan §4). Ratcheted 35000 → 36500 in v1.27.0.0
// when generate-brain-sync-block.ts gained the gbrain_mcp_mode probe +
// remote-mode ARTIFACTS_SYNC status line (Path 4 of /setup-gbrain).
// Ratcheted 36500 → 39000 in the contributor wave when #1205 added the
// \\u-escape CJK rule (rule 12 + self-check item) to the AskUserQuestion
// preamble.
// Ratcheted 39000 → 40000 in plan-tune cathedral T14: question-tuning
// resolver gained the <gstack-qid:...> marker convention + the
// (recommended) label requirement (D2 + D18 — both load-bearing for
// hook enforcement). Adds ~700 bytes.
// Ratcheted 40000 → 60000 in v1.52.0.0 cap audit: ~20K headroom so
// future preamble adds don't trip the gate on each PR. Real runaway
// (preamble doubling) still trips; normal scope growth doesn't.
for (const skill of reviewSkills) {
const content = fs.readFileSync(skill.path, 'utf-8');
const preamble = extractPreambleBeforeWorkflow(content, skill.markers);
expect(Buffer.byteLength(preamble, 'utf-8')).toBeLessThan(60_000);
}
});
test('voice and writing-style preamble sections stay compact', () => {
const content = readSkillUnion('plan-eng-review'); // carved: review body moved to section
const voice = extractMarkdownSection(content, '## Voice');
const writingStyle = extractMarkdownSection(content, '## Writing Style');
expect(Buffer.byteLength(voice, 'utf-8')).toBeLessThan(3_000);
expect(Buffer.byteLength(writingStyle, 'utf-8')).toBeLessThan(2_000);
});
test('slim voice section preserves the gstack voice contract', () => {
const content = readSkillUnion('plan-eng-review'); // carved: review body moved to section
const voice = extractMarkdownSection(content, '## Voice');
expect(voice).toMatch(/lead with the point|direct/i);
expect(voice).toMatch(/file|function|line|command|real numbers/i);
expect(voice).toMatch(/user.*outcome|user.*experience|real user/i);
expect(voice).toMatch(/corporate|academic|PR|hype/i);
expect(voice).toMatch(/AI vocabulary|delve|crucial|robust/i);
expect(voice).toMatch(/user decides|user.*context|sovereignty|recommendation, not a decision/i);
});
test('preamble .pending-* glob is zsh-safe (uses find, not shell glob)', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
if (!content.includes('.pending-')) continue;
// Must NOT have a bare shell glob ".pending-*" outside of find's -name argument
expect(content).not.toMatch(/for _PF in [^\n]*\/\.pending-\*/);
// Must use find to avoid zsh NOMATCH error on glob expansion
expect(content).toContain("find ~/.gstack/analytics -maxdepth 1 -name '.pending-*'");
}
});
test('bash blocks with shell globs are zsh-safe (setopt guard or find)', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
const bashBlocks = [...content.matchAll(/```bash\n([\s\S]*?)```/g)].map(m => m[1]);
for (const block of bashBlocks) {
const lines = block.split('\n');
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith('#')) continue;
if (!trimmed.includes('*')) continue;
// Skip lines where * is inside find -name, git pathspecs, or $(find)
if (/\bfind\b/.test(trimmed)) continue;
if (/\bgit\b/.test(trimmed)) continue;
if (/\$\(find\b/.test(trimmed)) continue;
// Check 1: "for VAR in <glob>" must use $(find ...) — caught above by the
// $(find check, so any surviving for-in with a glob pattern is a violation
if (/\bfor\s+\w+\s+in\b/.test(trimmed) && /\*\./.test(trimmed)) {
throw new Error(
`Unsafe for-in glob in ${skill.dir}/SKILL.md: "${trimmed}". ` +
`Use \`for f in $(find ... -name '*.ext')\` for zsh compatibility.`
);
}
// Check 2: ls/cat/rm/grep with glob file args must have setopt guard
const isGlobCmd = /\b(?:ls|cat|rm|grep)\b/.test(trimmed) &&
/(?:\/\*[a-z.*]|\*\.[a-z])/.test(trimmed);
if (isGlobCmd) {
expect(block).toContain('setopt +o nomatch');
}
}
}
}
});
test('preamble-using skills have correct skill name in telemetry', () => {
const PREAMBLE_SKILLS = [
{ dir: '.', name: 'gstack' },
{ dir: 'ship', name: 'ship' },
{ dir: 'review', name: 'review' },
{ dir: 'qa', name: 'qa' },
{ dir: 'retro', name: 'retro' },
];
for (const skill of PREAMBLE_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
expect(content).toContain(`"skill":"${skill.name}"`);
}
});
test('qa and qa-only templates use QA_METHODOLOGY placeholder', () => {
const qaTmpl = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md.tmpl'), 'utf-8');
expect(qaTmpl).toContain('{{QA_METHODOLOGY}}');
const qaOnlyTmpl = fs.readFileSync(path.join(ROOT, 'qa-only', 'SKILL.md.tmpl'), 'utf-8');
expect(qaOnlyTmpl).toContain('{{QA_METHODOLOGY}}');
});
test('QA_METHODOLOGY appears expanded in both qa and qa-only generated files', () => {
const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
const qaOnlyContent = fs.readFileSync(path.join(ROOT, 'qa-only', 'SKILL.md'), 'utf-8');
// Both should contain the health score rubric
expect(qaContent).toContain('Health Score Rubric');
expect(qaOnlyContent).toContain('Health Score Rubric');
// Both should contain framework guidance
expect(qaContent).toContain('Framework-Specific Guidance');
expect(qaOnlyContent).toContain('Framework-Specific Guidance');
// Both should contain the important rules
expect(qaContent).toContain('Important Rules');
expect(qaOnlyContent).toContain('Important Rules');
// Both should contain the 6 phases
expect(qaContent).toContain('Phase 1');
expect(qaOnlyContent).toContain('Phase 1');
expect(qaContent).toContain('Phase 6');
expect(qaOnlyContent).toContain('Phase 6');
});
test('qa-only has no-fix guardrails', () => {
const qaOnlyContent = fs.readFileSync(path.join(ROOT, 'qa-only', 'SKILL.md'), 'utf-8');
expect(qaOnlyContent).toContain('Never fix bugs');
expect(qaOnlyContent).toContain('NEVER fix anything');
// Should not have Edit, Glob, or Grep in allowed-tools.
// Scope to frontmatter (between the first two --- lines) — the body can
// legitimately mention these tool names in prose (e.g., Claude model
// overlay says "prefer Read, Edit, Write, Glob, Grep over Bash").
const fmMatch = qaOnlyContent.match(/^---\n([\s\S]*?)\n---/);
expect(fmMatch).not.toBeNull();
const frontmatter = fmMatch![1];
expect(frontmatter).toMatch(/allowed-tools:/);
expect(frontmatter).not.toMatch(/allowed-tools:[\s\S]*?- Edit/);
expect(frontmatter).not.toMatch(/allowed-tools:[\s\S]*?- Glob/);
expect(frontmatter).not.toMatch(/allowed-tools:[\s\S]*?- Grep/);
});
test('qa has fix-loop tools and phases', () => {
const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
// Should have Edit, Glob, Grep in allowed-tools
expect(qaContent).toContain('Edit');
expect(qaContent).toContain('Glob');
expect(qaContent).toContain('Grep');
// Should have fix-loop phases
expect(qaContent).toContain('Phase 7');
expect(qaContent).toContain('Phase 8');
expect(qaContent).toContain('Fix Loop');
expect(qaContent).toContain('Triage');
expect(qaContent).toContain('WTF');
});
});
describe('BASE_BRANCH_DETECT resolver', () => {
// Find a generated SKILL.md that uses the placeholder (ship is guaranteed to)
const shipContent = readShipUnion();
test('resolver output contains PR base detection command', () => {
expect(shipContent).toContain('gh pr view --json baseRefName');
});
test('resolver output contains repo default branch detection command', () => {
expect(shipContent).toContain('gh repo view --json defaultBranchRef');
});
test('resolver output contains fallback to main', () => {
expect(shipContent).toMatch(/fall\s*back\s+to\s+`main`/i);
});
test('resolver output uses "the base branch" phrasing', () => {
expect(shipContent).toContain('the base branch');
});
test('resolver output contains GitLab CLI commands', () => {
expect(shipContent).toContain('glab');
});
test('resolver output contains git-native fallback', () => {
expect(shipContent).toContain('git symbolic-ref');
});
test('resolver output mentions GitLab platform', () => {
expect(shipContent).toMatch(/gitlab/i);
});
});
describe('GitLab support in generated skills', () => {
const retroContent = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
const shipSkillContent = readShipUnion();
test('retro contains GitLab MR number extraction', () => {
expect(retroContent).toContain('[#!]');
});
test('retro uses BASE_BRANCH_DETECT (contains glab)', () => {
expect(retroContent).toContain('glab');
});
test('ship contains glab mr create', () => {
expect(shipSkillContent).toContain('glab mr create');
});
test('ship checks .gitlab-ci.yml', () => {
expect(shipSkillContent).toContain('.gitlab-ci.yml');
});
});
/**
* Quality evals — catch description regressions.
*
* These test that generated output is *useful for an AI agent*,
* not just structurally valid. Each test targets a specific
* regression we actually shipped and caught in review.
*/
describe('description quality evals', () => {
// Regression: snapshot flags lost value hints (-d <N>, -s <sel>, -o <path>)
test('snapshot flags with values include value hints in output', () => {
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
for (const flag of SNAPSHOT_FLAGS) {
if (flag.takesValue) {
expect(flag.valueHint).toBeDefined();
expect(content).toContain(`${flag.short} ${flag.valueHint}`);
}
}
});
// Regression: "is" lost the valid states enum
test('is command lists valid state values', () => {
const desc = COMMAND_DESCRIPTIONS['is'].description;
for (const state of ['visible', 'hidden', 'enabled', 'disabled', 'checked', 'editable', 'focused']) {
expect(desc).toContain(state);
}
});
// Regression: "press" lost common key examples
test('press command lists example keys', () => {
const desc = COMMAND_DESCRIPTIONS['press'].description;
expect(desc).toContain('Enter');
expect(desc).toContain('Tab');
expect(desc).toContain('Escape');
});
// Regression: "console" lost --errors filter note
test('console command describes --errors behavior', () => {
const desc = COMMAND_DESCRIPTIONS['console'].description;
expect(desc).toContain('--errors');
});
// Regression: snapshot -i lost "@e refs" context
test('snapshot -i mentions @e refs', () => {
const flag = SNAPSHOT_FLAGS.find(f => f.short === '-i')!;
expect(flag.description).toContain('@e');
});
// Regression: snapshot -C lost "@c refs" context
test('snapshot -C mentions @c refs', () => {
const flag = SNAPSHOT_FLAGS.find(f => f.short === '-C')!;
expect(flag.description).toContain('@c');
});
// Guard: every description must be at least 8 chars (catches empty or stub descriptions)
test('all command descriptions have meaningful length', () => {
for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
expect(meta.description.length).toBeGreaterThanOrEqual(8);
}
});
// Guard: snapshot flag descriptions must be at least 10 chars
test('all snapshot flag descriptions have meaningful length', () => {
for (const flag of SNAPSHOT_FLAGS) {
expect(flag.description.length).toBeGreaterThanOrEqual(10);
}
});
// Guard: descriptions must not contain pipe (breaks markdown table cells)
// Usage strings are backtick-wrapped in the table so pipes there are safe.
test('no command description contains pipe character', () => {
for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
expect(meta.description).not.toContain('|');
}
});
// Guard: generated output uses → not ->
test('generated SKILL.md uses unicode arrows', () => {
// P2 (v1.2.0): the browse body moved out of the top-level router into
// browse/SKILL.md. Guard arrow style on the browse body (sliced from its
// H1 so the auto-generated `-->` header comments are excluded).
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
const body = content.slice(content.indexOf('# browse: QA Testing'));
expect(body).toContain('→');
expect(body).not.toContain('->');
});
});
describe('REVIEW_DASHBOARD resolver', () => {
const REVIEW_SKILLS = ['plan-ceo-review', 'plan-eng-review', 'plan-design-review'];
for (const skill of REVIEW_SKILLS) {
test(`review dashboard appears in ${skill} generated file`, () => {
const content = readSkillUnion(skill); // carved skills: union skeleton + sections
expect(content).toContain('gstack-review');
expect(content).toContain('REVIEW READINESS DASHBOARD');
});
}
test('review dashboard appears in ship generated file', () => {
const content = readShipUnion();
expect(content).toContain('reviews.jsonl');
expect(content).toContain('REVIEW READINESS DASHBOARD');
});
test('dashboard treats review as a valid Eng Review source', () => {
const content = readShipUnion();
expect(content).toContain('plan-eng-review, review, plan-design-review');
expect(content).toContain('`review` (diff-scoped pre-landing review)');
expect(content).toContain('`plan-eng-review` (plan-stage architecture review)');
expect(content).toContain('from either \\`review\\` or \\`plan-eng-review\\`');
});
test('shared dashboard propagates review source to plan-eng-review', () => {
const content = readSkillUnion('plan-eng-review'); // carved: review body moved to section
expect(content).toContain('plan-eng-review, review, plan-design-review');
expect(content).toContain('`review` (diff-scoped pre-landing review)');
});
test('resolver output contains key dashboard elements', () => {
const content = readSkillUnion('plan-ceo-review'); // carved: dashboard moved to section
expect(content).toContain('VERDICT');
expect(content).toContain('CLEARED');
expect(content).toContain('Eng Review');
expect(content).toContain('7 days');
expect(content).toContain('Design Review');
expect(content).toContain('skip_eng_review');
});
test('dashboard bash block includes git HEAD for staleness detection', () => {
const content = readSkillUnion('plan-ceo-review'); // carved: dashboard moved to section
expect(content).toContain('git rev-parse --short HEAD');
expect(content).toContain('---HEAD---');
});
test('dashboard includes staleness detection prose', () => {
const content = readSkillUnion('plan-ceo-review'); // carved: dashboard moved to section
expect(content).toContain('Staleness detection');
expect(content).toContain('commit');
});
for (const skill of REVIEW_SKILLS) {
test(`${skill} contains review chaining section`, () => {
const content = readSkillUnion(skill); // carved skills: union skeleton + sections
expect(content).toContain('Review Chaining');
});
test(`${skill} Review Log includes commit field`, () => {
const content = readSkillUnion(skill); // carved skills: union skeleton + sections
expect(content).toContain('"commit"');
});
}
test('plan-ceo-review chaining mentions eng and design reviews', () => {
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
expect(content).toContain('/plan-eng-review');
expect(content).toContain('/plan-design-review');
});
test('plan-eng-review chaining mentions design and ceo reviews', () => {
const content = readSkillUnion('plan-eng-review'); // carved: review body moved to section
expect(content).toContain('/plan-design-review');
expect(content).toContain('/plan-ceo-review');
});
test('plan-design-review chaining mentions eng, ceo, and design skills', () => {
const content = readSkillUnion('plan-design-review');
expect(content).toContain('/plan-eng-review');
expect(content).toContain('/plan-ceo-review');
expect(content).toContain('/design-shotgun');
expect(content).toContain('/design-html');
});
test('ship does NOT contain review chaining', () => {
const content = readShipUnion();
expect(content).not.toContain('Review Chaining');
});
});
// ─── Test Coverage Audit Resolver Tests ─────────────────────
describe('TEST_COVERAGE_AUDIT placeholders', () => {
const planSkill = readSkillUnion('plan-eng-review'); // carved
const shipSkill = readShipUnion();
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
test('plan and ship modes share codepath tracing methodology', () => {
// Review mode delegates test coverage to the Testing specialist subagent (Review Army)
const sharedPhrases = [
'Trace data flow',
'Diagram the execution',
'Quality scoring rubric',
'★★★',
'★★',
'GAP',
];
for (const phrase of sharedPhrases) {
expect(planSkill).toContain(phrase);
expect(shipSkill).toContain(phrase);
}
// Plan mode traces the plan, not a git diff
expect(planSkill).toContain('Trace every codepath in the plan');
expect(planSkill).not.toContain('git diff origin');
// Ship mode traces the diff
expect(shipSkill).toContain('Trace every codepath changed');
});
test('review mode uses Review Army for specialist dispatch', () => {
expect(reviewSkill).toContain('Review Army');
expect(reviewSkill).toContain('Specialist Dispatch');
expect(reviewSkill).toContain('testing.md');
});
test('plan and ship modes include E2E decision matrix', () => {
// Review mode delegates to Testing specialist
for (const skill of [planSkill, shipSkill]) {
expect(skill).toContain('E2E Test Decision Matrix');
expect(skill).toContain('→E2E');
expect(skill).toContain('→EVAL');
}
});
test('plan and ship modes include regression rule', () => {
// Review mode delegates to Testing specialist
for (const skill of [planSkill, shipSkill]) {
expect(skill).toContain('REGRESSION RULE');
expect(skill).toContain('IRON RULE');
}
});
test('plan and ship modes include test framework detection', () => {
// Review mode delegates to Testing specialist
for (const skill of [planSkill, shipSkill]) {
expect(skill).toContain('Test Framework Detection');
expect(skill).toContain('CLAUDE.md');
}
});
test('plan mode adds tests to plan + includes test plan artifact', () => {
expect(planSkill).toContain('Add missing tests to the plan');
expect(planSkill).toContain('eng-review-test-plan');
expect(planSkill).toContain('Test Plan Artifact');
});
test('ship mode auto-generates tests + includes before/after count', () => {
expect(shipSkill).toContain('Generate tests for uncovered paths');
expect(shipSkill).toContain('Before/after test count');
expect(shipSkill).toContain('30 code paths max');
expect(shipSkill).toContain('ship-test-plan');
});
test('review mode uses Fix-First + Review Army for specialist coverage', () => {
expect(reviewSkill).toContain('Fix-First');
expect(reviewSkill).toContain('INFORMATIONAL');
// Review Army handles test coverage via Testing specialist subagent
expect(reviewSkill).toContain('Review Army');
expect(reviewSkill).toContain('Testing');
});
test('plan mode does NOT include ship-specific content', () => {
expect(planSkill).not.toContain('Before/after test count');
expect(planSkill).not.toContain('30 code paths max');
expect(planSkill).not.toContain('ship-test-plan');
});
test('review mode does NOT include test plan artifact', () => {
expect(reviewSkill).not.toContain('Test Plan Artifact');
expect(reviewSkill).not.toContain('eng-review-test-plan');
expect(reviewSkill).not.toContain('ship-test-plan');
});
test('review/specialists/ directory has all expected checklist files', () => {
const specDir = path.join(ROOT, 'review', 'specialists');
const expected = [
'testing.md',
'maintainability.md',
'security.md',
'performance.md',
'data-migration.md',
'api-contract.md',
'red-team.md',
];
for (const f of expected) {
expect(fs.existsSync(path.join(specDir, f))).toBe(true);
}
});
test('each specialist file has standard header with scope and output format', () => {
const specDir = path.join(ROOT, 'review', 'specialists');
const files = fs.readdirSync(specDir).filter(f => f.endsWith('.md'));
for (const f of files) {
const content = fs.readFileSync(path.join(specDir, f), 'utf-8');
// All specialist files must have Scope and Output/JSON in header
expect(content).toContain('Scope:');
expect(content.toLowerCase()).toMatch(/output|json/);
// Must define NO FINDINGS behavior
expect(content).toContain('NO FINDINGS');
}
});
// Regression guard: ship output contains key phrases from before the refactor
test('ship SKILL.md regression guard — key phrases preserved', () => {
const regressionPhrases = [
'100% coverage is the goal',
'ASCII coverage diagram',
'processPayment',
'refundPayment',
'billing.test.ts',
'checkout.e2e.ts',
'COVERAGE:',
'QUALITY:',
'GAPS:',
'Code paths:',
'User flows:',
];
for (const phrase of regressionPhrases) {
expect(shipSkill).toContain(phrase);
}
});
test('ship SKILL.md contains review army specialist dispatch', () => {
expect(shipSkill).toContain('Specialist Dispatch');
expect(shipSkill).toContain('Step 9.1');
expect(shipSkill).toContain('Step 9.2');
});
test('ship SKILL.md contains cross-review finding dedup', () => {
expect(shipSkill).toContain('Cross-review finding dedup');
expect(shipSkill).toContain('Step 9.3');
});
test('ship SKILL.md contains re-run idempotency behavior', () => {
expect(shipSkill).toContain('Re-run behavior (idempotency)');
expect(shipSkill).toContain('Never skip a verification step');
});
});
// --- {{TEST_FAILURE_TRIAGE}} resolver tests ---
describe('TEST_FAILURE_TRIAGE resolver', () => {
const shipSkill = readShipUnion();
test('contains all 4 triage steps', () => {
expect(shipSkill).toContain('Step T1: Classify each failure');
expect(shipSkill).toContain('Step T2: Handle in-branch failures');
expect(shipSkill).toContain('Step T3: Handle pre-existing failures');
expect(shipSkill).toContain('Step T4: Execute the chosen action');
});
test('T1 includes classification criteria (in-branch vs pre-existing)', () => {
expect(shipSkill).toContain('In-branch');
expect(shipSkill).toContain('Likely pre-existing');
expect(shipSkill).toContain('git diff origin/');
});
test('T3 branches on REPO_MODE (solo vs collaborative)', () => {
expect(shipSkill).toContain('REPO_MODE');
expect(shipSkill).toContain('solo');
expect(shipSkill).toContain('collaborative');
});
test('solo mode offers fix-now, TODO, and skip options', () => {
expect(shipSkill).toContain('Investigate and fix now');
expect(shipSkill).toContain('Add as P0 TODO');
expect(shipSkill).toContain('Skip');
});
test('collaborative mode offers blame + assign option', () => {
expect(shipSkill).toContain('Blame + assign GitHub issue');
expect(shipSkill).toContain('gh issue create');
});
test('defaults ambiguous failures to in-branch (safety)', () => {
expect(shipSkill).toContain('When ambiguous, default to in-branch');
});
});
// --- {{PLAN_FILE_REVIEW_REPORT}} resolver tests ---
describe('PLAN_FILE_REVIEW_REPORT resolver', () => {
const REVIEW_SKILLS = ['plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'codex'];
for (const skill of REVIEW_SKILLS) {
test(`plan file review report appears in ${skill} generated file`, () => {
const content = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
expect(content).toContain('GSTACK REVIEW REPORT');
});
}
test('resolver output contains key report elements', () => {
const content = readSkillUnion('plan-ceo-review'); // carved: report writer moved to section
expect(content).toContain('Trigger');
expect(content).toContain('Findings');
expect(content).toContain('VERDICT');
expect(content).toContain('/plan-ceo-review');
expect(content).toContain('/plan-eng-review');
expect(content).toContain('/plan-design-review');
expect(content).toContain('/codex review');
});
});
// --- {{PLAN_COMPLETION_AUDIT}} resolver tests ---
describe('PLAN_COMPLETION_AUDIT placeholders', () => {
const shipSkill = readShipUnion();
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
test('ship SKILL.md contains plan completion audit step', () => {
expect(shipSkill).toContain('Plan Completion Audit');
expect(shipSkill).toContain('Step 8');
});
test('review SKILL.md contains plan completion in scope drift', () => {
expect(reviewSkill).toContain('Plan File Discovery');
expect(reviewSkill).toContain('Actionable Item Extraction');
expect(reviewSkill).toContain('Integration with Scope Drift Detection');
});
test('both modes share plan file discovery methodology', () => {
expect(shipSkill).toContain('Plan File Discovery');
expect(reviewSkill).toContain('Plan File Discovery');
// Both should have conversation context first
expect(shipSkill).toContain('Conversation context (primary)');
expect(reviewSkill).toContain('Conversation context (primary)');
// Both should have grep fallback
expect(shipSkill).toContain('Content-based search (fallback)');
expect(reviewSkill).toContain('Content-based search (fallback)');
});
test('ship mode has gate logic for NOT DONE items', () => {
expect(shipSkill).toContain('NOT DONE');
expect(shipSkill).toContain('Stop — implement the missing items');
expect(shipSkill).toContain('Ship anyway — defer');
expect(shipSkill).toContain('intentionally dropped');
});
test('review mode is INFORMATIONAL only', () => {
expect(reviewSkill).toContain('INFORMATIONAL');
expect(reviewSkill).toContain('MISSING REQUIREMENTS');
expect(reviewSkill).toContain('SCOPE CREEP');
});
test('item extraction has 50-item cap', () => {
expect(shipSkill).toContain('at most 50 items');
});
test('uses file-level traceability (not commit-level)', () => {
expect(shipSkill).toContain('Cite the specific file');
expect(shipSkill).not.toContain('commit-level traceability');
});
});
// --- {{PLAN_VERIFICATION_EXEC}} resolver tests ---
describe('PLAN_VERIFICATION_EXEC placeholder', () => {
const shipSkill = readShipUnion();
test('ship SKILL.md contains plan verification step', () => {
expect(shipSkill).toContain('Step 8.1');
expect(shipSkill).toContain('Plan Verification');
});
test('references /qa-only invocation', () => {
expect(shipSkill).toContain('qa-only/SKILL.md');
expect(shipSkill).toContain('qa-only');
});
test('contains dev-server discovery (CLAUDE.md first, then a port probe)', () => {
// Fork port wave 2: the hardcoded 4-port list became read-CLAUDE.md-or-
// probe; the probe loops common ports instead of naming each once.
expect(shipSkill).toContain('CLAUDE.md first');
expect(shipSkill).toContain('http://localhost:$_p');
expect(shipSkill).toContain('NO_SERVER');
});
test('skips gracefully when no verification section', () => {
expect(shipSkill).toContain('No verification steps found in plan');
});
test('skips gracefully when no dev server', () => {
expect(shipSkill).toContain('No dev server detected');
});
});
// --- Coverage gate tests ---
describe('Coverage gate in ship', () => {
const shipSkill = readShipUnion();
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
test('ship SKILL.md contains coverage gate with thresholds', () => {
expect(shipSkill).toContain('Coverage gate');
expect(shipSkill).toContain('>= target');
expect(shipSkill).toContain('< minimum');
});
test('ship SKILL.md supports configurable thresholds via CLAUDE.md', () => {
expect(shipSkill).toContain('## Test Coverage');
expect(shipSkill).toContain('Minimum:');
expect(shipSkill).toContain('Target:');
});
test('coverage gate skips on parse failure (not block)', () => {
expect(shipSkill).toContain('could not determine percentage — skipping');
});
test('review SKILL.md delegates coverage to Testing specialist', () => {
// Coverage audit moved to Testing specialist subagent in Review Army
expect(reviewSkill).toContain('testing.md');
expect(reviewSkill).toContain('INFORMATIONAL');
});
});
// --- Ship metrics logging ---
describe('Ship metrics logging', () => {
const shipSkill = readShipUnion();
test('ship SKILL.md contains metrics persistence step', () => {
expect(shipSkill).toContain('Step 20');
expect(shipSkill).toContain('coverage_pct');
expect(shipSkill).toContain('plan_items_total');
expect(shipSkill).toContain('plan_items_done');
expect(shipSkill).toContain('verification_result');
});
});
// --- Plan file discovery shared helper ---
describe('Plan file discovery shared helper', () => {
// The shared helper should appear in ship (via PLAN_COMPLETION_AUDIT_SHIP)
// and in review (via PLAN_COMPLETION_AUDIT_REVIEW)
const shipSkill = readShipUnion();
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
test('plan file discovery appears in both ship and review', () => {
expect(shipSkill).toContain('Plan File Discovery');
expect(reviewSkill).toContain('Plan File Discovery');
});
test('both include conversation context first', () => {
expect(shipSkill).toContain('Conversation context (primary)');
expect(reviewSkill).toContain('Conversation context (primary)');
});
test('both include content-based fallback', () => {
expect(shipSkill).toContain('Content-based search (fallback)');
expect(reviewSkill).toContain('Content-based search (fallback)');
});
});
// --- Retro plan completion ---
describe('Retro plan completion section', () => {
const retroSkill = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
test('retro SKILL.md contains plan completion section', () => {
expect(retroSkill).toContain('### Plan Completion');
expect(retroSkill).toContain('plan_items_total');
expect(retroSkill).toContain('Plan Completion This Period');
});
});
// --- Plan status footer in preamble ---
describe('Plan status footer in preamble', () => {
test('preamble contains plan status footer as neutral forward reference to EXIT PLAN MODE GATE', () => {
// Read any skill that uses PREAMBLE
const content = readSkillUnion('office-hours'); // carved: Phase 5/6 prose moved to section
expect(content).toContain('Plan Status Footer');
expect(content).toContain('GSTACK REVIEW REPORT');
expect(content).toContain('ExitPlanMode');
expect(content).toContain('EXIT PLAN MODE GATE');
// The preamble must NOT impose review-report rules on operational skills
// that have no review report. It's a forward reference, not enforcement.
expect(content).not.toContain('NO REVIEWS YET');
});
});
// --- make-pdf setup ordering ---
describe('make-pdf setup ordering', () => {
test('MAKE-PDF SETUP appears before generic preamble footer sections', () => {
const content = fs.readFileSync(path.join(ROOT, 'make-pdf', 'SKILL.md'), 'utf-8');
const preambleIdx = content.indexOf('## Preamble (run first)');
const setupIdx = content.indexOf('## MAKE-PDF SETUP');
const planModeIdx = content.indexOf('## Plan Mode Safe Operations');
const telemetryIdx = content.indexOf('## Telemetry (run last)');
const workflowIdx = content.indexOf('# make-pdf: publication-quality PDFs from markdown');
expect(preambleIdx).toBeGreaterThanOrEqual(0);
expect(setupIdx).toBeGreaterThan(preambleIdx);
expect(setupIdx).toBeLessThan(planModeIdx);
expect(setupIdx).toBeLessThan(telemetryIdx);
expect(setupIdx).toBeLessThan(workflowIdx);
expect(content.match(/^## MAKE-PDF SETUP/gm)?.length ?? 0).toBe(1);
});
});
// --- Skill invocation during plan mode in preamble ---
describe('Skill invocation during plan mode in preamble', () => {
test('preamble contains skill invocation plan mode section', () => {
const content = readSkillUnion('office-hours'); // carved: Phase 5/6 prose moved to section
expect(content).toContain('Skill Invocation During Plan Mode');
expect(content).toContain('precedence over generic plan mode behavior');
expect(content).toContain('Do not continue the workflow');
expect(content).toContain('cancel the skill or leave plan mode');
});
});
// --- {{SPEC_REVIEW_LOOP}} resolver tests ---
describe('SPEC_REVIEW_LOOP resolver', () => {
const content = readSkillUnion('office-hours'); // carved: Phase 5/6 prose moved to section
test('contains all 5 review dimensions', () => {
for (const dim of ['Completeness', 'Consistency', 'Clarity', 'Scope', 'Feasibility']) {
expect(content).toContain(dim);
}
});
test('references Agent tool for subagent dispatch', () => {
expect(content).toMatch(/Agent.*tool/i);
});
test('specifies max 3 iterations', () => {
expect(content).toMatch(/3.*iteration|maximum.*3/i);
});
test('includes quality score', () => {
expect(content).toContain('quality score');
});
test('includes metrics path', () => {
expect(content).toContain('spec-review.jsonl');
});
test('includes convergence guard', () => {
expect(content).toMatch(/[Cc]onvergence/);
});
test('includes graceful failure handling', () => {
expect(content).toMatch(/skip.*review|unavailable/i);
});
});
// --- {{DESIGN_SKETCH}} resolver tests ---
describe('DESIGN_SKETCH resolver', () => {
const content = readSkillUnion('office-hours'); // carved: Phase 5/6 prose moved to section
test('references DESIGN.md for design system constraints', () => {
expect(content).toContain('DESIGN.md');
});
test('contains wireframe or sketch terminology', () => {
expect(content).toMatch(/wireframe|sketch/i);
});
test('references browse binary for rendering', () => {
expect(content).toContain('$B goto');
});
test('references screenshot capture', () => {
expect(content).toContain('$B screenshot');
});
test('specifies rough aesthetic', () => {
expect(content).toMatch(/[Rr]ough|hand-drawn/);
});
test('includes skip conditions', () => {
expect(content).toMatch(/no UI component|skip/i);
});
});
// --- {{CODEX_SECOND_OPINION}} resolver tests ---
describe('CODEX_SECOND_OPINION resolver', () => {
const content = readSkillUnion('office-hours'); // carved: Phase 5/6 prose moved to section
const codexContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-office-hours', 'SKILL.md'), 'utf-8');
test('Phase 3.5 section appears in office-hours SKILL.md', () => {
expect(content).toContain('Phase 3.5: Cross-Model Second Opinion');
});
test('contains codex exec invocation', () => {
expect(content).toContain('codex exec');
});
test('contains opt-in AskUserQuestion text', () => {
expect(content).toContain('second opinion from an independent AI perspective');
});
test('contains cross-model synthesis instructions', () => {
expect(content).toMatch(/[Ss]ynthesis/);
expect(content).toContain('Where Claude agrees with the second opinion');
});
test('contains Claude subagent fallback', () => {
expect(content).toContain('CODEX_NOT_AVAILABLE');
expect(content).toContain('Agent tool');
expect(content).toContain('SECOND OPINION (Claude subagent)');
});
test('contains premise revision check', () => {
expect(content).toContain('Codex challenged premise');
});
test('contains error handling for auth, timeout, and empty', () => {
expect(content).toMatch(/[Aa]uth.*fail/);
expect(content).toMatch(/[Tt]imeout/);
expect(content).toMatch(/[Ee]mpty response/);
});
test('Codex host variant does NOT contain the Phase 3.5 resolver output', () => {
// The resolver returns '' for codex host, so the interactive section is stripped.
// Static template references to "Phase 3.5" in prose/conditionals are fine.
// Other resolvers (design review lite) may contain CODEX_NOT_AVAILABLE, so we
// check for Phase 3.5-specific markers only.
expect(codexContent).not.toContain('Phase 3.5: Cross-Model Second Opinion');
expect(codexContent).not.toContain('TMPERR_OH');
expect(codexContent).not.toContain('gstack-codex-oh-');
});
});
// --- Codex filesystem boundary tests ---
describe('Codex filesystem boundary', () => {
// Skills that call codex exec/review and should contain boundary text
const CODEX_CALLING_SKILLS = [
'codex', // /codex skill — 3 modes
'autoplan', // /autoplan — CEO/design/eng voices
'review', // /review — adversarial step resolver
'ship', // /ship — adversarial step resolver
'plan-eng-review', // outside voice resolver
'plan-ceo-review', // outside voice resolver
'office-hours', // second opinion resolver
];
const BOUNDARY_MARKER = 'Do NOT read or execute any';
test('boundary instruction appears in all skills that call codex', () => {
for (const skill of CODEX_CALLING_SKILLS) {
// Union: ship's codex call lives in sections/adversarial.md after the carve.
const content = readSkillUnion(skill);
expect(content).toContain(BOUNDARY_MARKER);
}
});
test('codex skill has Filesystem Boundary section', () => {
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
expect(content).toContain('## Filesystem Boundary');
expect(content).toContain('skill definitions meant for a different AI system');
});
test('codex skill has rabbit-hole detection rule', () => {
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
expect(content).toContain('Detect skill-file rabbit holes');
expect(content).toContain('gstack-update-check');
expect(content).toContain('Consider retrying');
});
test('review.ts CODEX_BOUNDARY constant is interpolated into resolver output', () => {
// The adversarial step resolver should include boundary text in codex exec prompts
const reviewContent = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
// Boundary should appear near codex exec invocations
const boundaryIdx = reviewContent.indexOf(BOUNDARY_MARKER);
const codexExecIdx = reviewContent.indexOf('codex exec');
// Both must exist and boundary must come before a codex exec call
expect(boundaryIdx).toBeGreaterThan(-1);
expect(codexExecIdx).toBeGreaterThan(-1);
});
test('autoplan boundary text avoids host-specific paths for cross-host compatibility', () => {
const content = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md.tmpl'), 'utf-8');
// autoplan template uses generic 'skills/gstack' pattern instead of host-specific
// paths like ~/.claude/ or .agents/skills (which break Codex/Claude output tests)
const boundaryStart = content.indexOf('Filesystem Boundary');
const boundaryEnd = content.indexOf('---', boundaryStart + 1);
const boundarySection = content.slice(boundaryStart, boundaryEnd);
expect(boundarySection).not.toContain('~/.claude/');
expect(boundarySection).not.toContain('.agents/skills');
expect(boundarySection).toContain('skills/gstack');
expect(boundarySection).toContain(BOUNDARY_MARKER);
});
});
// --- {{BENEFITS_FROM}} resolver tests ---
describe('BENEFITS_FROM resolver', () => {
const ceoContent = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
const engContent = readSkillUnion('plan-eng-review'); // carved
test('plan-ceo-review contains prerequisite skill offer', () => {
expect(ceoContent).toContain('Prerequisite Skill Offer');
expect(ceoContent).toContain('/office-hours');
});
test('plan-eng-review contains prerequisite skill offer', () => {
expect(engContent).toContain('Prerequisite Skill Offer');
expect(engContent).toContain('/office-hours');
});
test('offer includes graceful decline', () => {
expect(ceoContent).toContain('No worries');
});
test('skills without benefits-from do NOT have prerequisite offer', () => {
const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
expect(qaContent).not.toContain('Prerequisite Skill Offer');
});
test('inline invocation — no "another window" language', () => {
expect(ceoContent).not.toContain('another window');
expect(engContent).not.toContain('another window');
});
test('inline invocation — read-and-follow path present', () => {
expect(ceoContent).toContain('office-hours/SKILL.md');
expect(engContent).toContain('office-hours/SKILL.md');
});
test('BENEFITS_FROM delegates to INVOKE_SKILL pattern', () => {
// Should contain the INVOKE_SKILL-style loading prose (not the old manual skip list)
expect(engContent).toContain('Follow its instructions from top to bottom');
expect(engContent).toContain('skipping these sections');
expect(ceoContent).toContain('Follow its instructions from top to bottom');
});
});
// --- {{INVOKE_SKILL}} resolver tests ---
describe('INVOKE_SKILL resolver', () => {
const ceoContent = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
test('plan-ceo-review uses INVOKE_SKILL for mid-session office-hours fallback', () => {
// The mid-session detection path should use INVOKE_SKILL-generated prose
expect(ceoContent).toContain('office-hours/SKILL.md');
expect(ceoContent).toContain('Follow its instructions from top to bottom');
});
test('INVOKE_SKILL output includes default skip list', () => {
expect(ceoContent).toContain('Preamble (run first)');
expect(ceoContent).toContain('Telemetry (run last)');
expect(ceoContent).toContain('AskUserQuestion Format');
});
test('INVOKE_SKILL output includes error handling', () => {
expect(ceoContent).toContain('If unreadable');
expect(ceoContent).toContain('Could not load');
});
test('template uses {{INVOKE_SKILL:office-hours}} placeholder', () => {
const tmpl = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md.tmpl'), 'utf-8');
expect(tmpl).toContain('{{INVOKE_SKILL:office-hours}}');
});
});
// --- {{CHANGELOG_WORKFLOW}} resolver tests ---
describe('CHANGELOG_WORKFLOW resolver', () => {
const shipContent = readShipUnion();
test('ship SKILL.md contains changelog workflow', () => {
expect(shipContent).toContain('CHANGELOG (auto-generate)');
expect(shipContent).toContain('git log <base>..HEAD --oneline');
});
test('changelog workflow includes cross-check step', () => {
expect(shipContent).toContain('Cross-check');
expect(shipContent).toContain('Every commit must map to at least one bullet point');
});
test('changelog workflow includes voice guidance', () => {
expect(shipContent).toContain('Lead with what the user can now **do**');
});
test('template uses {{CHANGELOG_WORKFLOW}} placeholder', () => {
// Post-carve (T9): the skeleton points to the changelog section, which carries
// the resolver. Neither should inline the old changelog content.
const skel = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md.tmpl'), 'utf-8');
const changelogSection = fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'changelog.md.tmpl'), 'utf-8');
expect(skel).toContain('{{SECTION:changelog}}');
expect(changelogSection).toContain('{{CHANGELOG_WORKFLOW}}');
expect(skel + changelogSection).not.toContain('Group commits by theme');
});
test('changelog workflow includes keep-changelog format', () => {
expect(shipContent).toContain('### Added');
expect(shipContent).toContain('### Fixed');
});
});
// --- Parameterized resolver infrastructure tests ---
describe('parameterized resolver support', () => {
test('gen-skill-docs regex handles colon-separated args', () => {
// Verify the template containing {{INVOKE_SKILL:office-hours}} was processed
// without leaving unresolved placeholders
const ceoContent = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
expect(ceoContent).not.toMatch(/\{\{INVOKE_SKILL:[^}]+\}\}/);
});
test('templates with parameterized resolvers pass unresolved check', () => {
// All generated SKILL.md files should have no unresolved {{...}} placeholders
const skillDirs = fs.readdirSync(ROOT).filter(d =>
fs.existsSync(path.join(ROOT, d, 'SKILL.md'))
);
for (const dir of skillDirs) {
const content = fs.readFileSync(path.join(ROOT, dir, 'SKILL.md'), 'utf-8');
const unresolved = content.match(/\{\{[A-Z_]+(?::[^}]*)?\}\}/g);
if (unresolved) {
throw new Error(`${dir}/SKILL.md has unresolved placeholders: ${unresolved.join(', ')}`);
}
}
});
});
// --- Preamble routing injection tests ---
describe('preamble routing injection', () => {
const shipContent = readShipUnion();
test('preamble bash checks for routing section in CLAUDE.md', () => {
expect(shipContent).toContain('grep -q "## Skill routing" CLAUDE.md');
expect(shipContent).toContain('HAS_ROUTING');
});
test('preamble bash reads routing_declined config', () => {
expect(shipContent).toContain('routing_declined');
expect(shipContent).toContain('ROUTING_DECLINED');
});
test('preamble includes routing injection AskUserQuestion', () => {
expect(shipContent).toContain('Add routing rules to CLAUDE.md');
expect(shipContent).toContain("I'll invoke skills manually");
});
test('routing injection respects prior decline', () => {
expect(shipContent).toContain('ROUTING_DECLINED');
expect(shipContent).toMatch(/routing_declined.*true/);
});
test('routing injection only fires when all conditions met', () => {
// Must be: HAS_ROUTING=no AND ROUTING_DECLINED=false AND PROACTIVE_PROMPTED=yes
expect(shipContent).toContain('HAS_ROUTING');
expect(shipContent).toContain('ROUTING_DECLINED');
expect(shipContent).toContain('PROACTIVE_PROMPTED');
});
test('routing section content includes key routing rules', () => {
expect(shipContent).toContain('invoke /office-hours');
expect(shipContent).toContain('invoke /investigate');
expect(shipContent).toContain('invoke /ship');
expect(shipContent).toContain('invoke /qa');
});
test('routing section uses renamed checkpoint skills (not stale /checkpoint)', () => {
expect(shipContent).toContain('invoke /context-save');
expect(shipContent).toContain('invoke /context-restore');
expect(shipContent).not.toContain('invoke checkpoint');
});
test('routing section uses soft "when in doubt" policy, not hard "ALWAYS invoke"', () => {
expect(shipContent).toContain('When in doubt, invoke the skill');
expect(shipContent).not.toContain('Do NOT answer directly');
});
});
// --- {{DESIGN_OUTSIDE_VOICES}} resolver tests ---
describe('DESIGN_OUTSIDE_VOICES resolver', () => {
test('plan-design-review contains outside voices section', () => {
const content = readSkillUnion('plan-design-review');
expect(content).toContain('Design Outside Voices');
expect(content).toContain('CODEX_AVAILABLE');
expect(content).toContain('LITMUS SCORECARD');
});
test('design-review contains outside voices section', () => {
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
expect(content).toContain('Design Outside Voices');
expect(content).toContain('source audit');
});
test('design-consultation contains outside voices section', () => {
const content = fs.readFileSync(path.join(ROOT, 'design-consultation', 'SKILL.md'), 'utf-8');
expect(content).toContain('Design Outside Voices');
expect(content).toContain('design direction');
});
test('branches correctly per skillName — different prompts', () => {
const planContent = readSkillUnion('plan-design-review');
const consultContent = fs.readFileSync(path.join(ROOT, 'design-consultation', 'SKILL.md'), 'utf-8');
// plan-design-review uses analytical prompt (high reasoning)
expect(planContent).toContain('model_reasoning_effort="high"');
// design-consultation uses creative prompt (medium reasoning)
expect(consultContent).toContain('model_reasoning_effort="medium"');
});
});
// --- {{DESIGN_HARD_RULES}} resolver tests ---
describe('DESIGN_HARD_RULES resolver', () => {
test('plan-design-review Pass 4 contains hard rules', () => {
const content = readSkillUnion('plan-design-review');
expect(content).toContain('Design Hard Rules');
expect(content).toContain('Classifier');
expect(content).toContain('MARKETING/LANDING PAGE');
expect(content).toContain('APP UI');
});
test('design-review contains hard rules', () => {
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
expect(content).toContain('Design Hard Rules');
});
test('includes all 3 rule sets', () => {
const content = readSkillUnion('plan-design-review');
expect(content).toContain('Landing page rules');
expect(content).toContain('App UI rules');
expect(content).toContain('Universal rules');
});
test('references shared AI slop blacklist items', () => {
const content = readSkillUnion('plan-design-review');
expect(content).toContain('3-column feature grid');
expect(content).toContain('Purple/violet/indigo');
});
test('includes OpenAI hard rejection criteria', () => {
const content = readSkillUnion('plan-design-review');
expect(content).toContain('Generic SaaS card grid');
expect(content).toContain('Carousel with no narrative purpose');
});
test('includes OpenAI litmus checks', () => {
const content = readSkillUnion('plan-design-review');
expect(content).toContain('Brand/product unmistakable');
expect(content).toContain('premium with all decorative shadows removed');
});
});
// --- Extended DESIGN_SKETCH resolver tests ---
describe('DESIGN_SKETCH extended with outside voices', () => {
const content = readSkillUnion('office-hours'); // carved: Phase 5/6 prose moved to section
test('contains outside design voices step', () => {
expect(content).toContain('Outside design voices');
});
test('offers opt-in via AskUserQuestion', () => {
expect(content).toContain('outside design perspectives');
});
test('still contains original wireframe steps', () => {
expect(content).toContain('wireframe');
expect(content).toContain('$B goto');
});
});
// --- Extended DESIGN_REVIEW_LITE resolver tests ---
describe('DESIGN_REVIEW_LITE extended with Codex', () => {
const content = readShipUnion();
test('contains Codex design voice block', () => {
expect(content).toContain('Codex design voice');
expect(content).toContain('CODEX (design)');
});
test('still contains original checklist steps', () => {
expect(content).toContain('design-checklist.md');
expect(content).toContain('SCOPE_FRONTEND');
});
});
// ─── Codex Generation Tests ─────────────────────────────────
describe('Codex generation (--host codex)', () => {
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
// .agents/ is gitignored (v0.11.2.0) — generate on demand for tests
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
// Dynamic discovery of expected Codex skills: all templates except /codex
// Also excludes skills where .agents/skills/{name} is a symlink back to the repo root
// (vendored dev mode — gen-skill-docs skips these to avoid overwriting Claude SKILL.md)
const CODEX_SKILLS = (() => {
const skills: Array<{ dir: string; codexName: string }> = [];
const isSymlinkLoop = (codexName: string): boolean => {
const agentSkillDir = path.join(ROOT, '.agents', 'skills', codexName);
try {
return fs.realpathSync(agentSkillDir) === fs.realpathSync(ROOT);
} catch { return false; }
};
if (fs.existsSync(path.join(ROOT, 'SKILL.md.tmpl'))) {
if (!isSymlinkLoop('gstack')) {
skills.push({ dir: '.', codexName: 'gstack' });
}
}
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
if (entry.name === 'codex') continue; // /codex is excluded from Codex output
if (!fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) continue;
const codexName = entry.name.startsWith('gstack-') ? entry.name : `gstack-${entry.name}`;
if (isSymlinkLoop(codexName)) continue;
skills.push({ dir: entry.name, codexName });
}
return skills;
})();
test('--host codex generates correct output paths', () => {
for (const skill of CODEX_SKILLS) {
const skillMd = path.join(AGENTS_DIR, skill.codexName, 'SKILL.md');
expect(fs.existsSync(skillMd)).toBe(true);
}
});
test('root gstack bundle has OpenAI metadata for Codex skill browsing', () => {
const rootMetadata = path.join(ROOT, 'agents', 'openai.yaml');
expect(fs.existsSync(rootMetadata)).toBe(true);
const content = fs.readFileSync(rootMetadata, 'utf-8');
expect(content).toContain('display_name: "gstack"');
expect(content).toContain('Use $gstack to locate the bundled gstack skills.');
expect(content).toContain('allow_implicit_invocation: true');
});
test('externalSkillName mapping: root is gstack, others are gstack-{dir}', () => {
// Root → gstack
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack', 'SKILL.md'))).toBe(true);
// Subdirectories → gstack-{dir}
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'))).toBe(true);
// gstack-upgrade doesn't double-prefix
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-upgrade', 'SKILL.md'))).toBe(true);
// No double-prefix: gstack-gstack-upgrade must NOT exist
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-gstack-upgrade', 'SKILL.md'))).toBe(false);
});
test('Codex frontmatter has ONLY name + description', () => {
for (const skill of CODEX_SKILLS) {
const content = fs.readFileSync(path.join(AGENTS_DIR, skill.codexName, 'SKILL.md'), 'utf-8');
expect(content.startsWith('---\n')).toBe(true);
const fmEnd = content.indexOf('\n---', 4);
expect(fmEnd).toBeGreaterThan(0);
const frontmatter = content.slice(4, fmEnd);
// Must have name and description
expect(frontmatter).toContain('name:');
expect(frontmatter).toContain('description:');
// Must NOT have allowed-tools, version, or hooks
expect(frontmatter).not.toContain('allowed-tools:');
expect(frontmatter).not.toContain('version:');
expect(frontmatter).not.toContain('hooks:');
}
});
test('all Codex skills have agents/openai.yaml metadata', () => {
for (const skill of CODEX_SKILLS) {
const metadata = path.join(AGENTS_DIR, skill.codexName, 'agents', 'openai.yaml');
expect(fs.existsSync(metadata)).toBe(true);
const content = fs.readFileSync(metadata, 'utf-8');
expect(content).toContain(`display_name: "${skill.codexName}"`);
expect(content).toContain('short_description:');
expect(content).toContain('allow_implicit_invocation: true');
}
});
test('no .claude/skills/ in Codex output', () => {
for (const skill of CODEX_SKILLS) {
const content = fs.readFileSync(path.join(AGENTS_DIR, skill.codexName, 'SKILL.md'), 'utf-8');
expect(content).not.toContain('.claude/skills');
}
});
test('no ~/.claude/ paths in Codex output', () => {
for (const skill of CODEX_SKILLS) {
const content = fs.readFileSync(path.join(AGENTS_DIR, skill.codexName, 'SKILL.md'), 'utf-8');
expect(content).not.toContain('~/.claude/');
}
});
test('/codex skill excluded from Codex output', () => {
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex'))).toBe(false);
});
test('Codex output includes Claude outside-voice skill with read-only boundary', () => {
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-claude', 'SKILL.md'), 'utf-8');
expect(content).toContain('claude -p');
expect(content).toContain('mktemp /tmp/gstack-claude-prompt-');
expect(content).toContain('mktemp /tmp/gstack-claude-response-XXXXXX');
expect(content).toContain('mktemp /tmp/gstack-claude-error-XXXXXX');
expect(content).toContain('mktemp /tmp/gstack-claude-diff-');
expect(content).not.toMatch(/gstack-claude-(?:prompt|response|error|diff)-X{6,}\.\w+/);
expect(content).not.toContain('/tmp/gstack-claude-diff-$$');
expect(content).toContain('cat "$PROMPT_FILE" | "$CLAUDE_BIN" -p');
expect(content).toContain('Resolve the binary and invoke it in the same host execution context');
expect(content).toContain('--disable-slash-commands');
expect(content).toContain('--tools ""');
expect(content).toContain('--allowedTools Read,Grep,Glob');
expect(content).toContain('--disallowedTools Bash,Edit,Write');
expect(content).toContain('Do not infer authentication state from credential files');
expect(content).toContain('run the actual `claude -p`');
expect(content).not.toContain('AUTH_MISSING');
expect(content).not.toContain('$HOME/.claude/.credentials.json');
expect(content).toContain('is_error');
});
test('Claude temp file templates are accepted by host mktemp', () => {
for (const template of [
'/tmp/gstack-claude-prompt-XXXXXX',
'/tmp/gstack-claude-response-XXXXXX',
'/tmp/gstack-claude-error-XXXXXX',
'/tmp/gstack-claude-diff-XXXXXX',
]) {
const result = spawnSync('mktemp', [template], { encoding: 'utf-8' });
expect(result.status).toBe(0);
const created = result.stdout.trim();
expect(created.startsWith(template.replace('XXXXXX', ''))).toBe(true);
fs.unlinkSync(created);
}
});
test('Codex review step stripped from Codex-host ship and review', () => {
const shipContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
expect(shipContent).not.toContain('codex review --base');
expect(shipContent).not.toContain('CODEX_REVIEWS');
const reviewContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
expect(reviewContent).not.toContain('codex review --base');
expect(reviewContent).not.toContain('CODEX_REVIEWS');
});
test('--host codex --dry-run freshness', () => {
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex', '--dry-run'], {
cwd: ROOT,
stdout: 'pipe',
stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
// Every Codex skill should be FRESH
for (const skill of CODEX_SKILLS) {
expect(output).toContain(`FRESH: .agents/skills/${skill.codexName}/SKILL.md`);
}
expect(output).not.toContain('STALE');
});
test('--host agents alias produces same output as --host codex', () => {
const codexResult = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex', '--dry-run'], {
cwd: ROOT,
stdout: 'pipe',
stderr: 'pipe',
});
const agentsResult = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'agents', '--dry-run'], {
cwd: ROOT,
stdout: 'pipe',
stderr: 'pipe',
});
expect(codexResult.exitCode).toBe(0);
expect(agentsResult.exitCode).toBe(0);
// Both should produce the same output (same FRESH lines)
expect(codexResult.stdout.toString()).toBe(agentsResult.stdout.toString());
});
test('multiline descriptions preserved in Codex output', () => {
// office-hours has a multiline description — verify it survives the frontmatter transform
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-office-hours', 'SKILL.md'), 'utf-8');
const fmEnd = content.indexOf('\n---', 4);
const frontmatter = content.slice(4, fmEnd);
// Description should span multiple lines (block scalar)
const descLines = frontmatter.split('\n').filter(l => l.startsWith(' '));
expect(descLines.length).toBeGreaterThan(1);
// Verify key phrases survived
expect(frontmatter).toContain('YC Office Hours');
});
test('hook skills have safety prose and no hooks: in frontmatter', () => {
const HOOK_SKILLS = ['gstack-careful', 'gstack-freeze', 'gstack-guard'];
for (const skillName of HOOK_SKILLS) {
const content = fs.readFileSync(path.join(AGENTS_DIR, skillName, 'SKILL.md'), 'utf-8');
// Must have safety advisory prose
expect(content).toContain('Safety Advisory');
// Must NOT have hooks: in frontmatter
const fmEnd = content.indexOf('\n---', 4);
const frontmatter = content.slice(4, fmEnd);
expect(frontmatter).not.toContain('hooks:');
}
});
test('all Codex SKILL.md files have auto-generated header', () => {
for (const skill of CODEX_SKILLS) {
const content = fs.readFileSync(path.join(AGENTS_DIR, skill.codexName, 'SKILL.md'), 'utf-8');
expect(content).toContain('AUTO-GENERATED from SKILL.md.tmpl');
expect(content).toContain('Regenerate: bun run gen:skill-docs');
}
});
test('Codex preamble resolves runtime assets from repo-local or global gstack roots', () => {
// Check a skill that has a preamble (review is a good candidate)
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
expect(content).toContain('GSTACK_ROOT');
expect(content).toContain('$_ROOT/.agents/skills/gstack');
expect(content).toContain('$GSTACK_BIN/gstack-config');
expect(content).toContain('$GSTACK_ROOT/gstack-upgrade/SKILL.md');
expect(content).not.toContain('~/.codex/skills/gstack/bin/gstack-config get telemetry');
});
// ─── Path rewriting regression tests ─────────────────────────
test('sidecar paths point to .agents/skills/gstack/review/ (not gstack-review/)', () => {
// Regression: gen-skill-docs rewrote .claude/skills/review → .agents/skills/gstack-review
// but setup puts sidecars under .agents/skills/gstack/review/. Must match setup layout.
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
// Correct: references to sidecar files use gstack/review/ path
expect(content).toContain('.agents/skills/gstack/review/checklist.md');
// design-checklist.md is now referenced via Review Army specialist (Claude only, stripped for Codex)
// Wrong: must NOT reference gstack-review/checklist.md (file doesn't exist there)
expect(content).not.toContain('.agents/skills/gstack-review/checklist.md');
});
test('sidecar paths in ship skill point to gstack/review/ for pre-landing review', () => {
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
// Ship references the review checklist in its pre-landing review step
if (content.includes('checklist.md')) {
expect(content).toContain('.agents/skills/gstack/review/');
expect(content).not.toContain('.agents/skills/gstack-review/checklist');
}
});
test('greptile-triage sidecar path is correct', () => {
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
if (content.includes('greptile-triage')) {
expect(content).toContain('.agents/skills/gstack/review/greptile-triage.md');
expect(content).not.toContain('.agents/skills/gstack-review/greptile-triage');
}
});
test('all four path rewrite rules produce correct output', () => {
// Test each of the 4 path rewrite rules individually
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
// Rule 1: ~/.claude/skills/gstack → $GSTACK_ROOT
expect(content).not.toContain('~/.claude/skills/gstack');
expect(content).toContain('$GSTACK_ROOT');
// Rule 2: .claude/skills/gstack → .agents/skills/gstack
expect(content).not.toContain('.claude/skills/gstack');
// Rule 3: .claude/skills/review → .agents/skills/gstack/review
expect(content).not.toContain('.claude/skills/review');
// Rule 4: .claude/skills → .agents/skills (catch-all)
expect(content).not.toContain('.claude/skills');
});
test('path rewrite rules apply to all Codex skills with sidecar references', () => {
// Verify across ALL generated skills, not just review
for (const skill of CODEX_SKILLS) {
const content = fs.readFileSync(path.join(AGENTS_DIR, skill.codexName, 'SKILL.md'), 'utf-8');
// No skill should reference Claude paths
expect(content).not.toContain('~/.claude/skills');
expect(content).not.toContain('.claude/skills');
if (content.includes('gstack-config') || content.includes('gstack-update-check') || content.includes('gstack-telemetry-log')) {
expect(content).toContain('$GSTACK_ROOT');
}
// If a skill references checklist.md, it must use the correct sidecar path
if (content.includes('checklist.md') && !content.includes('design-checklist.md')) {
expect(content).not.toContain('gstack-review/checklist.md');
}
}
});
// ─── Claude output regression guard ─────────────────────────
test('Claude output unchanged: review skill still uses .claude/skills/ paths', () => {
// Codex changes must NOT affect Claude output
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('.claude/skills/review/checklist.md');
expect(content).toContain('~/.claude/skills/gstack');
// Must NOT contain Codex HOST paths. `~/.codex/sessions/` is exempt: the
// timeout-wrapper guidance documents the Codex CLI's own rollout-log
// location (a user-facing CLI path, same class as ~/.codex/logs/ in the
// codex skill), not the gstack Codex host install path.
expect(content).not.toContain('.agents/skills');
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
});
test('Claude output unchanged: ship skill still uses .claude/skills/ paths', () => {
const content = readShipUnion();
expect(content).toContain('~/.claude/skills/gstack');
expect(content).not.toContain('.agents/skills');
// ~/.codex/sessions/ is the Codex CLI's rollout-log path (user-facing),
// documented by the adversarial-pass timeout guidance — see review test above.
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
});
test('Claude output unchanged: all Claude skills have zero Codex paths', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
// pair-agent legitimately documents how Codex agents store credentials.
// codex + autoplan document the Codex CLI auth file (~/.codex/auth.json)
// and log path (~/.codex/logs/) — those are user-facing Codex CLI paths,
// not the gstack Codex host install path. ~/.codex/sessions/ (rollout
// logs, referenced by the review/ship timeout guidance) is the same
// user-facing class, so it is scrubbed before the ban.
if (skill.dir !== 'pair-agent' && skill.dir !== 'codex' && skill.dir !== 'autoplan') {
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
}
// gstack-upgrade legitimately references .agents/skills for cross-platform detection
if (skill.dir !== 'gstack-upgrade') {
expect(content).not.toContain('.agents/skills');
}
}
});
// ─── Design outside voices: Codex host guard ─────────────────
test('codex host produces empty outside voices in design-review', () => {
const codexContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-design-review', 'SKILL.md'), 'utf-8');
expect(codexContent).not.toContain('Design Outside Voices');
});
test('codex host does not include Codex design block in ship', () => {
const codexContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
expect(codexContent).not.toContain('Codex design voice');
});
});
// ─── Factory generation tests ────────────────────────────────
describe('Factory generation (--host factory)', () => {
const FACTORY_DIR = path.join(ROOT, '.factory', 'skills');
// Generate Factory output for tests
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'factory'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
const FACTORY_SKILLS = (() => {
const skills: Array<{ dir: string; factoryName: string }> = [];
const isSymlinkLoop = (name: string): boolean => {
const factorySkillDir = path.join(ROOT, '.factory', 'skills', name);
try { return fs.realpathSync(factorySkillDir) === fs.realpathSync(ROOT); }
catch { return false; }
};
if (fs.existsSync(path.join(ROOT, 'SKILL.md.tmpl'))) {
if (!isSymlinkLoop('gstack')) skills.push({ dir: '.', factoryName: 'gstack' });
}
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
if (entry.name === 'codex') continue;
if (!fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) continue;
const factoryName = entry.name.startsWith('gstack-') ? entry.name : `gstack-${entry.name}`;
if (isSymlinkLoop(factoryName)) continue;
skills.push({ dir: entry.name, factoryName });
}
return skills;
})();
test('--host factory generates correct output paths', () => {
for (const skill of FACTORY_SKILLS) {
const skillMd = path.join(FACTORY_DIR, skill.factoryName, 'SKILL.md');
expect(fs.existsSync(skillMd)).toBe(true);
}
});
test('Factory frontmatter has name + description + user-invocable', () => {
for (const skill of FACTORY_SKILLS) {
const content = fs.readFileSync(path.join(FACTORY_DIR, skill.factoryName, 'SKILL.md'), 'utf-8');
const fmEnd = content.indexOf('\n---', 4);
const frontmatter = content.slice(4, fmEnd);
expect(frontmatter).toContain('name:');
expect(frontmatter).toContain('description:');
expect(frontmatter).toContain('user-invocable: true');
expect(frontmatter).not.toContain('allowed-tools:');
expect(frontmatter).not.toContain('preamble-tier:');
expect(frontmatter).not.toContain('sensitive:');
}
});
test('sensitive skills have disable-model-invocation', () => {
const SENSITIVE = ['gstack-ship', 'gstack-land-and-deploy', 'gstack-guard', 'gstack-careful', 'gstack-freeze', 'gstack-unfreeze'];
for (const name of SENSITIVE) {
const content = fs.readFileSync(path.join(FACTORY_DIR, name, 'SKILL.md'), 'utf-8');
const fmEnd = content.indexOf('\n---', 4);
const frontmatter = content.slice(4, fmEnd);
expect(frontmatter).toContain('disable-model-invocation: true');
}
});
test('non-sensitive skills lack disable-model-invocation', () => {
const NON_SENSITIVE = ['gstack-qa', 'gstack-review', 'gstack-investigate', 'gstack-browse'];
for (const name of NON_SENSITIVE) {
const content = fs.readFileSync(path.join(FACTORY_DIR, name, 'SKILL.md'), 'utf-8');
const fmEnd = content.indexOf('\n---', 4);
const frontmatter = content.slice(4, fmEnd);
expect(frontmatter).not.toContain('disable-model-invocation');
}
});
test('no .claude/skills/ in Factory output', () => {
for (const skill of FACTORY_SKILLS) {
const content = fs.readFileSync(path.join(FACTORY_DIR, skill.factoryName, 'SKILL.md'), 'utf-8');
expect(content).not.toContain('.claude/skills');
}
});
test('no ~/.claude/skills/ paths in Factory output', () => {
for (const skill of FACTORY_SKILLS) {
const content = fs.readFileSync(path.join(FACTORY_DIR, skill.factoryName, 'SKILL.md'), 'utf-8');
// ~/.claude/skills should be rewritten, but ~/.claude/plans is legitimate
// (plan directory lookup) and ~/.claude/ in codex prompts is intentional
expect(content).not.toContain('~/.claude/skills');
}
});
test('/codex skill excluded from Factory output', () => {
expect(fs.existsSync(path.join(FACTORY_DIR, 'gstack-codex', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(FACTORY_DIR, 'gstack-codex'))).toBe(false);
});
test('Factory keeps Codex integration blocks', () => {
// Factory users CAN use Codex second opinions (codex exec is a standalone binary)
const shipContent = fs.readFileSync(path.join(FACTORY_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
expect(shipContent).toContain('codex');
});
test('no agents/openai.yaml in Factory output', () => {
for (const skill of FACTORY_SKILLS) {
const yamlPath = path.join(FACTORY_DIR, skill.factoryName, 'agents', 'openai.yaml');
expect(fs.existsSync(yamlPath)).toBe(false);
}
});
test('--host droid alias works', () => {
const factoryResult = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'factory', '--dry-run'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
const droidResult = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'droid', '--dry-run'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
expect(factoryResult.exitCode).toBe(0);
expect(droidResult.exitCode).toBe(0);
expect(factoryResult.stdout.toString()).toBe(droidResult.stdout.toString());
});
test('--host factory --dry-run freshness', () => {
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'factory', '--dry-run'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
for (const skill of FACTORY_SKILLS) {
expect(output).toContain(`FRESH: .factory/skills/${skill.factoryName}/SKILL.md`);
}
expect(output).not.toContain('STALE');
});
test('Factory preamble uses .factory paths', () => {
const content = fs.readFileSync(path.join(FACTORY_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
expect(content).toContain('GSTACK_ROOT');
expect(content).toContain('$_ROOT/.factory/skills/gstack');
expect(content).toContain('$GSTACK_BIN/gstack-config');
});
});
// ─── Parameterized host smoke tests (config-driven) ─────────
import { ALL_HOST_CONFIGS, getExternalHosts } from '../hosts/index';
describe('Parameterized host smoke tests', () => {
// Regenerate every external host up front so the per-host `--dry-run` freshness
// checks are deterministic. These host dirs (.agents/.factory/.cursor/...) are
// gitignored regenerated artifacts, so the freshness check is really an
// idempotency/determinism check — it still catches non-deterministic gen, but no
// longer flakes on stale-on-disk state left by a missing `gen --host all` prestep
// (the canonical `bun test` does not run one). The tracked-claude freshness test
// (`generated files are fresh`) runs earlier and is unaffected.
beforeAll(() => {
for (const h of getExternalHosts()) {
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', h.name], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
}
});
for (const hostConfig of getExternalHosts()) {
describe(`${hostConfig.displayName} (--host ${hostConfig.name})`, () => {
const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills');
test('generates output that exists on disk', () => {
// Generated dir should exist (created by earlier bun run gen:skill-docs --host all)
if (!fs.existsSync(hostDir)) {
// Generate if not already done
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', hostConfig.name], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
}
expect(fs.existsSync(hostDir)).toBe(true);
const skills = fs.readdirSync(hostDir).filter(d =>
fs.existsSync(path.join(hostDir, d, 'SKILL.md'))
);
expect(skills.length).toBeGreaterThan(0);
});
test('no .claude/skills path leakage outside repo-root sidecar symlinks', () => {
if (!fs.existsSync(hostDir)) return; // skip if not generated
const skills = fs.readdirSync(hostDir);
for (const skill of skills) {
// Dev installs may mount the repo root at host/skills/gstack as a runtime
// sidecar. The generator skips that symlink loop, so leakage checks should too.
if (isRepoRootSymlink(path.join(hostDir, skill))) continue;
const skillMd = path.join(hostDir, skill, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
// Strip bash blocks (which have legitimate fallback paths)
const noBash = content.replace(/```bash\n[\s\S]*?```/g, '');
const leaks = noBash.split('\n').filter(l => l.includes('.claude/skills'));
if (leaks.length > 0) {
throw new Error(`${skill}: .claude/skills leakage:\n${leaks.slice(0, 3).join('\n')}`);
}
}
});
test('frontmatter has name and description', () => {
if (!fs.existsSync(hostDir)) return;
const skills = fs.readdirSync(hostDir);
for (const skill of skills) {
const skillMd = path.join(hostDir, skill, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
expect(content).toMatch(/^---\n/);
expect(content).toMatch(/^name:\s/m);
expect(content).toMatch(/^description:\s/m);
}
});
test('--dry-run freshness check passes', () => {
const result = Bun.spawnSync(
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', hostConfig.name, '--dry-run'],
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }
);
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
expect(output).not.toContain('STALE');
});
if (hostConfig.generation.skipSkills?.includes('codex')) {
test('/codex skill excluded', () => {
expect(fs.existsSync(path.join(hostDir, 'gstack-codex', 'SKILL.md'))).toBe(false);
});
}
});
}
});
// ─── --host all tests ────────────────────────────────────────
describe('--host all', () => {
// Same determinism guard as the parameterized block: make external hosts fresh on
// disk so `--host all --dry-run` reports FRESH regardless of prior state.
beforeAll(() => {
for (const h of getExternalHosts()) {
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', h.name], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
}
});
test('--host all generates for all registered hosts', () => {
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--dry-run'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
// All hosts should appear in output
expect(output).toContain('FRESH: SKILL.md'); // claude
for (const hostConfig of getExternalHosts()) {
expect(output).toContain(`FRESH: ${hostConfig.hostSubdir}/skills/`);
}
});
});
// ─── Setup script validation ─────────────────────────────────
// These tests verify the setup script's install layout matches
// what the generator produces — catching the bug where setup
// installed Claude-format source dirs for Codex users.
describe('setup script validation', () => {
const setupContent = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
test('setup has separate link functions for Claude and Codex', () => {
expect(setupContent).toContain('link_claude_skill_dirs');
expect(setupContent).toContain('link_codex_skill_dirs');
// Old unified function must not exist
expect(setupContent).not.toMatch(/^link_skill_dirs\(\)/m);
});
test('Claude install uses link_claude_skill_dirs', () => {
// The Claude install section (section 4) should use the Claude function
const claudeSection = setupContent.slice(
setupContent.indexOf('# 4. Install for Claude'),
setupContent.indexOf('# 5. Install for Codex')
);
expect(claudeSection).toContain('link_claude_skill_dirs');
expect(claudeSection).not.toContain('link_codex_skill_dirs');
});
test('Codex install uses link_codex_skill_dirs', () => {
// The Codex install section (section 5) should use the Codex function
const codexSection = setupContent.slice(
setupContent.indexOf('# 5. Install for Codex'),
setupContent.indexOf('# 6. Create')
);
expect(codexSection).toContain('create_codex_runtime_root');
expect(codexSection).toContain('link_codex_skill_dirs');
expect(codexSection).not.toContain('link_claude_skill_dirs');
expect(codexSection).not.toContain('_link_or_copy "$GSTACK_DIR" "$CODEX_GSTACK"');
});
test('Codex install prefers repo-local .agents/skills when setup runs from there', () => {
expect(setupContent).toContain('SKILLS_PARENT_BASENAME');
expect(setupContent).toContain('CODEX_REPO_LOCAL=0');
expect(setupContent).toContain('[ "$SKILLS_PARENT_BASENAME" = ".agents" ]');
expect(setupContent).toContain('CODEX_REPO_LOCAL=1');
expect(setupContent).toContain('CODEX_SKILLS="$INSTALL_SKILLS_DIR"');
});
test('setup separates install path from source path for symlinked repo-local installs', () => {
expect(setupContent).toContain('INSTALL_GSTACK_DIR=');
expect(setupContent).toContain('SOURCE_GSTACK_DIR=');
expect(setupContent).toContain('INSTALL_SKILLS_DIR=');
expect(setupContent).toContain('CODEX_GSTACK="$INSTALL_GSTACK_DIR"');
expect(setupContent).toContain('link_codex_skill_dirs "$SOURCE_GSTACK_DIR" "$CODEX_SKILLS"');
});
test('Codex installs always create sidecar runtime assets for the real skill target', () => {
expect(setupContent).toContain('if [ "$INSTALL_CODEX" -eq 1 ]; then');
expect(setupContent).toContain('create_agents_sidecar "$SOURCE_GSTACK_DIR"');
});
test('link_codex_skill_dirs reads from .agents/skills/', () => {
// The Codex link function must reference .agents/skills for generated Codex skills
const fnStart = setupContent.indexOf('link_codex_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('.agents/skills');
expect(fnBody).toContain('gstack*');
});
test('link_claude_skill_dirs creates real directories with absolute SKILL.md symlinks', () => {
// Claude links should be real directories with absolute SKILL.md symlinks
// to ensure Claude Code discovers them as top-level skills (not nested under gstack/)
const fnStart = setupContent.indexOf('link_claude_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('mkdir -p "$target"');
// v1.36.0.0: routes through _link_or_copy helper for Windows fallback (cp on MSYS2/Git Bash).
expect(fnBody).toContain('_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"');
});
// REGRESSION: cleanup functions must handle both old symlinks AND new real-directory pattern
test('cleanup functions handle real directories with symlinked SKILL.md', () => {
// cleanup_old_claude_symlinks must detect and remove real dirs with SKILL.md symlinks
const cleanupOldStart = setupContent.indexOf('cleanup_old_claude_symlinks()');
const cleanupOldEnd = setupContent.indexOf('}', setupContent.indexOf('cleaned up old', cleanupOldStart));
const cleanupOldBody = setupContent.slice(cleanupOldStart, cleanupOldEnd);
expect(cleanupOldBody).toContain('-d "$old_target"');
expect(cleanupOldBody).toContain('-L "$old_target/SKILL.md"');
expect(cleanupOldBody).toContain('rm -rf "$old_target"');
// cleanup_prefixed_claude_symlinks must also handle the new pattern
const cleanupPrefixedStart = setupContent.indexOf('cleanup_prefixed_claude_symlinks()');
const cleanupPrefixedEnd = setupContent.indexOf('}', setupContent.indexOf('cleaned up prefixed', cleanupPrefixedStart));
const cleanupPrefixedBody = setupContent.slice(cleanupPrefixedStart, cleanupPrefixedEnd);
expect(cleanupPrefixedBody).toContain('-d "$prefixed_target"');
expect(cleanupPrefixedBody).toContain('-L "$prefixed_target/SKILL.md"');
expect(cleanupPrefixedBody).toContain('rm -rf "$prefixed_target"');
});
// REGRESSION: link function must upgrade old directory symlinks
test('link_claude_skill_dirs removes old directory symlinks before creating real dirs', () => {
const fnStart = setupContent.indexOf('link_claude_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
// Must check for and remove old symlinks before mkdir
expect(fnBody).toContain('if [ -L "$target" ]');
expect(fnBody).toContain('rm -f "$target"');
});
test('setup links root gstack skill through a thin Claude wrapper alias', () => {
const fnStart = setupContent.indexOf('link_claude_root_skill_alias()');
const fnEnd = setupContent.indexOf('# ─── Helper: remove old unprefixed Claude skill entries', fnStart);
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('_gstack-command');
expect(fnBody).toContain('_link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md"');
const claudeSection = setupContent.slice(
setupContent.indexOf('# 4. Install for Claude'),
setupContent.indexOf('# 5. Install for Codex')
);
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
});
test('setup supports --host auto|claude|codex|kiro|opencode|cursor|slate', () => {
expect(setupContent).toContain('--host');
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|slate|auto');
});
test('auto mode detects claude, codex, kiro, and opencode binaries', () => {
expect(setupContent).toContain('command -v claude');
expect(setupContent).toContain('command -v codex');
expect(setupContent).toContain('command -v kiro-cli');
expect(setupContent).toContain('command -v opencode');
});
// T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill
test('link_codex_skill_dirs skips the gstack sidecar directory', () => {
const fnStart = setupContent.indexOf('link_codex_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('[ "$skill_name" = "gstack" ] && continue');
});
// T2: Dynamic $GSTACK_ROOT paths in generated Codex preambles
test('generated Codex preambles use dynamic GSTACK_ROOT paths', () => {
const codexSkillDir = path.join(ROOT, '.agents', 'skills', 'gstack-ship');
if (!fs.existsSync(codexSkillDir)) return; // skip if .agents/ not generated
const content = fs.readFileSync(path.join(codexSkillDir, 'SKILL.md'), 'utf-8');
expect(content).toContain('GSTACK_ROOT=');
expect(content).toContain('$GSTACK_BIN/');
});
test('setup supports --host kiro with install section and sed rewrites', () => {
expect(setupContent).toContain('INSTALL_KIRO=');
expect(setupContent).toContain('kiro-cli');
expect(setupContent).toContain('KIRO_SKILLS=');
expect(setupContent).toContain('~/.kiro/skills/gstack');
expect(setupContent).toContain('$KIRO_GSTACK/lib');
});
test('setup supports --host opencode with install section and OpenCode skill path vars', () => {
expect(setupContent).toContain('INSTALL_OPENCODE=');
expect(setupContent).toContain('OPENCODE_SKILLS="$HOME/.config/opencode/skills"');
expect(setupContent).toContain('OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"');
});
test('setup installs OpenCode skills into a nested gstack runtime root', () => {
expect(setupContent).toContain('create_opencode_runtime_root');
expect(setupContent).toContain('.opencode/skills');
expect(setupContent).toContain('review/specialists');
expect(setupContent).toContain('qa/templates');
expect(setupContent).toContain('qa/references');
expect(setupContent).toContain('dx-hall-of-fame.md');
expect(setupContent).toContain('$opencode_gstack/lib');
});
test('create_agents_sidecar links runtime assets', () => {
// Sidecar must link bin with its shared lib modules, plus browse, review, qa
const fnStart = setupContent.indexOf('create_agents_sidecar()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('bin');
expect(fnBody).toContain('lib');
expect(fnBody).toContain('browse');
expect(fnBody).toContain('review');
expect(fnBody).toContain('qa');
});
test('create_codex_runtime_root exposes only runtime assets', () => {
const fnStart = setupContent.indexOf('create_codex_runtime_root()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart)));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('gstack/SKILL.md');
expect(fnBody).toContain('$codex_gstack/lib');
expect(fnBody).toContain('browse/dist');
expect(fnBody).toContain('browse/bin');
expect(fnBody).toContain('gstack-upgrade/SKILL.md');
// Review runtime assets (individual files, not the whole dir)
expect(fnBody).toContain('checklist.md');
expect(fnBody).toContain('design-checklist.md');
expect(fnBody).toContain('greptile-triage.md');
expect(fnBody).toContain('TODOS-format.md');
expect(fnBody).not.toContain('_link_or_copy "$gstack_dir" "$codex_gstack"');
});
test('create_factory_runtime_root links shared lib modules beside bin', () => {
const fnStart = setupContent.indexOf('create_factory_runtime_root()');
const fnEnd = setupContent.indexOf('create_opencode_runtime_root()', fnStart);
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('$factory_gstack/bin');
expect(fnBody).toContain('$factory_gstack/lib');
});
test('direct Codex installs are migrated out of ~/.codex/skills/gstack', () => {
expect(setupContent).toContain('migrate_direct_codex_install');
expect(setupContent).toContain('$HOME/.gstack/repos/gstack');
expect(setupContent).toContain('avoid duplicate skill discovery');
});
// --- Symlink prefix tests (PR #503) ---
test('link_claude_skill_dirs applies gstack- prefix by default', () => {
const fnStart = setupContent.indexOf('link_claude_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('SKILL_PREFIX');
expect(fnBody).toContain('link_name="gstack-$skill_name"');
});
test('link_claude_skill_dirs preserves already-prefixed dirs', () => {
const fnStart = setupContent.indexOf('link_claude_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
// gstack-* dirs should keep their name (e.g., gstack-upgrade stays gstack-upgrade)
expect(fnBody).toContain('gstack-*) link_name="$skill_name"');
});
test('setup supports --no-prefix flag', () => {
expect(setupContent).toContain('--no-prefix');
expect(setupContent).toContain('SKILL_PREFIX=0');
});
test('cleanup_old_claude_symlinks removes only gstack-pointing symlinks', () => {
expect(setupContent).toContain('cleanup_old_claude_symlinks');
const fnStart = setupContent.indexOf('cleanup_old_claude_symlinks()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('removed[@]}', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
// Should check readlink before removing
expect(fnBody).toContain('readlink');
expect(fnBody).toContain('gstack/*');
// Should skip already-prefixed dirs
expect(fnBody).toContain('gstack-*) continue');
});
test('cleanup runs before link when prefix is enabled', () => {
// In the Claude install section, cleanup should happen before linking
const claudeInstallSection = setupContent.slice(
setupContent.indexOf('INSTALL_CLAUDE'),
setupContent.lastIndexOf('link_claude_skill_dirs')
);
expect(claudeInstallSection).toContain('cleanup_old_claude_symlinks');
});
// --- Persistent config + interactive prompt tests ---
test('setup reads skill_prefix from config', () => {
expect(setupContent).toContain('get skill_prefix');
expect(setupContent).toContain('GSTACK_CONFIG');
});
test('setup supports --prefix flag', () => {
expect(setupContent).toContain('--prefix)');
expect(setupContent).toContain('SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1');
});
test('--prefix and --no-prefix persist to config', () => {
expect(setupContent).toContain('set skill_prefix');
});
test('interactive prompt shows when no config', () => {
expect(setupContent).toContain('Short names');
expect(setupContent).toContain('Namespaced');
expect(setupContent).toContain('Choice [1/2]');
});
test('non-TTY defaults to flat names', () => {
// Should check if stdin is a TTY before prompting
expect(setupContent).toContain('-t 0');
});
test('cleanup_prefixed_claude_symlinks exists and uses readlink', () => {
expect(setupContent).toContain('cleanup_prefixed_claude_symlinks');
const fnStart = setupContent.indexOf('cleanup_prefixed_claude_symlinks()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('removed[@]}', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('readlink');
expect(fnBody).toContain('gstack-$skill_name');
});
test('reverse cleanup runs before link when prefix is disabled', () => {
const claudeInstallSection = setupContent.slice(
setupContent.indexOf('INSTALL_CLAUDE'),
setupContent.lastIndexOf('link_claude_skill_dirs')
);
expect(claudeInstallSection).toContain('cleanup_prefixed_claude_symlinks');
});
test('welcome message references SKILL_PREFIX', () => {
// gstack-upgrade is always called gstack-upgrade (it's the actual dir name)
// but the welcome section should exist near the prefix logic
expect(setupContent).toContain('Run /gstack-upgrade anytime');
});
});
describe('discover-skills hidden directory filtering', () => {
test('discoverTemplates skips dot-prefixed directories', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-discover-'));
try {
// Create a hidden dir with a template (should be excluded)
fs.mkdirSync(path.join(tmpDir, '.hidden'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.hidden', 'SKILL.md.tmpl'), '---\nname: evil\n---\ntest');
// Create a visible dir with a template (should be included)
fs.mkdirSync(path.join(tmpDir, 'visible'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'visible', 'SKILL.md.tmpl'), '---\nname: good\n---\ntest');
const { discoverTemplates } = require('../scripts/discover-skills');
const results = discoverTemplates(tmpDir);
const dirs = results.map((r: { tmpl: string }) => r.tmpl);
expect(dirs).toContain('visible/SKILL.md.tmpl');
expect(dirs).not.toContain('.hidden/SKILL.md.tmpl');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe('telemetry', () => {
test('generated SKILL.md contains telemetry start block', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('_TEL_START');
expect(content).toContain('_SESSION_ID');
expect(content).toContain('TELEMETRY:');
expect(content).toContain('TEL_PROMPTED:');
expect(content).toContain('gstack-config get telemetry');
});
test('generated SKILL.md contains telemetry opt-in prompt', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('.telemetry-prompted');
expect(content).toContain('Help gstack get better');
expect(content).toContain('gstack-config set telemetry community');
expect(content).toContain('gstack-config set telemetry anonymous');
expect(content).toContain('gstack-config set telemetry off');
});
test('generated SKILL.md contains telemetry epilogue', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('Telemetry (run last)');
expect(content).toContain('gstack-telemetry-log');
expect(content).toContain('_TEL_END');
expect(content).toContain('_TEL_DUR');
expect(content).toContain('SKILL_NAME');
expect(content).toContain('OUTCOME');
expect(content).toContain('PLAN MODE EXCEPTION');
});
test('generated SKILL.md contains pending marker handling', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('.pending');
expect(content).toContain('_pending_finalize');
});
test('telemetry blocks appear in all skill files that use PREAMBLE', () => {
const skills = ['qa', 'ship', 'review', 'plan-ceo-review', 'plan-eng-review', 'retro'];
for (const skill of skills) {
const skillPath = path.join(ROOT, skill, 'SKILL.md');
if (fs.existsSync(skillPath)) {
const content = fs.readFileSync(skillPath, 'utf-8');
expect(content).toContain('_TEL_START');
expect(content).toContain('Telemetry (run last)');
}
}
});
});
describe('community fixes wave', () => {
// Helper to get all generated SKILL.md files
function getAllSkillMds(): Array<{ name: string; content: string }> {
const results: Array<{ name: string; content: string }> = [];
const rootPath = path.join(ROOT, 'SKILL.md');
if (fs.existsSync(rootPath)) {
results.push({ name: 'root', content: fs.readFileSync(rootPath, 'utf-8') });
}
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const skillPath = path.join(ROOT, entry.name, 'SKILL.md');
if (fs.existsSync(skillPath)) {
results.push({ name: entry.name, content: fs.readFileSync(skillPath, 'utf-8') });
}
}
return results;
}
// #594 — Discoverability: every SKILL.md.tmpl description contains "gstack"
test('every SKILL.md.tmpl description contains "gstack"', () => {
for (const skill of ALL_SKILLS) {
const tmplPath = skill.dir === '.' ? path.join(ROOT, 'SKILL.md.tmpl') : path.join(ROOT, skill.dir, 'SKILL.md.tmpl');
const content = fs.readFileSync(tmplPath, 'utf-8');
const desc = extractDescription(content);
expect(desc.toLowerCase()).toContain('gstack');
}
});
// #594 — Discoverability: first line of each description is under 120 chars
test('every SKILL.md.tmpl description first line is under 120 chars', () => {
for (const skill of ALL_SKILLS) {
const tmplPath = skill.dir === '.' ? path.join(ROOT, 'SKILL.md.tmpl') : path.join(ROOT, skill.dir, 'SKILL.md.tmpl');
const content = fs.readFileSync(tmplPath, 'utf-8');
const desc = extractDescription(content);
const firstLine = desc.split('\n')[0];
expect(firstLine.length).toBeLessThanOrEqual(120);
}
});
// #573 — Feature signals: ship/SKILL.md contains feature signal detection
test('ship/SKILL.md contains feature signal detection in Step 4', () => {
const content = readShipUnion();
expect(content.toLowerCase()).toContain('feature signal');
});
// #510 — Context warnings: no SKILL.md contains "running low on context"
test('no generated SKILL.md contains "running low on context"', () => {
const skills = getAllSkillMds();
for (const { name, content } of skills) {
expect(content).not.toContain('running low on context');
}
});
// #510 — Context warnings: plan-eng-review has explicit anti-warning
test('plan-eng-review/SKILL.md contains "Do not preemptively warn"', () => {
const content = readSkillUnion('plan-eng-review'); // carved: review body moved to section
expect(content).toContain('Do not preemptively warn');
});
// #474 — Safety Net: no SKILL.md uses find with -delete
test('no generated SKILL.md contains find with -delete flag', () => {
const skills = getAllSkillMds();
for (const { name, content } of skills) {
// Match find commands that use -delete (but not prose mentioning the word "delete")
const lines = content.split('\n');
for (const line of lines) {
if (line.includes('find ') && line.includes('-delete')) {
throw new Error(`${name}/SKILL.md contains find with -delete: ${line.trim()}`);
}
}
}
});
// #467 — Telemetry: preamble JSONL writes are gated by telemetry setting
test('preamble JSONL writes are inside telemetry conditional', () => {
const preamble = fs.readFileSync(path.join(ROOT, 'scripts/resolvers/preamble.ts'), 'utf-8');
// Find all skill-usage.jsonl write lines
const lines = preamble.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('skill-usage.jsonl') && lines[i].includes('>>')) {
// Look backwards for a telemetry conditional within 5 lines
let foundConditional = false;
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
if (lines[j].includes('_TEL') && lines[j].includes('off')) {
foundConditional = true;
break;
}
}
expect(foundConditional).toBe(true);
}
}
});
});
describe('codex commands must not use inline $(git rev-parse --show-toplevel) for cwd', () => {
// Regression test: inline $(git rev-parse --show-toplevel) in codex exec -C
// or codex review without cd evaluates in whatever cwd the background shell
// inherits, which may be a different project in Conductor workspaces.
// The fix is to resolve _REPO_ROOT eagerly at the top of each bash block.
// Scan all source files that could contain codex commands
// Use Bun.Glob to avoid ELOOP from .claude/skills/gstack symlink back to ROOT
const tmplGlob = new Bun.Glob('**/*.tmpl');
const sourceFiles = [
...Array.from(tmplGlob.scanSync({ cwd: ROOT, followSymlinks: false })),
...fs.readdirSync(path.join(ROOT, 'scripts/resolvers'))
.filter(f => f.endsWith('.ts'))
.map(f => `scripts/resolvers/${f}`),
'scripts/gen-skill-docs.ts',
];
test('no codex exec command uses inline $(git rev-parse --show-toplevel) in -C flag', () => {
const violations: string[] = [];
for (const rel of sourceFiles) {
const abs = path.join(ROOT, rel);
if (!fs.existsSync(abs)) continue;
const content = fs.readFileSync(abs, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('codex exec') && line.includes('-C') && line.includes('$(git rev-parse --show-toplevel)')) {
violations.push(`${rel}:${i + 1}`);
}
}
}
expect(violations).toEqual([]);
});
test('no generated SKILL.md has codex exec with inline $(git rev-parse --show-toplevel) in -C flag', () => {
const violations: string[] = [];
const skillMdGlob = new Bun.Glob('**/SKILL.md');
const skillMdFiles = Array.from(skillMdGlob.scanSync({ cwd: ROOT, followSymlinks: false }));
for (const rel of skillMdFiles) {
const abs = path.join(ROOT, rel);
if (!fs.existsSync(abs)) continue;
const content = fs.readFileSync(abs, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('codex exec') && line.includes('-C') && line.includes('$(git rev-parse --show-toplevel)')) {
violations.push(`${rel}:${i + 1}`);
}
}
}
expect(violations).toEqual([]);
});
test('codex review commands must be preceded by cd "$_REPO_ROOT" (no -C support)', () => {
// codex review does not support -C, so the pattern must be:
// _REPO_ROOT=$(git rev-parse --show-toplevel) || { ... }
// cd "$_REPO_ROOT"
// codex review ...
// NOT: codex review ... with inline $(git rev-parse --show-toplevel)
const allFiles = [
...Array.from(tmplGlob.scanSync({ cwd: ROOT, followSymlinks: false })),
...Array.from(new Bun.Glob('**/SKILL.md').scanSync({ cwd: ROOT, followSymlinks: false })),
...fs.readdirSync(path.join(ROOT, 'scripts/resolvers'))
.filter(f => f.endsWith('.ts'))
.map(f => `scripts/resolvers/${f}`),
'scripts/gen-skill-docs.ts',
];
const violations: string[] = [];
for (const rel of allFiles) {
const abs = path.join(ROOT, rel);
if (!fs.existsSync(abs)) continue;
const content = fs.readFileSync(abs, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip non-executable lines (markdown table cells, prose references)
if (line.includes('|') && line.includes('`/codex review`')) continue;
if (line.includes('`codex review`')) continue;
// Check for codex review with inline $(git rev-parse)
if (line.includes('codex review') && line.includes('$(git rev-parse --show-toplevel)')) {
violations.push(`${rel}:${i + 1} — inline git rev-parse in codex review`);
}
}
}
expect(violations).toEqual([]);
});
test('codex review commands take their scope from a flag, never from prompt text', () => {
// `codex review` scope comes ONLY from --base/--commit/--uncommitted. The
// positional [PROMPT] is mutually exclusive with all three (#1428, #1479),
// and a prompt-only `codex review` silently falls back to the *uncommitted
// working-tree* scope (`git status --short; git diff`) — so describing the
// diff range in prompt text produces a confident review of the wrong
// changes, with no error. Both halves are pinned here:
// (a) every `codex review` invocation carries a scope flag, and
// (b) no invocation puts a positional prompt in front of that flag.
//
// This does NOT apply to `codex exec`, which is agentic and really does run
// the git command it's told to — the adversarial pass legitimately scopes
// itself in prompt text.
const checkedFiles = [
'scripts/resolvers/review.ts',
'review/SKILL.md',
'ship/SKILL.md',
'codex/SKILL.md.tmpl',
'codex/SKILL.md',
];
const violations: string[] = [];
for (const rel of checkedFiles) {
// ship's codex/adversarial command moved into sections/adversarial.md (T9 carve).
const content = rel === 'ship/SKILL.md' ? readShipUnion() : fs.readFileSync(path.join(ROOT, rel), 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Only inspect real shell invocations, not prose mentioning the command.
if (line.includes('`codex review`')) continue;
const match = line.match(/(?:^|[;&|]\s*|\s)codex\s+review\b(.*)$/);
if (!match) continue;
const rest = match[1];
const scopeFlag = /--base\b|--commit\b|--uncommitted\b/;
if (!scopeFlag.test(rest)) {
// A quoted prompt with no scope flag is the silent-wrong-scope bug.
if (/^\s*["'$]/.test(rest)) {
violations.push(`${rel}:${i + 1} — prompt-only codex review (falls back to working-tree scope)`);
}
continue;
}
const beforeFlag = rest.split(scopeFlag)[0].trim();
if (/^["'$]|^--\s*["']/.test(beforeFlag)) {
violations.push(`${rel}:${i + 1} — positional prompt passed alongside a scope flag`);
}
}
}
expect(violations).toEqual([]);
});
});
// ─── Learnings + Confidence Resolver Tests ─────────────────────
describe('LEARNINGS_SEARCH resolver', () => {
const SEARCH_SKILLS = ['review', 'ship', 'plan-eng-review', 'investigate', 'office-hours', 'plan-ceo-review'];
for (const skill of SEARCH_SKILLS) {
test(`${skill} generated SKILL.md contains learnings search`, () => {
const content = readSkillUnion(skill); // ship: moved to sections/plan-completion.md
expect(content).toContain('Prior Learnings');
expect(content).toContain('gstack-learnings-search');
});
}
test('learnings search includes cross-project config check', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('cross_project_learnings');
expect(content).toContain('--cross-project');
});
test('learnings search includes AskUserQuestion for first-time cross-project opt-in', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('Enable cross-project learnings');
expect(content).toContain('project-scoped only');
});
test('learnings search mentions prior learning applied display format', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('Prior learning applied');
});
});
describe('LEARNINGS_LOG resolver', () => {
const LOG_SKILLS = ['review', 'retro', 'investigate'];
for (const skill of LOG_SKILLS) {
test(`${skill} generated SKILL.md contains learnings log`, () => {
const content = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
expect(content).toContain('Capture Learnings');
expect(content).toContain('gstack-learnings-log');
});
}
test('learnings log documents all type values', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
for (const type of ['pattern', 'pitfall', 'preference', 'architecture', 'tool']) {
expect(content).toContain(type);
}
});
test('learnings log documents all source values', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
for (const source of ['observed', 'user-stated', 'inferred', 'cross-model']) {
expect(content).toContain(source);
}
});
test('learnings log includes files field for staleness detection', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('"files"');
expect(content).toContain('staleness detection');
});
});
describe('CONFIDENCE_CALIBRATION resolver', () => {
const CONFIDENCE_SKILLS = ['review', 'ship', 'plan-eng-review', 'cso'];
for (const skill of CONFIDENCE_SKILLS) {
test(`${skill} generated SKILL.md contains confidence calibration`, () => {
const content = readSkillUnion(skill); // ship: moved to sections/review-army.md
expect(content).toContain('Confidence Calibration');
expect(content).toContain('confidence score');
});
}
test('confidence calibration includes scoring rubric with all tiers', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('9-10');
expect(content).toContain('7-8');
expect(content).toContain('5-6');
expect(content).toContain('3-4');
expect(content).toContain('1-2');
});
test('confidence calibration includes display rules', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('Show normally');
expect(content).toContain('Suppress from main report');
});
test('confidence calibration includes finding format example', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('[P1] (confidence:');
expect(content).toContain('SQL injection');
});
test('confidence calibration includes calibration learning feedback loop', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('calibration event');
expect(content).toContain('Log the corrected pattern');
});
test('skills without confidence calibration do NOT contain it', () => {
// office-hours and retro do NOT use confidence calibration
for (const skill of ['office-hours', 'retro']) {
const content = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
expect(content).not.toContain('## Confidence Calibration');
}
});
});
describe('gen-skill-docs prefix warning (#620/#578)', () => {
const { execSync } = require('child_process');
test('warns about skill_prefix when config has prefix=true', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prefix-warn-'));
try {
// Create a fake ~/.gstack/config.yaml with skill_prefix: true
const fakeHome = tmpDir;
const fakeGstack = path.join(fakeHome, '.gstack');
fs.mkdirSync(fakeGstack, { recursive: true });
fs.writeFileSync(path.join(fakeGstack, 'config.yaml'), 'skill_prefix: true\n');
const output = execSync('bun run scripts/gen-skill-docs.ts', {
cwd: ROOT,
env: { ...process.env, HOME: fakeHome },
encoding: 'utf-8',
timeout: 30000,
});
expect(output).toContain('skill_prefix is true');
expect(output).toContain('gstack-relink');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('no warning when skill_prefix is false or absent', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prefix-warn-'));
try {
const fakeHome = tmpDir;
const fakeGstack = path.join(fakeHome, '.gstack');
fs.mkdirSync(fakeGstack, { recursive: true });
fs.writeFileSync(path.join(fakeGstack, 'config.yaml'), 'skill_prefix: false\n');
const output = execSync('bun run scripts/gen-skill-docs.ts', {
cwd: ROOT,
env: { ...process.env, HOME: fakeHome },
encoding: 'utf-8',
timeout: 30000,
});
expect(output).not.toContain('skill_prefix is true');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe('voice-triggers processing', () => {
const { extractVoiceTriggers, processVoiceTriggers } = require('../scripts/gen-skill-docs') as {
extractVoiceTriggers: (content: string) => string[];
processVoiceTriggers: (content: string) => string;
};
test('extractVoiceTriggers parses valid YAML list', () => {
const content = `---\nname: cso\ndescription: |\n Security audit.\nvoice-triggers:\n - "see-so"\n - "security review"\n---\nBody`;
const triggers = extractVoiceTriggers(content);
expect(triggers).toEqual(['see-so', 'security review']);
});
test('extractVoiceTriggers returns [] when no field present', () => {
const content = `---\nname: qa\ndescription: |\n QA testing.\n---\nBody`;
expect(extractVoiceTriggers(content)).toEqual([]);
});
test('processVoiceTriggers appends voice triggers to description', () => {
const content = `---\nname: cso\ndescription: |\n Security audit. (gstack)\nvoice-triggers:\n - "see-so"\n - "security review"\n---\nBody`;
const result = processVoiceTriggers(content);
expect(result).toContain('Voice triggers (speech-to-text aliases): "see-so", "security review".');
});
test('processVoiceTriggers strips voice-triggers field from output', () => {
const content = `---\nname: cso\ndescription: |\n Security audit. (gstack)\nvoice-triggers:\n - "see-so"\n---\nBody`;
const result = processVoiceTriggers(content);
expect(result).not.toContain('voice-triggers:');
});
test('processVoiceTriggers returns content unchanged when no voice-triggers', () => {
const content = `---\nname: qa\ndescription: |\n QA testing.\n---\nBody`;
expect(processVoiceTriggers(content)).toBe(content);
});
test('generated CSO SKILL.md contains voice triggers in description', () => {
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
expect(content).toContain('"see-so"');
expect(content).toContain('Voice triggers (speech-to-text aliases):');
});
test('generated CSO SKILL.md does NOT contain raw voice-triggers field', () => {
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
const fmEnd = content.indexOf('\n---', 4);
const frontmatter = content.slice(0, fmEnd);
expect(frontmatter).not.toContain('voice-triggers:');
});
});
describe('plan-mode-info resolver (handshake-replacement)', () => {
const REVIEW_SKILLS = [
'plan-ceo-review',
'plan-eng-review',
'plan-design-review',
'plan-devex-review',
];
// Header for the vestigial handshake that was removed. If it ever reappears,
// someone accidentally re-introduced the resolver.
const HANDSHAKE_MARKER = '## Plan Mode Handshake';
// Header for the new plan-mode-info section (previously lived at the tail
// of completion-status.ts; now hoisted to position 1 of the preamble).
const PLAN_MODE_INFO_MARKER = '## Skill Invocation During Plan Mode';
test('vestigial handshake is absent from all generated Claude SKILL.md files', () => {
// Scan every generated SKILL.md under ROOT (top-level directory per skill).
// Using fs.readdirSync + filter instead of a glob so we catch any skill
// that gets added later without updating this list.
const entries = fs.readdirSync(ROOT, { withFileTypes: true });
let checked = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(ROOT, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
expect(content, `handshake marker in ${entry.name}/SKILL.md`).not.toContain(HANDSHAKE_MARKER);
checked++;
}
expect(checked).toBeGreaterThan(0);
});
test('vestigial handshake is absent from non-Claude host outputs when present on disk', () => {
// Non-Claude hosts render to hostSubdirs (.agents/, .openclaw/, etc). The
// plan-mode-info resolver has no host-scoping — all hosts get the new
// section, none get the old handshake. Scan all candidate host dirs.
const hostDirs = ['.agents', '.openclaw', '.opencode', '.factory', '.hermes', '.kiro', '.cursor', '.slate'];
let checked = 0;
for (const host of hostDirs) {
const skillsRoot = path.join(ROOT, host, 'skills');
if (!fs.existsSync(skillsRoot)) continue;
const entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(skillsRoot, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
expect(content, `handshake marker in ${host}/skills/${entry.name}/SKILL.md`).not.toContain(HANDSHAKE_MARKER);
checked++;
}
}
if (checked === 0) {
// eslint-disable-next-line no-console
console.warn(
'plan-mode-info: no non-Claude host outputs found for cross-host absence check — ' +
'run `bun run gen:skill-docs --host all` to populate',
);
}
});
test.each(REVIEW_SKILLS)(
'%s/SKILL.md contains the new plan-mode-info section near the top',
(skill) => {
const content = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
const idx = content.indexOf(PLAN_MODE_INFO_MARKER);
expect(idx).toBeGreaterThan(0);
// Position 1 in preamble composition = within the first ~300 lines.
// Roughly translates to first ~15KB of text.
expect(idx).toBeLessThan(15_000);
},
);
test('plan-mode-info is wired BEFORE generateUpgradeCheck in preamble', () => {
const content = fs.readFileSync(
path.join(ROOT, 'plan-ceo-review', 'SKILL.md'),
'utf-8',
);
const planModeIdx = content.indexOf(PLAN_MODE_INFO_MARKER);
const upgradeIdx = content.indexOf('UPGRADE_AVAILABLE');
expect(planModeIdx).toBeGreaterThan(0);
expect(upgradeIdx).toBeGreaterThan(0);
expect(planModeIdx).toBeLessThan(upgradeIdx);
});
test('0C-bis STOP block present in plan-ceo-review/SKILL.md', () => {
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
const presentIdx = content.indexOf('Present these approach options via AskUserQuestion');
const preludeIdx = content.indexOf('### 0D-prelude');
expect(presentIdx).toBeGreaterThan(0);
expect(preludeIdx).toBeGreaterThan(presentIdx);
const between = content.slice(presentIdx, preludeIdx);
expect(between).toContain('**STOP.**');
expect(between).toContain('Do NOT proceed to Step 0D or 0F until the user responds to 0C-bis');
});
});
// GSTACK REVIEW REPORT report-at-bottom contract — verifies the prompt-text
// fix in scripts/resolvers/review.ts (the load-bearing change for the
// "report not at bottom of plan in plan mode" bug). The bug is in the
// prompt's contradictory write-flow instructions, not in observable
// runtime behavior we can cheaply gate in CI. Verifying the prompt text
// directly is the deterministic equivalent of the regression test the
// PTY harness can't reliably drive (autoplan needs auto-progression of
// AskUserQuestions to reach the report-write step, which the harness
// doesn't support today).
describe('GSTACK REVIEW REPORT delete-then-append flow', () => {
const PLAN_REVIEW_SKILLS = [
'plan-ceo-review',
'plan-design-review',
'plan-devex-review',
'plan-eng-review',
];
for (const skill of PLAN_REVIEW_SKILLS) {
test(`${skill}/SKILL.md prescribes delete-then-append, not in-place replace`, () => {
// Carved skills (v2 plan Phase B) relocate the review-report prose into
// sections/*.md; readSkillUnion follows the content wherever the carve put it.
const content = readSkillUnion(skill);
// The new (correct) instruction must be present.
expect(content).toContain('delete-then-append flow');
expect(content).toContain('never mid-file');
expect(content).toContain('Do NOT replace the section in place');
// The old contradictory bullets must be gone. The signature phrase
// from the buggy prompt was 'replace it entirely using the Edit tool'
// which is what allowed mid-file reports to stay mid-file.
expect(content).not.toContain('replace it** entirely using the Edit tool');
expect(content).not.toContain('If it was found mid-file, move it');
});
}
test('scripts/resolvers/review.ts source has the rewritten flow', () => {
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'resolvers', 'review.ts'), 'utf-8');
expect(src).toContain('delete-then-append flow');
expect(src).toContain('never mid-file');
expect(src).toContain('Do NOT replace the section in place');
// Old contradictory bullets are gone from the source resolver.
expect(src).not.toContain('replace it** entirely using the Edit tool');
expect(src).not.toContain('If it was found mid-file, move it');
});
});
describe('LEARNINGS_SEARCH resolver: query parameter', () => {
// Lazy-load resolver and types after describe block to keep test file self-contained.
const { generateLearningsSearch } = require('../scripts/resolvers/learnings');
const { HOST_PATHS } = require('../scripts/resolvers/types');
const claudeCtx = {
skillName: 'test',
tmplPath: 'test/SKILL.md.tmpl',
host: 'claude',
paths: HOST_PATHS.claude,
};
const codexCtx = { ...claudeCtx, host: 'codex', paths: HOST_PATHS.codex };
test('no args → bash does not contain --query (backwards-compat)', () => {
const out = generateLearningsSearch(claudeCtx);
expect(out).not.toContain('--query');
});
test('claude host + query=foo bar → both cross-project and project-scoped branches contain --query', () => {
const out = generateLearningsSearch(claudeCtx, ['query=foo bar']);
// Both branches of the if/else must carry the flag.
const lines = out.split('\n').filter(l => l.includes('gstack-learnings-search'));
expect(lines.length).toBeGreaterThanOrEqual(2);
for (const line of lines) {
expect(line).toContain('--query "foo bar"');
}
});
test('codex host + query=foo bar → codex bash variant contains --query', () => {
const out = generateLearningsSearch(codexCtx, ['query=foo bar']);
expect(out).toContain('--query "foo bar"');
expect(out).toContain('$GSTACK_BIN/gstack-learnings-search');
});
test('empty value query= → bash does not contain --query (locked semantics: falls through)', () => {
const claudeOut = generateLearningsSearch(claudeCtx, ['query=']);
expect(claudeOut).not.toContain('--query');
const codexOut = generateLearningsSearch(codexCtx, ['query=']);
expect(codexOut).not.toContain('--query');
});
test('shell-injection chars in query= → throws at gen-time (defense in depth)', () => {
for (const bad of ['$(whoami)', '`cmd`', 'a;b', 'a&b', 'a"b', 'a\\b', 'foo$x']) {
expect(() => generateLearningsSearch(claudeCtx, [`query=${bad}`])).toThrow(/alphanumeric/);
}
});
});
describe('EXIT PLAN MODE GATE placement', () => {
// Fresh skill list — do NOT reuse REVIEW_SKILLS upstream (3 entries, missing plan-devex).
const planSkills = ['plan-eng-review', 'plan-ceo-review', 'plan-design-review', 'plan-devex-review'];
// Strip fenced code blocks before matching headings — PLAN_FILE_REVIEW_REPORT
// already contains `## GSTACK REVIEW REPORT` inside a markdown example fence,
// and the gate text itself shows `## GSTACK REVIEW REPORT` inside a fence too.
const stripFences = (md: string) => md.replace(/```[\s\S]*?```/g, '');
test('gate is the terminal ## heading in every plan-* review SKILL.md', () => {
for (const skill of planSkills) {
const md = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
const stripped = stripFences(md);
const headings = [...stripped.matchAll(/^## .+$/gm)].map(m => m[0]);
const lastH2 = headings.at(-1);
expect(lastH2, `${skill}/SKILL.md last ## heading (fences stripped)`).toBe('## EXIT PLAN MODE GATE (BLOCKING)');
expect(md, `${skill}/SKILL.md gate body`).toContain('Failing this gate and calling ExitPlanMode anyway is a contract violation');
}
});
test('codex/SKILL.md contains gate (mid-file per D5; Step 2B/2C follow)', () => {
const codex = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
expect(codex).toContain('## EXIT PLAN MODE GATE (BLOCKING)');
expect(codex).toContain('Failing this gate and calling ExitPlanMode anyway is a contract violation');
});
});
describe('scope-gate exceptions drift-guard', () => {
// The plan-mode auto-select-B exceptions block is hand-duplicated in the
// plan-eng-review and plan-design-review templates (matching the gate
// around it, which predates this block). The two copies must stay
// byte-identical modulo exactly two known variant slots:
// 1. the plan-mode bullet's action tail (Design Doc Check vs pre-review
// audit + mockups),
// 2. the named-target vocabulary ("a path, a doc" vs "a path, a page, a doc").
// A future edit to one copy that silently misses the other fails here
// instead of drifting. The real fix (shared {{SCOPE_GATE}} resolver) is a
// filed TODO — this guard is the stopgap that makes the duplication safe.
const START_MARKER = '**Exceptions — check in this order, BEFORE asking:**';
const END_MARKER = 'in any mode — it is a hard STOP.';
function extractExceptionsBlock(skill: string): string {
const md = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
const start = md.indexOf(START_MARKER);
expect(start, `${skill}/SKILL.md: exceptions block start marker present`).toBeGreaterThan(-1);
const end = md.indexOf(END_MARKER, start);
expect(end, `${skill}/SKILL.md: exceptions block end marker present`).toBeGreaterThan(start);
return md.slice(start, end + END_MARKER.length);
}
const normalizeVariantSlots = (block: string) =>
block
.replace('Then run the Design Doc Check and Step 0 against that plan.', '<ACTION_TAIL>')
.replace('Then run the pre-review audit, mockups, and Step 0 against that plan.', '<ACTION_TAIL>')
.replace('a path, a page, a doc they pasted,', 'a path, a doc they pasted,');
test('eng and design exceptions blocks are identical modulo the two variant slots', () => {
const eng = normalizeVariantSlots(extractExceptionsBlock('plan-eng-review'));
const design = normalizeVariantSlots(extractExceptionsBlock('plan-design-review'));
expect(eng).toBe(design);
// The action tail must actually have been normalized in both (guards
// against a rewording that bypasses the normalizer and vacuously passes).
expect(eng).toContain('<ACTION_TAIL>');
});
test('exceptions block carries the announcement string the PTY detectors pin', () => {
for (const skill of ['plan-eng-review', 'plan-design-review']) {
const block = extractExceptionsBlock(skill);
expect(block, `${skill}: verbatim announcement`).toContain(
'Scope gate: plan mode — auto-selected B (reviewing <target>).',
);
}
});
test('gate menu carries the question strings the PTY question detector pins', () => {
// isScopeGateQuestionVisible (claude-pty-runner.ts) anchors on the
// question text + option A's body. If the menu is reworded without
// updating the detector, the paid smokes' must-stay-false assertions go
// vacuous — this free pin fails first.
for (const skill of ['plan-eng-review', 'plan-design-review']) {
const md = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
expect(md, `${skill}: gate question text`).toContain('What should I review?');
expect(md, `${skill}: option A body text`).toContain('The current branch diff');
}
});
});
describe('GSTACK REVIEW REPORT mandatory unresolved-decisions status', () => {
// Report text rides in PLAN_FILE_REVIEW_REPORT → every report consumer gets it.
// devex-review is a report consumer but NOT a gate consumer, so the two target
// sets differ (CP5/CX5). Regression guard: a future token-cut that drops the
// unresolved-status line again fails here. See plan-flag-unresolved-issues.
const REPORT_CONSUMERS = [
'plan-ceo-review',
'plan-eng-review',
'plan-design-review',
'plan-devex-review',
'codex',
'devex-review',
];
// Gate text rides in EXIT_PLAN_MODE_GATE (lives in SKILL.md, not sections).
const GATE_SKILLS = [
'plan-ceo-review',
'plan-eng-review',
'plan-design-review',
'plan-devex-review',
'codex',
];
for (const skill of REPORT_CONSUMERS) {
test(`${skill}: report mandates the unresolved-decisions status as final content`, () => {
const content = readSkillUnion(skill);
expect(content).toContain('NO UNRESOLVED DECISIONS');
// The "never omit / always final" contract must be present, not just the phrase.
expect(content).toContain('Unresolved-decisions status (MANDATORY');
expect(content).toMatch(/never omitted/);
// \s+ tolerates prose line-wraps within "final non-whitespace line".
expect(content).toMatch(/final\s+non-whitespace\s+line/);
});
}
for (const skill of GATE_SKILLS) {
test(`${skill}: exit gate blocks unless the unresolved status is the final line`, () => {
const md = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
// Gate check #4 — present, sentinel named, and explicitly blocking (no escape).
expect(md).toContain('NO UNRESOLVED DECISIONS');
expect(md).toContain('FINAL non-whitespace line is the unresolved-decisions');
expect(md).toContain('FAILS the gate');
});
}
test('scripts/resolvers/review.ts source carries the mandatory block + blocking gate', () => {
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'resolvers', 'review.ts'), 'utf-8');
// Report resolver: mandatory, never-omitted, exact sentinel, anti-double-count algorithm.
expect(src).toContain('Unresolved-decisions status (MANDATORY');
expect(src).toContain('NO UNRESOLVED DECISIONS');
expect(src).toContain('avoids double-counting');
expect(src).toContain('DROP the current skill');
// Gate resolver: the blocking final-line check with no "if applicable" escape.
expect(src).toContain('FINAL non-whitespace line is the unresolved-decisions');
expect(src).toContain('FAILS the gate');
// The old soft wording must be gone from the gate.
expect(src).not.toContain('absorbs CODEX / CROSS-MODEL / UNRESOLVED lines if applicable');
});
});
// ─── {{PREAMBLE}} requires an explicit preamble-tier ────────
describe('PREAMBLE resolution requires declared preamble-tier', () => {
test('resolving {{PREAMBLE}} without preamble-tier throws with the template path', async () => {
const { generatePreamble } = await import('../scripts/resolvers/preamble');
const { HOST_PATHS } = await import('../scripts/resolvers/types');
const ctx = {
skillName: 'tierless-skill',
tmplPath: 'tierless-skill/SKILL.md.tmpl',
host: 'claude' as const,
paths: HOST_PATHS.claude,
// preambleTier deliberately absent — the generator must refuse to default it.
};
expect(() => generatePreamble(ctx)).toThrow(/tierless-skill\/SKILL\.md\.tmpl/);
expect(() => generatePreamble(ctx)).toThrow(/preamble-tier/);
});
test('every template that resolves {{PREAMBLE}} declares preamble-tier in frontmatter', () => {
const entries = fs.readdirSync(ROOT, { withFileTypes: true });
const offenders: string[] = [];
const checkTmpl = (tmplPath: string) => {
const tmpl = fs.readFileSync(tmplPath, 'utf-8');
if (tmpl.includes('{{PREAMBLE}}') && !/^preamble-tier:\s*\d+$/m.test(tmpl)) {
offenders.push(path.relative(ROOT, tmplPath));
}
};
checkTmpl(path.join(ROOT, 'SKILL.md.tmpl'));
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
const tmplPath = path.join(ROOT, e.name, 'SKILL.md.tmpl');
if (fs.existsSync(tmplPath)) checkTmpl(tmplPath);
}
expect(offenders).toEqual([]);
});
});