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>
This commit is contained in:
authored and GitHub committed 2026-08-15 11:42:19 -07:00
1 parent c118e2402e
commit 2be6c06ba8
202 files changed
+11002 -1163

No files matched your search

-6
View File
@@ -109,12 +109,6 @@ describe('Audit compliance', () => {
});
// Round 2 Fix 4: Chrome CDP binds to localhost only
test('chrome-cdp binds to localhost only', () => {
const cdp = readFileSync(join(ROOT, 'bin/chrome-cdp'), 'utf-8');
expect(cdp).toContain('--remote-debugging-address=127.0.0.1');
expect(cdp).toContain('--remote-allow-origins=');
});
// Fix 2+6: All generated SKILL.md files with telemetry are conditional
test('all generated SKILL.md files with telemetry calls use conditional pattern', () => {
const skills = getAllSkillMds();
+151
View File
@@ -0,0 +1,151 @@
/**
* Free unit tests for the ClaudeAdapter auth sniff in
* test/helpers/providers/claude.ts — specifically the macOS Keychain branch
* (#1890): the default subscription install stores OAuth under the
* generic-password service "Claude Code-credentials" and never writes
* ~/.claude/.credentials.json, so availability must consult
* `security find-generic-password -s "Claude Code-credentials"` when neither
* the creds file nor ANTHROPIC_API_KEY exists.
*
* The `security` spawn is darwin-gated in the adapter (the probe only runs
* when process.platform === 'darwin'), so the keychain-branch tests skip
* elsewhere; the creds-file / env-key / no-auth verdicts run on every
* platform.
*
* Harness note: available() spawns `security` without an explicit `env:`, and
* Bun resolves such spawns against the PROCESS STARTUP env — mutating
* process.env.PATH in-test cannot shadow the real /usr/bin/security (verified;
* same Bun property lib/gbrain-exec.ts documents for DATABASE_URL). So each
* case runs available() in a child `bun -e` whose env (and PATH shim) the test
* fully controls; the fake `security` logs its argv, so the operator's real
* Keychain is never consulted and a logged-in machine can't false-pass the
* "no auth" cases. cwd is the fake home so Bun doesn't autoload the repo .env
* (which defines ANTHROPIC_API_KEY).
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { spawnSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
const ADAPTER = path.join(import.meta.dir, "helpers", "providers", "claude.ts");
describe("ClaudeAdapter.available() — auth sniff incl. macOS Keychain branch (#1890)", () => {
let fakeHome: string;
let shimDir: string;
let securityLog: string;
beforeEach(() => {
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-home-"));
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-bin-"));
securityLog = path.join(shimDir, "security-argv.log");
});
afterEach(() => {
fs.rmSync(fakeHome, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
});
/** Fake `security` that logs its argv and exits with `exitCode`. */
function writeFakeSecurity(exitCode: number): void {
fs.writeFileSync(
path.join(shimDir, "security"),
`#!/bin/sh
printf '%s\\n' "$*" >> "${securityLog}"
exit ${exitCode}
`,
{ mode: 0o755 },
);
}
/** Run `new ClaudeAdapter().available()` in a child bun with a controlled env. */
function runAvailable(opts: { anthropicKey?: string; bareShimPath?: boolean } = {}): { ok: boolean; reason?: string } {
const env: Record<string, string | undefined> = {
...process.env,
// Shim dir first so a fake `security` shadows /usr/bin/security; the
// bare variant drops the inherited PATH entirely (no `claude` findable).
PATH: opts.bareShimPath ? shimDir : `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`,
// os.homedir() honors $HOME (POSIX) / %USERPROFILE% (Windows), so the
// adapter's ~/.claude/.credentials.json check reads the fake home.
HOME: fakeHome,
USERPROFILE: fakeHome,
// Absolute-path override satisfies resolveClaudeCommand() without a real
// claude install — available() never spawns it, only resolves it. The
// bare-PATH case clears it to exercise the not-found branch.
GSTACK_CLAUDE_BIN: opts.bareShimPath ? undefined : path.join(shimDir, "claude"),
CLAUDE_BIN: undefined,
GSTACK_CLAUDE_BIN_ARGS: undefined,
CLAUDE_BIN_ARGS: undefined,
ANTHROPIC_API_KEY: opts.anthropicKey,
};
for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k];
const driver = `const { ClaudeAdapter } = await import(${JSON.stringify(ADAPTER)});
const check = await new ClaudeAdapter().available();
console.log(JSON.stringify(check));`;
// process.execPath (absolute bun binary): the bare-PATH case strips the
// inherited PATH, so a name-based "bun" lookup would ENOENT.
const res = spawnSync(process.execPath, ["-e", driver], {
encoding: "utf-8",
timeout: 30_000,
cwd: fakeHome, // no .env here — the repo root's would inject ANTHROPIC_API_KEY
env: env as Record<string, string>,
});
if (res.status !== 0) throw new Error(`driver failed (${res.status}): ${res.stderr}`);
return JSON.parse(res.stdout.trim()) as { ok: boolean; reason?: string };
}
test("creds file present → available, and the Keychain is never consulted", () => {
fs.mkdirSync(path.join(fakeHome, ".claude"), { recursive: true });
fs.writeFileSync(path.join(fakeHome, ".claude", ".credentials.json"), "{}");
// A fake security that would report NOT FOUND — if the probe ran anyway,
// ok would still be true, so the no-spawn pin is the argv log staying empty.
writeFakeSecurity(1);
const check = runAvailable();
expect(check.ok).toBe(true);
expect(fs.existsSync(securityLog)).toBe(false);
});
test("ANTHROPIC_API_KEY set → available without creds file or Keychain probe", () => {
writeFakeSecurity(1);
const check = runAvailable({ anthropicKey: "sk-ant-test-not-a-real-key" });
expect(check.ok).toBe(true);
expect(fs.existsSync(securityLog)).toBe(false);
});
test("darwin: Keychain hit (security exit 0) → available, probed with the exact service name", () => {
if (process.platform !== "darwin") return; // keychain branch is darwin-gated in the adapter
writeFakeSecurity(0);
const check = runAvailable();
expect(check.ok).toBe(true);
// Exactly one metadata-only probe, against the service subscription installs use.
const argv = fs.readFileSync(securityLog, "utf-8").trim().split("\n");
expect(argv).toEqual(["find-generic-password -s Claude Code-credentials"]);
expect(argv[0]).not.toContain("-w"); // never reads the secret itself
});
test("darwin: Keychain miss (security exit 1) → not available with the no-auth reason", () => {
if (process.platform !== "darwin") return;
writeFakeSecurity(1);
const check = runAvailable();
expect(check.ok).toBe(false);
expect(check.reason).toContain("No Claude auth found");
// The probe DID run (this is the miss path, not a skipped probe).
expect(fs.readFileSync(securityLog, "utf-8")).toContain("find-generic-password");
});
test("non-darwin: no creds file + no env key → not available (no Keychain to consult)", () => {
if (process.platform === "darwin") return; // darwin covered by the shimmed miss case above
const check = runAvailable();
expect(check.ok).toBe(false);
expect(check.reason).toContain("No Claude auth found");
});
test("claude CLI unresolvable → not-found reason, before any auth sniff", () => {
writeFakeSecurity(0); // even a keychain HIT can't rescue a missing binary
const check = runAvailable({ bareShimPath: true });
expect(check.ok).toBe(false);
expect(check.reason).toContain("claude CLI not found on PATH");
expect(fs.existsSync(securityLog)).toBe(false); // returned before the auth sniff
});
});
File diff suppressed because it is too large. Load diff
+34
View File
@@ -152,6 +152,40 @@ describe("upstream fix a: root-as-container + walkMd exclusions", () => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("totalMd: a container skill excludes nested child skills' bytes; the grand total sums per-skill with no overlap", () => {
// Regression pin for the v1.63 double-count: totalMd on a skill dir that
// CONTAINS other skill dirs (the gstack root wraps the whole tree) used to
// swallow the children's .md bytes too, so the TOTAL line billed every
// nested skill twice. A revert of the topSeg/SKILL.md skip in totalMd
// (lib/context-bill.ts) must fail here.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-total-"));
fs.writeFileSync(path.join(tmp, "SKILL.md"), "---\nname: parent\ndescription: p\n---\n# Parent\n");
fs.writeFileSync(path.join(tmp, "NOTES.md"), "n".repeat(1_000));
// A non-skill subdir (no SKILL.md) still belongs to the parent's total.
fs.mkdirSync(path.join(tmp, "references"));
fs.writeFileSync(path.join(tmp, "references", "GUIDE.md"), "g".repeat(2_000));
// Nested child skill with a LARGE .md — the bytes a revert double-counts.
fs.mkdirSync(path.join(tmp, "child"));
fs.writeFileSync(path.join(tmp, "child", "SKILL.md"), "---\nname: child\ndescription: c\n---\n# Child\n");
fs.writeFileSync(path.join(tmp, "child", "BIG.md"), "x".repeat(50_000));
const bill = buildBill(tmp);
const parent = bill.skills.find((s) => s.name !== "child")!;
const child = bill.skills.find((s) => s.name === "child")!;
expect(bill.skills).toHaveLength(2);
const parentOwn =
fileBytes(tmp, "SKILL.md") + fileBytes(tmp, "NOTES.md") + fileBytes(tmp, "references", "GUIDE.md");
const childOwn = fileBytes(tmp, "child", "SKILL.md") + fileBytes(tmp, "child", "BIG.md");
// Parent's total is its OWN files only — the child's 50KB is excluded.
expect(parent.totalMdBytes).toBe(parentOwn);
expect(child.totalMdBytes).toBe(childOwn);
// Grand total = sum of per-skill figures, every byte billed exactly once.
expect(bill.totals.totalMdBytes).toBe(parentOwn + childOwn);
expect(bill.totals.totalMdBytes).toBe(parent.totalMdBytes + child.totalMdBytes);
fs.rmSync(tmp, { recursive: true, force: true });
});
it("walkMd skips node_modules and dot-directories", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-walk-"));
fs.writeFileSync(path.join(tmp, "real.md"), "x");
+7
View File
@@ -67,6 +67,13 @@ const MODULE_SINKS = [
'bin/gstack-gbrain-sync.ts',
'bin/gstack-memory-ingest.ts',
'browse/src/server.ts',
// Code-intelligence adapters (fork port wave 2): the gbrain adapter shells
// repo content to the user's gbrain DB and the Sourcebot adapter POSTs
// queries to a self-hosted HTTP endpoint — both sensitive-class
// (repo-content) sinks, fail-closed. Registered so a refactor that drops
// their writeReceipt calls fails CI, not just the tree-sweep scanner.
'lib/code-intelligence/gbrain-adapter.ts',
'lib/code-intelligence/sourcebot-adapter.ts',
// Unconditional: context-bill ships in the same tree as this tripwire. A
// missing file must fail loudly (a rename/move that drops its receipt wiring
// is exactly what this pins), not silently soften the assertion.
+23
View File
@@ -0,0 +1,23 @@
/** OV11: the host-neutral eval-model resolver's contract. */
import { describe, test, expect } from "bun:test";
import { resolveEvalModel } from "../lib/eval-model";
describe("resolveEvalModel", () => {
test("explicit argument wins over everything", () => {
expect(resolveEvalModel("capture", "my-model", { GSTACK_EVAL_MODEL: "x" } as never)).toBe("my-model");
});
test("per-kind env beats the global env", () => {
expect(resolveEvalModel("warmup", null, { GSTACK_EVAL_MODEL_WARMUP: "w", GSTACK_EVAL_MODEL: "g" } as never)).toBe("w");
});
test("global env beats the default", () => {
expect(resolveEvalModel("distill", null, { GSTACK_EVAL_MODEL: "g" } as never)).toBe("g");
});
test("defaults per kind", () => {
expect(resolveEvalModel("capture", null, {} as never)).toBe("claude-opus-4-7");
expect(resolveEvalModel("warmup", null, {} as never)).toBe("claude-haiku-4-5");
expect(resolveEvalModel("distill", null, {} as never)).toBe("claude-haiku-4-5-20251001");
});
test("unknown kind throws instead of silently defaulting", () => {
expect(() => resolveEvalModel("banana" as never, null, {} as never)).toThrow();
});
});
+31
View File
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one 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. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@@ -808,6 +812,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
## Third-Party Web Actions
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
## Step 0: Detect platform and base branch
First, detect the git hosting platform from the remote URL:
@@ -895,6 +913,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| When | Read this section |
|------|-------------------|
| the ship target is an Apple platform app (.xcodeproj, .xcworkspace, or an app-product Swift package) — read BEFORE Step 1's branch gate and any preflight; store distribution never routes through the branch/PR ceremony | `sections/apple-release.md` |
| running the test suites and (if prompt files changed) the eval suites (Steps 4-6) | `sections/tests.md` |
| auditing test coverage of the diff (Step 7) | `sections/test-coverage.md` |
| auditing plan completion, verification, and scope drift (Step 8) | `sections/plan-completion.md` |
@@ -906,6 +925,18 @@ sections. Read a section in full before doing its step; do not work from memory.
---
## Step 0.9: Apple target detection
Shipping to the App Store is not landing a PR. If the repository contains an
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
user's ask is store distribution (App Store, TestFlight, "release my app"),
**STOP and Read `~/.claude/skills/gstack/ship/sections/apple-release.md` FIRST**
— before the branch gate and any preflight below. Store distribution proceeds
from whatever branch the user is on (a clean tree on the base branch is the
solo developer's normal case, not an error) and follows the adapter end to
end. The branch gate and repository-landing pipeline below apply ONLY to
repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
+98 -23
View File
@@ -664,6 +664,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one 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. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@@ -794,6 +798,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
## Third-Party Web Actions
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
## Step 0: Detect platform and base branch
First, detect the git hosting platform from the remote URL:
@@ -878,6 +896,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
---
## Step 0.9: Apple target detection
Shipping to the App Store is not landing a PR. If the repository contains an
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
user's ask is store distribution (App Store, TestFlight, "release my app"),
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
— before the branch gate and any preflight below. Store distribution proceeds
from whatever branch the user is on (a clean tree on the base branch is the
solo developer's normal case, not an error) and follows the adapter end to
end. The branch gate and repository-landing pipeline below apply ONLY to
repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
@@ -999,41 +1029,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
## Test Framework Bootstrap
**Detect existing test framework and project runtime:**
**Read the project's AGENTS.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
[ -f composer.json ] && echo "RUNTIME:php"
[ -f mix.exs ] && echo "RUNTIME:elixir"
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Detect sub-frameworks
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
# Existing test path — config files, declared scripts, AND test FILES.
# A project with real tests and no config file is the common miss.
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
# Rust keeps unit tests inside src/, so file names alone miss them
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
# Check opt-out marker
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
```
**If test framework detected** (config files or test directories found):
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
Map the markers to the command you will OFFER — never to one you run on a guess:
| Marker | Ecosystem | Candidate command to offer |
|--------|-----------|----------------------------|
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
| `Cargo.toml` | Rust | `cargo test` |
| `pom.xml` | JVM (Maven) | `mvn test` |
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
| `mix.exs` | Elixir | `mix test` |
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
| `Makefile` with a `test:` target | any | `make test` |
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — AGENTS.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to AGENTS.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
**If NO runtime detected** (no config files found): Use AskUserQuestion:
**If NO ecosystem marker matched:** Use AskUserQuestion:
"I couldn't detect your project's language. What runtime are you using?"
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
**If runtime detected but no test framework — bootstrap:**
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
### B2. Research best practices
@@ -1049,7 +1108,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
| Node.js | vitest + @testing-library | jest + @testing-library |
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
| Python | pytest + pytest-cov | unittest |
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
| Go | stdlib testing + testify | stdlib only |
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
| Rust | cargo test (built-in) + mockall | — |
| PHP | phpunit + mockery | pest |
| Elixir | ExUnit (built-in) + ex_machina | — |
@@ -1378,15 +1439,20 @@ Before analyzing coverage, detect the project's test framework:
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Detect project runtime (markers are evidence, not commands to run blind)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Check for existing test infrastructure — config files, scripts, AND test files
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
```
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
@@ -1818,16 +1884,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
### 2. Check for running dev server
Before invoking browse-based verification, check if a dev server is reachable:
Before invoking browse-based verification, find the dev-server URL the way the
project declares it — never trust a hardcoded port list alone:
1. **AGENTS.md first:** look for a documented dev URL or dev command (a
`## Development`/`## Testing` section naming a port or URL). Use it.
2. **The plan file:** if the plan's verification section names a URL, use it.
3. **Fallback probe** (common ports, only when 1-2 found nothing):
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
for _p in 3000 8080 5173 4000 4321 8000; do
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
done
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
```
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
**If NO_SERVER:** Skip with "No dev server detected (checked AGENTS.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in AGENTS.md so this step finds it next time."
### 3. Invoke /qa-only inline
@@ -2579,6 +2652,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
+98 -23
View File
@@ -666,6 +666,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one 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. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@@ -796,6 +800,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
## Third-Party Web Actions
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
## Step 0: Detect platform and base branch
First, detect the git hosting platform from the remote URL:
@@ -880,6 +898,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
---
## Step 0.9: Apple target detection
Shipping to the App Store is not landing a PR. If the repository contains an
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
user's ask is store distribution (App Store, TestFlight, "release my app"),
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
— before the branch gate and any preflight below. Store distribution proceeds
from whatever branch the user is on (a clean tree on the base branch is the
solo developer's normal case, not an error) and follows the adapter end to
end. The branch gate and repository-landing pipeline below apply ONLY to
repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
@@ -1001,41 +1031,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
## Test Framework Bootstrap
**Detect existing test framework and project runtime:**
**Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
[ -f composer.json ] && echo "RUNTIME:php"
[ -f mix.exs ] && echo "RUNTIME:elixir"
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Detect sub-frameworks
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
# Existing test path — config files, declared scripts, AND test FILES.
# A project with real tests and no config file is the common miss.
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
# Rust keeps unit tests inside src/, so file names alone miss them
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
# Check opt-out marker
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
```
**If test framework detected** (config files or test directories found):
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
Map the markers to the command you will OFFER — never to one you run on a guess:
| Marker | Ecosystem | Candidate command to offer |
|--------|-----------|----------------------------|
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
| `Cargo.toml` | Rust | `cargo test` |
| `pom.xml` | JVM (Maven) | `mvn test` |
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
| `mix.exs` | Elixir | `mix test` |
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
| `Makefile` with a `test:` target | any | `make test` |
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
**If NO runtime detected** (no config files found): Use AskUserQuestion:
**If NO ecosystem marker matched:** Use AskUserQuestion:
"I couldn't detect your project's language. What runtime are you using?"
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
**If runtime detected but no test framework — bootstrap:**
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
### B2. Research best practices
@@ -1051,7 +1110,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
| Node.js | vitest + @testing-library | jest + @testing-library |
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
| Python | pytest + pytest-cov | unittest |
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
| Go | stdlib testing + testify | stdlib only |
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
| Rust | cargo test (built-in) + mockall | — |
| PHP | phpunit + mockery | pest |
| Elixir | ExUnit (built-in) + ex_machina | — |
@@ -1380,15 +1441,20 @@ Before analyzing coverage, detect the project's test framework:
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Detect project runtime (markers are evidence, not commands to run blind)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Check for existing test infrastructure — config files, scripts, AND test files
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
```
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
@@ -1820,16 +1886,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
### 2. Check for running dev server
Before invoking browse-based verification, check if a dev server is reachable:
Before invoking browse-based verification, find the dev-server URL the way the
project declares it — never trust a hardcoded port list alone:
1. **CLAUDE.md first:** look for a documented dev URL or dev command (a
`## Development`/`## Testing` section naming a port or URL). Use it.
2. **The plan file:** if the plan's verification section names a URL, use it.
3. **Fallback probe** (common ports, only when 1-2 found nothing):
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
for _p in 3000 8080 5173 4000 4321 8000; do
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
done
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
```
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
**If NO_SERVER:** Skip with "No dev server detected (checked CLAUDE.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in CLAUDE.md so this step finds it next time."
### 3. Invoke /qa-only inline
@@ -2995,6 +3068,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
@@ -97,10 +97,12 @@ public final class StateServer {
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
// The daemon will rotate immediately; this log line is dead within
// seconds.
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
// token from the 0600 file above (copyFileFromAppContainer); the
// os_log line that used to carry it had no consumer and handed a
// live credential to anything reading the unified log during the
// launch window. Port/build stay for diagnostics.
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
@@ -149,7 +151,19 @@ public final class StateServer {
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
// loopback at the socket level; IPv6 keeps the wildcard bind and
// relies on the per-connection peer check below for tunnel peers.
let listener: NWListener
switch family {
case .ipv4:
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(rawValue: port)!)
listener = try NWListener(using: params)
case .ipv6:
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
}
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state {
+77
View File
@@ -0,0 +1,77 @@
/**
* #538: the office-hours founder-resources pitch takes no for an answer.
*
* The reporter found that memory instructions telling the agent to stop
* showing the 34-resource pool kept being overridden on every update. The
* fix is a config key — `founder_resources` — that session context cannot
* override: `false` skips the entire section silently, forever, until the
* user re-enables it.
*
* Pins: (1) the config key's default, persistence, and validation through
* the real bin/gstack-config subprocess; (2) the generated section gates on
* the key BEFORE any resource content; (3) the opt-out write is verified
* before the skill may promise "never again" (R6 — a failed write must not
* produce a false promise).
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
const ROOT = path.resolve(import.meta.dir, "..");
const CONFIG_BIN = path.join(ROOT, "bin", "gstack-config");
const SECTION = path.join(ROOT, "office-hours", "sections", "design-and-handoff.md");
let tmpHome: string;
beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-538-")); });
afterEach(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); });
function cfg(args: string[]): string {
return execFileSync(CONFIG_BIN, args, {
encoding: "utf-8",
env: { ...process.env, GSTACK_HOME: tmpHome },
}).trim();
}
describe("founder_resources config key (#538)", () => {
test("defaults to true (pitch stays on for everyone who never opted out)", () => {
expect(cfg(["get", "founder_resources"])).toBe("true");
});
test("set false persists and reads back — the write-verify contract", () => {
cfg(["set", "founder_resources", "false"]);
expect(cfg(["get", "founder_resources"])).toBe("false");
});
test("invalid values are rejected to the default, never persisted as-is", () => {
execFileSync(CONFIG_BIN, ["set", "founder_resources", "banana"], {
encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, GSTACK_HOME: tmpHome },
});
expect(cfg(["get", "founder_resources"])).toBe("true");
});
});
describe("office-hours section gates on the key (#538)", () => {
const src = fs.readFileSync(SECTION, "utf-8");
test("the opt-out check precedes any resource content", () => {
const gate = src.indexOf("gstack-config get founder_resources");
const pool = src.indexOf("Resource Pool");
expect(gate).toBeGreaterThan(-1);
expect(pool).toBeGreaterThan(-1);
expect(gate).toBeLessThan(pool);
});
test("skip is silent and permanent — never means never", () => {
expect(src).toContain("skip this entire section silently");
expect(src).toContain("gstack-config set founder_resources true");
});
test("the opt-out write is verified before any promise (R6)", () => {
expect(src).toContain("VERIFY the write");
expect(src).toMatch(/read back\s*\n?`false`/);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* CI secret gate contract (R4/R9, fork port wave 2).
*
* .github/scripts/gate-secret-scan.mjs pipes a unified diff's ADDED lines
* into bin/gstack-redact and enforces: HIGH fails (exit 1), MEDIUM is an
* advisory count only (no human in CI to confirm, so it must never fail
* the check), clean passes. The workflow-level pathspec excludes keep the
* planted-bug fixtures out of the diff entirely; this pins the script's
* own exit contract with live subprocess runs.
*/
import { describe, test, expect } from "bun:test";
import { spawnSync } from "child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const SCRIPT = join(ROOT, ".github", "scripts", "gate-secret-scan.mjs");
function scan(diff: string, cwd: string = ROOT): { code: number; out: string } {
const res = spawnSync("node", [SCRIPT], {
cwd,
input: diff,
encoding: "utf-8",
timeout: 60_000,
});
return { code: res.status ?? -1, out: `${res.stdout}${res.stderr}` };
}
describe("gate-secret-scan.mjs exit contract", () => {
test("clean added lines pass", () => {
const r = scan("+const x = 1;\n+++ b/file.ts\n+// harmless\n");
expect(r.code).toBe(0);
expect(r.out).toContain("0 high");
});
// The planted PEM is assembled at runtime — header split included — so this
// FILE never carries a live-format private key: the repo's own prepush
// credential guard scans pushed diffs and (correctly) blocks any one-line
// BEGIN…END spelling regardless of body. The scanner under test still
// receives the true live shape.
const PEM_BEGIN = ["-----BEGIN RSA ", "PRIVATE KEY-----"].join("");
const PEM_END = ["-----END RSA ", "PRIVATE KEY-----"].join("");
const PLANTED_PEM_BODY = ["MIIEow", "IBAAKC", "AQEA"].join("");
test("a HIGH credential in an added line fails the gate", () => {
const r = scan(
`+${PEM_BEGIN}\n+${PLANTED_PEM_BODY}\n+${PEM_END}\n`,
);
expect(r.code).toBe(1);
expect(r.out).toContain("1 high");
});
test("removed lines and context are ignored — only additions are scanned", () => {
const r = scan(
`-${PEM_BEGIN}\n-${PLANTED_PEM_BODY}\n${PEM_END}\n+just an addition\n`,
);
expect(r.code).toBe(0);
});
test("MEDIUM findings are advisory only — never fail CI", () => {
// A Stripe publishable-key shape sits at MEDIUM in the taxonomy
// (context-variable; a human confirms interactively, CI cannot).
const r = scan(`+const key = "pk_live_${"a".repeat(24)}";\n`);
expect(r.code).toBe(0);
expect(r.out).toMatch(/\d+ advisory/);
});
});
describe("gate-secret-scan.mjs fail-closed legs", () => {
test("oversize diff (report.oversize) fails the gate", () => {
// The script pins --max-bytes 16000000; bin/gstack-redact refuses to scan
// anything larger and reports oversize:true (fail-closed). The gate must
// exit 1 rather than pass unscanned bytes. ~17MB of added lines guarantees
// the joined additions exceed the cap.
const line = `+${"a".repeat(8190)}\n`;
const r = scan(line.repeat(2100));
expect(r.code).toBe(1);
// Proves the failure came from the parsed report (the engine surfaces
// oversize as a fail-closed HIGH), not from a crashed subprocess.
expect(r.out).toContain("1 high");
}, 60_000);
test("unexpected gstack-redact exit code fails the gate even when the report is clean", () => {
// Stub bin/gstack-redact that emits a CLEAN JSON report but exits 1 —
// not one of the contract codes (0 clean / 2 MEDIUM / 3 HIGH). The gate
// must treat the unexpected exit as failure: a broken scanner reporting
// "all clear" is exactly the fail-open shape this leg guards against.
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-stub-"));
try {
mkdirSync(join(dir, "bin"));
writeFileSync(
join(dir, "bin", "gstack-redact"),
[
"#!/usr/bin/env bun",
'let input = "";',
'process.stdin.setEncoding("utf8");',
'process.stdin.on("data", (c) => { input += c; });',
'process.stdin.on("end", () => {',
' console.log(JSON.stringify({ findings: [], counts: { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 }, repoVisibility: "public", oversize: false }));',
" process.exit(1);",
"});",
"",
].join("\n"),
);
const r = scan("+const x = 1;\n", dir);
expect(r.out).toContain("0 high"); // the clean report WAS parsed...
expect(r.code).toBe(1); // ...and the gate still failed on the exit code
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("missing gstack-redact (spawn crash, empty stdout) exits nonzero — never fail-open", () => {
// cwd with no bin/gstack-redact at all: bun exits module-not-found with
// empty stdout. Whatever the exact failure shape, the gate must not
// report success.
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-absent-"));
try {
const r = scan("+const x = 1;\n", dir);
expect(r.code).not.toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+111 -111
View File
@@ -24,6 +24,7 @@ import {
mkdirSync,
writeFileSync,
readFileSync,
existsSync,
rmSync,
chmodSync,
} from "fs";
@@ -39,24 +40,6 @@ interface FakeEnv {
cleanup: () => void;
}
interface ShellCase {
name: "bash" | "zsh";
command: string;
available: boolean;
}
function shellAvailable(command: string): boolean {
const result = spawnSync(command, ["-c", "exit 0"], {
stdio: "ignore",
});
return result.status === 0;
}
const SHELLS: ShellCase[] = [
{ name: "bash", command: "bash", available: shellAvailable("bash") },
{ name: "zsh", command: "zsh", available: shellAvailable("zsh") },
];
function makeFakeEnv(): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-voyage-init-"));
const home = join(tmp, "home");
@@ -69,13 +52,8 @@ function makeFakeEnv(): FakeEnv {
// succeeds on init (writes a sentinel pglite config), and returns canned
// output for --version. Nothing else is needed for the shape test.
const fake = `#!/bin/sh
{
echo "__CALL__"
for arg in "$@"; do
printf 'arg=%s\\n' "$arg"
done
echo "__END__"
} >> "${argvLog}"
echo "$@" >> "${argvLog}"
echo "$#" >> "${argvLog}.argc"
case "$1" in
--version)
echo "gbrain 0.37.1.0"
@@ -104,11 +82,11 @@ exit 0
}
/**
* Verbatim reimplementation of the skill template's voyage-code-3 conditional.
* The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5 inside the
* rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the model to execute
* this shell; we execute the same shell here and assert the argv passed to
* gbrain matches the contract under both bash and zsh.
* Verbatim reimplementation of the skill template's voyage-code-3
* conditional. The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5
* inside the rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the
* model to execute this bash; we execute the same bash here and assert the
* argv passed to gbrain matches the contract.
*
* If the template changes the flag set or the env-var name, this test
* should fail until the shell here is updated too — by design.
@@ -116,15 +94,19 @@ exit 0
function runInitWithVoyageGate(
env: FakeEnv,
voyageKey: string | undefined,
shell: ShellCase,
): string[][] {
shell: "bash" | "zsh" = "bash",
): string[] {
// The template's #1798 shape: flags ride the positional params, because an
// unquoted $VAR does NOT word-split under zsh — the whole flag string
// arrived as ONE argv word and gbrain silently fell back to its default
// embedding model.
const script = `
set -u
set --
if [ -n "\${VOYAGE_API_KEY:-}" ]; then
gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
else
gbrain init --pglite --json
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
fi
gbrain init --pglite --json "$@"
`;
const baseEnv: Record<string, string> = {
...process.env,
@@ -136,86 +118,111 @@ fi
} else {
baseEnv.VOYAGE_API_KEY = voyageKey;
}
const result = spawnSync(shell.command, ["-c", script], {
const result = spawnSync(shell, ["-c", script], {
encoding: "utf-8",
env: baseEnv,
});
if (result.status !== 0) {
throw new Error(
`${shell.name} init script exited ${result.status}: ${result.stderr}`,
);
throw new Error(`init script exited ${result.status}: ${result.stderr}`);
}
return parseArgvLog(env.argvLog);
return readFileSync(env.argvLog, "utf-8").trim().split("\n");
}
function parseArgvLog(argvLog: string): string[][] {
const calls: string[][] = [];
let current: string[] | undefined;
for (const line of readFileSync(argvLog, "utf-8").trim().split("\n")) {
if (line === "__CALL__") {
current = [];
continue;
}
if (line === "__END__") {
if (current) calls.push(current);
current = undefined;
continue;
}
if (current && line.startsWith("arg=")) {
current.push(line.slice("arg=".length));
}
}
return calls;
function lastArgc(env: FakeEnv): number {
const lines = readFileSync(`${env.argvLog}.argc`, "utf-8").trim().split("\n");
return parseInt(lines[lines.length - 1], 10);
}
const HAVE_ZSH = spawnSync("zsh", ["-c", "true"]).status === 0;
describe("voyage-code-3 default for gstack-driven PGLite init", () => {
for (const shell of SHELLS) {
const describeShell = shell.available ? describe : describe.skip;
it("passes voyage-code-3 flags when VOYAGE_API_KEY is set", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "vk_test_set");
expect(calls.length).toBe(1);
const argv = calls[0];
expect(argv).toContain("init --pglite --json");
expect(argv).toContain("--embedding-model voyage:voyage-code-3");
expect(argv).toContain("--embedding-dimensions 1024");
} finally {
env.cleanup();
}
});
describeShell(`under ${shell.name}`, () => {
it("passes voyage-code-3 flags as four separate argv entries when VOYAGE_API_KEY is set", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "vk_test_set", shell);
expect(calls).toEqual([
[
"init",
"--pglite",
"--json",
"--embedding-model",
"voyage:voyage-code-3",
"--embedding-dimensions",
"1024",
],
]);
} finally {
env.cleanup();
}
});
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, undefined);
expect(calls.length).toBe(1);
const argv = calls[0];
expect(argv).toContain("init --pglite --json");
expect(argv).not.toContain("voyage");
expect(argv).not.toContain("--embedding-model");
expect(argv).not.toContain("--embedding-dimensions");
} finally {
env.cleanup();
}
});
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, undefined, shell);
expect(calls).toEqual([["init", "--pglite", "--json"]]);
} finally {
env.cleanup();
}
});
it("zsh: flags arrive as SEPARATE argv words (#1798 — the shell that broke)", () => {
if (!HAVE_ZSH) return; // zsh ships on macOS; skip quietly elsewhere
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "vk_test_set", "zsh");
expect(calls.length).toBe(1);
expect(calls[0]).toContain("--embedding-model voyage:voyage-code-3");
// init --pglite --json + 4 flag words = 7 argv entries. The pre-#1798
// unquoted-var shape produced 4 under zsh (the whole flag string as one
// word), and gbrain silently fell back to its default embedding model.
expect(lastArgc(env)).toBe(7);
} finally {
env.cleanup();
}
});
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "", shell);
expect(calls).toEqual([["init", "--pglite", "--json"]]);
} finally {
env.cleanup();
}
it("demonstrates the #1798 collision: an unquoted flags var is ONE word under zsh", () => {
if (!HAVE_ZSH) return;
const env = makeFakeEnv();
try {
const brokenShape = `
set -u
GBRAIN_EMBED_FLAGS="--embedding-model voyage:voyage-code-3 --embedding-dimensions 1024"
gbrain init --pglite --json $GBRAIN_EMBED_FLAGS
`;
const result = spawnSync("zsh", ["-c", brokenShape], {
encoding: "utf-8",
env: { ...process.env, HOME: env.home, PATH: `${env.bindir}:/usr/bin:/bin` },
});
});
}
expect(result.status).toBe(0);
expect(lastArgc(env)).toBe(4); // init, --pglite, --json, "<entire flag string>"
} finally {
env.cleanup();
}
});
it("template uses the positional-params shape, not an unquoted flags var", () => {
const tmpl = readFileSync(
join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl"),
"utf-8",
);
expect(tmpl).not.toContain("$GBRAIN_EMBED_FLAGS");
const sites = tmpl.match(/gbrain init --pglite --json "\$@"/g) || [];
expect(sites.length).toBe(3);
const setSites = tmpl.match(/set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g) || [];
expect(setSites.length).toBe(3);
});
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "");
expect(calls.length).toBe(1);
expect(calls[0]).not.toContain("voyage");
} finally {
env.cleanup();
}
});
});
describe("template alignment: the .tmpl actually contains the voyage gate", () => {
@@ -225,13 +232,11 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
const TEMPLATE_PATH = join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl");
const tmpl = readFileSync(TEMPLATE_PATH, "utf-8");
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY without word-splitting", () => {
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY", () => {
// Should appear at least once (currently 3 init sites use the same gate).
expect(tmpl).toContain('if [ -n "${VOYAGE_API_KEY:-}" ]; then');
expect(tmpl).toContain(
"gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024",
);
expect(tmpl).not.toContain("GBRAIN_EMBED_FLAGS");
expect(tmpl).toContain("--embedding-model voyage:voyage-code-3");
expect(tmpl).toContain("--embedding-dimensions 1024");
});
it("setup-gbrain template uses the conditional gate at all 3 PGLite init sites", () => {
@@ -239,10 +244,5 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
// init site, update this expectation deliberately.
const matches = tmpl.match(/if \[ -n "\$\{VOYAGE_API_KEY:-\}" \]; then/g);
expect(matches?.length).toBe(3);
const voyageInitMatches = tmpl.match(
/gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g,
);
expect(voyageInitMatches?.length).toBe(3);
});
});
+102
View File
@@ -269,3 +269,105 @@ describe('get without arg (auto-detect from current dir)', () => {
}
});
});
// ── #2140 sync-path chokepoint ──────────────────────────────────────────────
// The tier above is a STORE. This block pins the ENFORCEMENT: a direct
// gstack-gbrain-sync invocation (skill prose bypassed — cron, curiosity,
// automation) must honor deny/read-only at the code-import stage, and the
// egress receipt's "per-repo policy chokepoint (repoPolicyTier)" consent
// string must describe code that exists. Wave-1 shipped the receipt string
// without the function; these tests make that impossible to repeat.
describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)', () => {
const SYNC = path.join(ROOT, 'bin', 'gstack-gbrain-sync.ts');
const REPO_URL = 'https://github.com/acme/widget.git';
let repoDir: string;
function makeRepo(): void {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-repo-'));
const git = (...args: string[]) =>
spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
git('init', '-q', '.');
git('remote', 'add', 'origin', REPO_URL);
fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
}
function runSync(): { status: number; text: string; stages: any[] } {
const res = spawnSync('bun', [SYNC, '--code-only', '--incremental'], {
cwd: repoDir,
encoding: 'utf-8',
timeout: 60_000,
// HOME also redirected so engine detection can't find a real ~/.gbrain.
env: { ...process.env, GSTACK_HOME: tmpHome, HOME: tmpHome },
});
let stages: any[] = [];
try {
stages = JSON.parse(
fs.readFileSync(path.join(tmpHome, '.gbrain-sync-state.json'), 'utf-8'),
).last_stages || [];
} catch {
// state file may be absent on early refusal paths — text asserts cover it
}
return {
status: res.status ?? -1,
text: `${res.stdout || ''}\n${res.stderr || ''}`,
stages,
};
}
afterEach(() => {
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
});
test('deny → code stage refuses loudly, exit 1, status refused-policy-deny', () => {
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
const r = runSync();
expect(r.status).toBe(1);
expect(r.text).toContain('refused');
expect(r.text).toContain('deny');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('refused-policy-deny');
});
test('read-only → clean skip (exit 0), status skipped-policy-read-only', () => {
makeRepo();
expect(run(['set', REPO_URL, 'read-only']).status).toBe(0);
const r = runSync();
expect(r.status).toBe(0);
expect(r.text).toContain('read-only');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('skipped-policy-read-only');
});
test('store exists but unreadable → fail-closed refusal, never bypassed', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod semantics differ
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
fs.chmodSync(policyFile(), 0o000);
try {
const r = runSync();
expect(r.status).toBe(1);
expect(r.text).toContain('refus');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('refused-policy-unreadable');
} finally {
fs.chmodSync(policyFile(), 0o600);
}
});
test('no policy store → fail-open, stage proceeds past the gate (no policy status)', () => {
makeRepo();
const r = runSync();
const code = r.stages.find((s: any) => s.name === 'code');
// With no engine in the redirected HOME the stage skips for ENGINE
// reasons — what matters is that no policy refusal fired and the exit
// is clean, preserving pre-policy behavior for every non-policy user.
expect(r.status).toBe(0);
expect(String(code?.detail?.status || '')).not.toContain('policy');
expect(r.text).not.toContain('refused');
});
});
+5 -2
View File
@@ -1079,8 +1079,11 @@ describe('PLAN_VERIFICATION_EXEC placeholder', () => {
expect(shipSkill).toContain('qa-only');
});
test('contains localhost reachability check', () => {
expect(shipSkill).toContain('localhost:3000');
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');
});
+144 -2
View File
@@ -7,7 +7,7 @@
*/
import { describe, it, expect } from "bun:test";
import { chmodSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { tmpdir } from "os";
import { delimiter, join } from "path";
import { spawnSync } from "child_process";
@@ -52,10 +52,47 @@ fi
chmodSync(fakeBin, 0o755);
}
/**
* Like writeFakeGbrain, but every invocation appends its argv to `logFile`.
* Still answers `--version` successfully ON PURPOSE: a revert from the
* memoized PATH stat scan back to the old `gbrain --version` spawn probe
* would pass every non-logging test — only the argv log catches it.
*/
function writeLoggingGbrain(binDir: string, logFile: string): void {
if (process.platform === "win32") {
writeFileSync(
join(binDir, "gbrain.cmd"),
`@echo off\r\necho %* >> "${logFile}"\r\nif "%1"=="--version" (\r\n echo gbrain 0.test\r\n) else (\r\n echo fake gbrain %*\r\n)\r\n`,
"utf-8",
);
return;
}
const fakeBin = join(binDir, "gbrain");
writeFileSync(
fakeBin,
`#!/bin/sh
printf '%s\\n' "$*" >> "${logFile}"
if [ "$1" = "--version" ]; then
echo "gbrain 0.test"
else
echo "fake gbrain $*"
fi
`,
"utf-8",
);
chmodSync(fakeBin, 0o755);
}
function prependPath(binDir: string): Record<string, string> {
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH";
const currentPath = process.env[pathKey] || "";
return { [pathKey]: `${binDir}${delimiter}${currentPath}` };
return {
[pathKey]: `${binDir}${delimiter}${currentPath}`,
// Cold process spawns on a loaded machine can exceed the 500ms default
// budget; the fake gbrain is instant once spawned, so give it headroom.
GSTACK_BRAIN_TIMEOUT_MS: "10000",
};
}
describe("gstack-brain-context-load CLI", () => {
@@ -252,6 +289,111 @@ describe("gstack-brain-context-load — graceful gbrain absence", () => {
}
});
it("manifest filter: blocks reach gbrain as --filter args with template vars resolved (#1687)", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
writeFakeGbrain(binDir);
const skillFile = join(dir, "SKILL.md");
writeFileSync(
skillFile,
`---
name: x
gbrain:
schema: 1
context_queries:
- id: prior-sessions
kind: list
filter:
type: ceo-plan
tags_contains: "repo:{repo_slug}"
sort: updated_at_desc
limit: 5
render_as: "## Prior sessions"
---
`,
"utf-8"
);
try {
const r = runScript(["--skill-file", skillFile, "--repo", "my-test-repo"], prependPath(binDir));
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("fake gbrain list_pages");
expect(r.stdout).toContain("--filter type=ceo-plan");
expect(r.stdout).toContain("--filter tags_contains=repo:my-test-repo");
expect(r.stdout).toContain("--sort updated_at_desc");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("gbrain detection never spawns gbrain — stat-based PATH scan, not a `--version` probe (revert trap)", () => {
// The fix replaced a per-query `gbrain --version` spawn probe with a
// memoized PATH stat scan. The plain writeFakeGbrain shim still ANSWERS
// --version, so a revert to the spawn probe passes every other test in
// this file. This fake logs its argv: detection must invoke gbrain zero
// times, so the only invocations are the 3 default-manifest list_pages
// queries — a revert adds `--version` lines (and re-probing adds one per
// query) and fails exactly here.
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
const logFile = join(dir, "gbrain-argv.log");
writeLoggingGbrain(binDir, logFile);
try {
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], prependPath(binDir));
expect(r.exitCode).toBe(0);
expect(r.stderr).toContain("queries=3");
const invocations = readFileSync(logFile, "utf-8").split("\n").filter(Boolean);
expect(invocations.some((argv) => argv.includes("--version"))).toBe(false);
// Exactly the 3 real queries — no extra availability spawns of any shape.
expect(invocations).toHaveLength(3);
for (const argv of invocations) expect(argv.startsWith("list_pages")).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("availability survives a 1ms query budget — detection is not subject to GSTACK_BRAIN_TIMEOUT_MS", () => {
// The spawn probe ran under the same MCP_TIMEOUT_MS budget as the queries,
// so a cold spawn slower than the budget misreported gbrain as MISSING.
// With the stat scan, a 1ms budget kills the queries themselves (SKIP)
// but detection still sees the CLI — "gbrain CLI missing" must not appear.
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
// A SLOW fake, not the shared instant one: on a fast CI runner the
// instant fake answered inside even a 1ms budget (observed dur=0ms on
// ubicloud) and no SKIP ever printed. Sleeping makes the timeout
// deterministic on every machine; --version stays instant so detection
// has nothing to wait on.
const fakeBin = join(binDir, "gbrain");
writeFileSync(
fakeBin,
`#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gbrain 0.test"
else
sleep 0.3
echo "fake gbrain $*"
fi
`,
"utf-8",
);
chmodSync(fakeBin, 0o755);
try {
const env = { ...prependPath(binDir), GSTACK_BRAIN_TIMEOUT_MS: "1" };
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], env);
expect(r.exitCode).toBe(0);
expect(r.stderr).toContain("SKIP");
expect(r.stderr).not.toContain("gbrain CLI missing");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("vector + list queries still complete (with SKIP) when gbrain CLI is missing", () => {
// We can't easily un-install gbrain; rely on the helper's own missing-binary
// detection. The default manifest uses kind: list which calls gbrain. If
+33
View File
@@ -63,6 +63,39 @@ describe("gstack-decision-log", () => {
const r = log("not json", true);
expect(r.code).toBe(1);
});
test("--supersede with a replacement body records the replacement, linked to the old id", () => {
const id = log('{"decision":"old-call","scope":"repo","source":"user"}').out;
const out = logFlag(
`--supersede ${id} '{"decision":"new-call","rationale":"better","scope":"repo","source":"user"}'`,
);
expect(out).toContain(id);
expect(search()).toContain("new-call"); // the replacement is NOT silently dropped
expect(search()).not.toContain("old-call");
const arr = JSON.parse(search("--json"));
expect(arr.find((d: any) => d.decision === "new-call")?.supersedes).toBe(id);
});
test("--supersede with an INVALID replacement persists nothing (old stays active)", () => {
const id = log('{"decision":"keep-me","scope":"repo","source":"user"}').out;
let code = 0;
try {
logFlag(`--supersede ${id} '{"decision":""}'`);
} catch (e: any) {
code = e.status || 1;
}
expect(code).toBe(1);
expect(search()).toContain("keep-me"); // not retired by a failed replacement
});
test("--redact refuses a replacement body instead of dropping it", () => {
const id = log('{"decision":"redact-target","scope":"repo","source":"user"}').out;
let code = 0;
try {
logFlag(`--redact ${id} '{"decision":"would-be-lost","scope":"repo","source":"user"}'`);
} catch (e: any) {
code = e.status || 1;
}
expect(code).toBe(1);
expect(search()).toContain("redact-target"); // nothing happened at all
});
});
describe("gstack-decision-search", () => {
+13 -3
View File
@@ -11,7 +11,7 @@
* Free-tier (~50ms total). Runs in `bun test`.
*/
import { describe, it, expect, beforeEach, afterAll } from "bun:test";
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
@@ -338,7 +338,11 @@ describe("withErrorContext", () => {
process.env.GSTACK_HOME = testHome;
});
afterAll(() => {
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
// restore would put back the PREVIOUS test's temp dir (the last beforeEach
// overwrote savedHome) and leak a gstack-test-home-* dir into every test
// file that runs after this one in the same bun process.
afterEach(() => {
if (savedHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = savedHome;
});
@@ -414,7 +418,13 @@ describe("detectEngineTier", () => {
process.env.HOME = testHome;
});
afterAll(() => {
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
// restore would put back the PREVIOUS test's gstack-test-engine-* temp dir
// (the last beforeEach overwrote the saved values). That leaked
// HOME/GSTACK_HOME/PATH into every test file that ran after this one in the
// same bun process — child processes then looked for Playwright's Chromium
// cache and ~/.gstack config under a throwaway temp HOME.
afterEach(() => {
if (savedHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = savedHome;
if (savedGbrainHome === undefined) delete process.env.GBRAIN_HOME;
+99
View File
@@ -4,6 +4,7 @@
// when the relevant CLI isn't available).
import { test, expect, describe } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -221,6 +222,104 @@ describe("resolveVersionPath (monorepo VERSION-path support)", () => {
});
});
// Fixture-repo coverage for the default-base detection chain in parseArgs:
// origin/HEAD symbolic-ref → origin/main probe → origin/master probe →
// literal "main". No --base and no --current-version are passed, so the
// detected base is observable through base_version: each fixture branch
// carries a distinct VERSION and readBaseVersion does
// `git show origin/<detected-base>:VERSION`.
describe("default-base detection (no --base)", () => {
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
// Point git at a nonexistent global/system config so operator settings
// (init.defaultBranch, commit.gpgsign, hooks, ...) can't leak into fixtures.
const noCfg = join(mkdtempSync(join(tmpdir(), "nextver-gitcfg-")), "empty");
const GIT_ENV = {
...process.env,
GIT_CONFIG_GLOBAL: noCfg,
GIT_CONFIG_SYSTEM: noCfg,
GIT_AUTHOR_NAME: "fixture",
GIT_AUTHOR_EMAIL: "fixture@example.com",
GIT_COMMITTER_NAME: "fixture",
GIT_COMMITTER_EMAIL: "fixture@example.com",
};
function git(cwd: string, ...args: string[]): void {
execFileSync("git", args, { cwd, env: GIT_ENV, stdio: ["ignore", "pipe", "pipe"] });
}
// Origin repo whose branches each hold a distinct VERSION, plus a clone
// (the clone is the repo the CLI runs in).
function makeClone(branches: Array<[name: string, version: string]>): { root: string; clone: string } {
const root = mkdtempSync(join(tmpdir(), "nextver-base-"));
const origin = join(root, "origin");
mkdirSync(origin);
git(origin, "init", "-q", "-b", branches[0][0]);
for (let i = 0; i < branches.length; i++) {
const [name, version] = branches[i];
if (i > 0) git(origin, "checkout", "-q", "-b", name);
writeFileSync(join(origin, "VERSION"), `${version}\n`);
git(origin, "add", "VERSION");
git(origin, "commit", "-q", "--no-gpg-sign", "-m", `VERSION ${version}`);
}
git(root, "clone", "-q", "origin", "clone");
return { root, clone: join(root, "clone") };
}
function runWithoutBase(cwd: string): { exitCode: number; parsed: any } {
const proc = Bun.spawnSync(
["bun", "run", SCRIPT, "--bump", "patch", "--workspace-root", "null"],
{ cwd },
);
const out = new TextDecoder().decode(proc.stdout);
return { exitCode: proc.exitCode, parsed: JSON.parse(out) };
}
test("origin/HEAD symbolic-ref wins — resolves trunk even though origin/main exists", () => {
const { root, clone } = makeClone([["main", "1.1.1.1"], ["trunk", "2.2.2.2"]]);
try {
git(clone, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk");
const { exitCode, parsed } = runWithoutBase(clone);
expect(exitCode).toBe(0);
// trunk's VERSION, not main's — the rev-parse probes never ran.
expect(parsed.base_version).toBe("2.2.2.2");
expect(parsed.version).toBe("2.2.3.0");
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 30_000);
test("no origin/HEAD, no origin/main: the origin/master probe resolves master", () => {
const { root, clone } = makeClone([["master", "3.3.3.3"]]);
try {
// Plain clones that never ran `git remote set-head` have no origin/HEAD.
git(clone, "remote", "set-head", "origin", "--delete");
const { exitCode, parsed } = runWithoutBase(clone);
expect(exitCode).toBe(0);
// Read at origin/master. The "main" literal fallback would have warned
// and assumed 0.0.0.0 instead.
expect(parsed.base_version).toBe("3.3.3.3");
expect(parsed.version).toBe("3.3.4.0");
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 30_000);
test("neither origin/HEAD nor main/master: falls back to the 'main' literal", () => {
const { root, clone } = makeClone([["develop", "4.4.4.4"]]);
try {
git(clone, "remote", "set-head", "origin", "--delete");
const { exitCode, parsed } = runWithoutBase(clone);
expect(exitCode).toBe(0);
// base = literal "main"; origin/main doesn't exist, so readBaseVersion
// warns and assumes 0.0.0.0 — which pins WHICH base the fallback chose.
expect(parsed.base_version).toBe("0.0.0.0");
expect(parsed.warnings.join("\n")).toContain("origin/main");
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 30_000);
});
// Integration smoke — only runs if gh is available and authenticated. Confirms
// the CLI executes end-to-end against real APIs without crashing.
describe("integration (smoke)", () => {
+4 -3
View File
@@ -11,6 +11,7 @@
* zero rendering loss. The TTY rendering layer is identical for fat and slim
* skills, so it is not where token-reduction degradation can hide.
*/
import { resolveEvalModel } from '../../lib/eval-model';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -191,7 +192,7 @@ This is a capture test, not an interactive session. Skip any system-audit / envi
timeout: 240_000,
testName: opts.testName,
runId: opts.runId,
model: opts.model ?? 'claude-opus-4-7',
model: resolveEvalModel('capture', opts.model),
});
try {
@@ -253,7 +254,7 @@ Rules for this run:
timeout: opts.timeout ?? 300_000,
testName: opts.testName,
runId: opts.runId,
model: opts.model ?? 'claude-opus-4-7',
model: resolveEvalModel('capture', opts.model),
});
const readSections = new Set<string>();
@@ -334,7 +335,7 @@ Write the verbatim text of that AskUserQuestion (the full decision brief: title,
timeout: 240_000,
testName: opts.testName,
runId: opts.runId,
model: opts.model ?? 'claude-opus-4-7',
model: resolveEvalModel('capture', opts.model),
});
try {
+43 -22
View File
@@ -99,6 +99,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
ship: {
skill: 'ship',
expectedSections: [
'apple-release.md',
'tests.md',
'test-coverage.md',
'plan-completion.md',
@@ -129,7 +130,16 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
minUnionBytes: 120_000,
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
maxSizeRatio: 1.08,
// Fork port wave 2: multi-ecosystem test-detection evidence (Django/JVM
// markers, test-file census — e3259078 port) + the #1079 gh pr edit REST
// fallback grew the union to 1.090x; the third-party web-actions
// contract (consent-gated browser drive for API-key registration etc.)
// adds ~2.3KB inline judgment, measured 1.103x. The Apple release
// adapter (14.8KB carved section, 21 live releases of judgment — the
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.22,
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -144,9 +154,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
},
behavioral: 'external',
externalTest: 'test/skill-e2e-plan-ceo-review-section-loading.test.ts',
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 90,280 B; +1 KB headroom.
maxSkeletonBytes: 91_000,
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 92_500, // v1.64+v1.65 merge: both waves' preamble growth; measured 92,004
minUnionBytes: 80_000,
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -167,9 +178,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
behavioral: 'plan',
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
// +~1 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 68,163 B; +~1 KB headroom.
maxSkeletonBytes: 69_000,
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 70_000, // measured 68,780
minUnionBytes: 70_000,
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
@@ -180,7 +192,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
// hardening: host-anchored mode signal, precedence, passing-mention
// guards) and the plan-mode preamble reword land the union at 1.092.
maxSizeRatio: 1.10,
maxSizeRatio: 1.12, // measured 1.103
},
'plan-design-review': {
skill: 'plan-design-review',
@@ -198,13 +210,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
// +~1.3 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
// +~340 B: telemetry --error-message/--failed-step flags + prose in the
// shared completion-status preamble (PR #769, 2026-08); this skill was the
// closest to its ceiling (landed 89040 / ratio 1.072).
maxSkeletonBytes: 89_400,
// Fork port wave 2 (D1): evidence directive adds ~0.45KB to every
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
// (telemetry --error-message/--failed-step preamble prose, PR #769).
// Budget covers the sum of both waves.
maxSkeletonBytes: 91_000,
minUnionBytes: 70_000,
mustContain: ['design', 'visual'],
maxSizeRatio: 1.08,
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
},
'plan-devex-review': {
skill: 'plan-devex-review',
@@ -221,7 +234,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/destructive prose safety +
// continuation protocol in the always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 80_000,
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 82_000, // measured 80,493
minUnionBytes: 70_000,
mustContain: ['developer experience', 'Getting Started'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -244,12 +259,15 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
behavioral: 'prompt',
// v1.2.0 activation lift: first-run-guidance section in the shared preamble,
// plus the P1 office-hours closing handoff (AUQ that launches the next skill).
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 98,193 B; +~1 KB headroom.
maxSkeletonBytes: 99_000,
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2: the third-party web-actions contract sits inline
// (judgment must be visible before the workflow directs the user to a
// vendor site), plus the #703 dual-write + repo-doc-preference block and
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
maxSkeletonBytes: 101_000,
minUnionBytes: 70_000,
mustContain: ['design doc', 'problem statement'],
maxSizeRatio: 1.07,
maxSizeRatio: 1.12,
},
'document-release': {
skill: 'document-release',
@@ -267,7 +285,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 56_000,
maxSkeletonBytes: 56_500, // v1.64+v1.65 merge; measured 56,044
minUnionBytes: 55_000,
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
// Two intentional additions stack on this small skill: the AUQ-failure prose
@@ -295,6 +313,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 69,022 B; +~1 KB headroom.
maxSkeletonBytes: 70_000,
@@ -303,7 +322,8 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
// the cross-session decision-memory nudge) lands this carved skeleton just over
// the strict 1.05; headroom for the shared preamble additions.
maxSizeRatio: 1.07,
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
maxSizeRatio: 1.08,
},
cso: {
skill: 'cso',
@@ -336,13 +356,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 75_000,
maxSkeletonBytes: 75_800, // v1.64+v1.65 merge; measured 75,364
minUnionBytes: 72_000,
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
// cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB + the
// decision-memory nudge) lands it just over 1.05; headroom for the shared additions.
maxSizeRatio: 1.07,
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
maxSizeRatio: 1.08,
},
};
+11 -2
View File
@@ -21,6 +21,7 @@
* tests don't need it).
*/
import { resolveEvalModel } from '../../lib/eval-model';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -459,7 +460,7 @@ ${tail}
// below resolveClaudeBinary(), breaking under hermetic PATHs.
const result = nodeSpawnSync(
resolveClaudeBinary() ?? 'claude',
['-p', '--model', 'claude-haiku-4-5', '--max-turns', '1'],
['-p', '--model', resolveEvalModel('warmup'), '--max-turns', '1'],
{
input: prompt,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -1256,7 +1257,15 @@ export const ceoStep0Boundary: Step0BoundaryPredicate = (fp) =>
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
/scope reduction recommendation|cross[\s-]?project learnings/i.test(
fp.promptSnippet,
);
) ||
// plan-eng-review's Step 0 may legitimately end with NO scope-reduction /
// learnings AUQ. When it does, the first answered review-phase question —
// tagged <gstack-qid:plan-eng-review-...> ({skill}-{slug} convention) —
// must fire the boundary, or every per-finding AUQ stays classified
// preReview and the multi-finding batching counter reads 0. Anchor allows
// the skill-name prefix; live qids observed: plan-eng-review-jitter,
// plan-eng-review-idempotency, plan-eng-review-todos-e2e-concurrent.
/gstack-qid:\s*(?:plan-)?eng-review-/i.test(fp.promptSnippet);
export const designStep0Boundary: Step0BoundaryPredicate = (fp) =>
/design system|design posture|design score|first dimension/i.test(
+14 -4
View File
@@ -214,7 +214,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
// codexPreflight() block (install + auth tri-state + CODEX_MODE branch prose),
// landing ~6.3% over the v1.53.0.0 baseline. Intentional: it adds proper
// not-installed vs not-authed handling, not slop.
maxSizeRatio: 1.08,
// v1.64+v1.65 merge: both waves grew the shared preamble (evidence
// directive + telemetry failure flags); measured 1.094.
maxSizeRatio: 1.10,
minBytes: 70_000,
},
{
@@ -223,7 +225,11 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
mustHaveHeadings: ['## Preamble', '## When to invoke'],
// v1.2.0 activation lift: the unified first-run-guidance section (P4 scaffold +
// P3 loop tip) is added to every skill's shared preamble — intentional, ~1KB.
maxSizeRatio: 1.07,
// Fork port wave 2: the shared coverage-audit detection block gained the
// multi-ecosystem markers (Django/JVM, script/target/test-file census —
// e3259078 port); measured 1.111x. v1.64+v1.65 merge sums both waves'
// preamble growth; measured 1.125.
maxSizeRatio: 1.13,
minBytes: 50_000,
},
{
@@ -237,7 +243,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
// 1.09 → 1.10: the plan-mode preamble reword (scope-gate auto-select-B
// change) adds ~250 B to every skill's shared preamble; investigate was
// the closest to its ceiling (landed 1.092).
maxSizeRatio: 1.10,
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
maxSizeRatio: 1.12, // D1 measured
minBytes: 30_000,
},
{
@@ -245,7 +253,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
mustContain: ['ceo', 'eng', 'design'],
mustHaveHeadings: ['## Preamble', '## When to invoke'],
// v1.2.0 activation lift: shared first-run-guidance preamble section.
maxSizeRatio: 1.07,
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
maxSizeRatio: 1.09, // D1 measured
minBytes: 70_000,
},
];
+22 -3
View File
@@ -1,6 +1,6 @@
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
import { estimateCostUsd } from '../pricing';
import { execFileSync } from 'child_process';
import { execFileSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -25,11 +25,30 @@ export class ClaudeAdapter implements ProviderAdapter {
if (!resolved) {
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' };
}
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY OR (macOS)
// the Keychain entry subscription installs use instead of the creds file.
// #1890: the default macOS install stores OAuth under the generic-password
// service "Claude Code-credentials" and never writes .credentials.json,
// so the file-or-env sniff reported "No Claude auth found" while
// `claude -p` worked fine. Metadata probe only (no -w — never reads the
// secret), and any failure of `security` itself falls through to the
// not-found reason rather than throwing.
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
const hasCreds = fs.existsSync(credsPath);
const hasKey = !!process.env.ANTHROPIC_API_KEY;
if (!hasCreds && !hasKey) {
let hasKeychain = false;
if (!hasCreds && !hasKey && process.platform === 'darwin') {
try {
const probe = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials'], {
stdio: 'ignore',
timeout: 5000,
});
hasKeychain = probe.status === 0;
} catch {
hasKeychain = false;
}
}
if (!hasCreds && !hasKey && !hasKeychain) {
return { ok: false, reason: 'No Claude auth found. Log in via `claude` interactive session, or export ANTHROPIC_API_KEY.' };
}
return { ok: true };
+48
View File
@@ -0,0 +1,48 @@
/**
* StateServer hardening pins (fork port wave 2, B3 + B5).
*
* B3: the boot token must never appear in an os_log statement. The daemon
* reads it from the 0600 app-container file (copyFileFromAppContainer in
* tunnel-bootstrap.ts); the old `token=\(self.bootToken, privacy: .public)`
* announce line had NO consumer and handed a live credential to anything
* reading the unified log during the launch window.
*
* B5: the IPv4 listener has no CoreDevice tunnel path, so it must bind to
* loopback at the socket level (requiredLocalEndpoint 127.0.0.1), not rely
* solely on the per-connection peer check. IPv6 keeps the wildcard bind for
* CoreDevice ULA peers by design.
*
* Pinned on BOTH the generated template and the fixture app copy so neither
* can drift back independently.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const COPIES = [
"ios-qa/templates/StateServer.swift.template",
"test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift",
];
describe.each(COPIES)("StateServer hardening — %s", (rel) => {
const src = readFileSync(join(ROOT, rel), "utf-8");
test("no os_log statement interpolates the boot token (B3)", () => {
const logLines = src.split("\n").filter((l) => /logger\.(notice|info|error|debug|log)/.test(l));
for (const line of logLines) {
expect(line).not.toContain("bootToken");
}
// The bootstrap announce survives (diagnostics), token-free.
expect(src).toContain('gstack-ios-qa-bootstrap port=');
expect(src).not.toContain("gstack-ios-qa-bootstrap token=");
});
test("IPv4 listener binds loopback at the socket level (B5)", () => {
expect(src).toContain("requiredLocalEndpoint");
expect(src).toContain('NWEndpoint.Host("127.0.0.1")');
// IPv6 wildcard + peer-check path must survive (CoreDevice tunnel peers).
expect(src).toMatch(/case \.ipv6:\s*\n\s*listener = try NWListener\(using: params, on:/);
});
});
+61 -2
View File
@@ -22,7 +22,7 @@
import { describe, it, expect } from "bun:test";
import { execFileSync } from "child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { readFileSync } from "fs";
@@ -45,11 +45,70 @@ describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
// Match the spawn call's argument array and assert both the subcommand
// and the flag live in it, so the flag can't drift onto another call.
const call = stripped.match(/spawnGbrainAsync\(\s*\[[^\]]*"import"[^\]]*\]/s);
// [\s\S]*? bridges the conditional-spread's nested brackets (main's
// capability-probed --include-gitignored merged with our baseEnv defense).
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]/s);
expect(call).not.toBeNull();
expect(call![0]).toContain("--include-gitignored");
});
it("sets a realpath'd GIT_CEILING_DIRECTORIES on the import child (defense-in-depth)", () => {
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
// Second #2144 layer: the ceiling env must be built from the staging
// dir's REAL parent path and merged into the spawn's baseEnv, so a
// git-enumerating collector fails out of the git fast path even when
// the flag's semantics drift, and symlinked staging paths still match.
expect(stripped).toContain("GIT_CEILING_DIRECTORIES");
expect(stripped).toMatch(/realpathSync\(dirname\(stagingDir\)\)/);
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]\s*,\s*\{\s*baseEnv\s*\}/s);
expect(call).not.toBeNull();
});
it("proves the ceiling stops git discovery from the staging dir — including through a symlink", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-ceiling-"));
try {
const git = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) =>
execFileSync("git", args, {
cwd,
encoding: "utf-8",
env: { ...process.env, ...env },
});
// ~/.gstack shape: a git repo whose root ignores everything, with the
// staging dir as a direct child.
const home = join(dir, "gstack-home");
mkdirSync(home, { recursive: true });
git(["init", "-q", "."], home);
writeFileSync(join(home, ".gitignore"), "*\n", "utf-8");
const staging = join(home, ".staging-ingest-12345-1700000000000");
mkdirSync(staging, { recursive: true });
// Without a ceiling: discovery from the staging dir finds the repo —
// this is the git fast path that collects zero files.
const found = git(["rev-parse", "--show-toplevel"], staging).trim();
expect(realpathSync(found)).toBe(realpathSync(home));
// With the ceiling at the staging dir's REAL parent: discovery fails,
// which is exactly what pushes a collector onto its plain FS walk.
const ceiling = realpathSync(home);
expect(() =>
git(["rev-parse", "--show-toplevel"], staging, { GIT_CEILING_DIRECTORIES: ceiling }),
).toThrow();
// Symlink variant (the OV4 trap): reach the same staging dir through a
// symlinked path. A realpath'd ceiling still stops discovery.
const linked = join(dir, "linked-home");
symlinkSync(home, linked);
const stagingViaLink = join(linked, ".staging-ingest-12345-1700000000000");
expect(() =>
git(["rev-parse", "--show-toplevel"], stagingViaLink, {
GIT_CEILING_DIRECTORIES: ceiling,
}),
).toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("demonstrates the collision: an ignore-everything root hides staged pages", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-gitignore-"));
try {
+66 -1
View File
@@ -71,7 +71,11 @@ function run(extraEnv: Record<string, string> = {}, input = ''): { code: number;
PATH: `${fakeBinDir}:${path.join(ROOT, 'bin')}:/usr/bin:/bin:/opt/homebrew/bin`,
HOME: tmpHome,
USER: 'testuser',
// Disable interactive prompt: empty stdin = treat as non-interactive.
// Empty stdin = non-interactive. Since #1383 the script SKIPS by
// default in that context (a remote repo rename is consent-shaped);
// the harness opts in explicitly, simulating a consenting unattended
// run. The default-skip contract has its own test below.
GSTACK_MIGRATE_ASSUME_YES: '1',
...extraEnv,
},
encoding: 'utf-8',
@@ -168,6 +172,67 @@ describe('v1.27.0.0 migration — GitHub host (non-interactive)', () => {
});
});
describe('v1.27.0.0 migration — #1383 consent + failure-stays-pending contract', () => {
beforeEach(() => {
fs.writeFileSync(
path.join(tmpHome, '.gstack-brain-remote.txt'),
'https://github.com/testuser/gstack-brain-testuser\n'
);
fs.writeFileSync(
path.join(tmpHome, '.gstack/config.yaml'),
'gbrain_sync_mode: full\n'
);
makeFakeGh({});
});
test('non-interactive without opt-in: skips for now, touches NOTHING', () => {
const r = run({ GSTACK_MIGRATE_ASSUME_YES: '0' });
expect(r.code).toBe(0);
expect(r.stderr).toContain('skipping for now');
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
// The remediation must be REAL: a direct invocation of this script.
// `/setup-gbrain --rerun-migration` never existed, and the runners'
// version windows never re-select a passed migration, so "will ask
// again next upgrade" was false.
expect(r.stderr).toContain('v1.27.0.0.sh');
expect(r.stderr).not.toContain('--rerun-migration');
expect(r.stderr).not.toContain('ask again');
// Old state untouched, nothing recorded as done.
expect(fs.existsSync(path.join(tmpHome, '.gstack-brain-remote.txt'))).toBe(true);
expect(fs.existsSync(path.join(tmpHome, '.gstack-artifacts-remote.txt'))).toBe(false);
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
});
test('gh rename failure: step stays pending, migration exits INCOMPLETE, retry succeeds', () => {
makeFakeGh({ renameSucceeds: false });
const r = run();
// Failure must be loud and the migration visibly incomplete — the old
// behavior marked the failed step done and wrote the done touchfile,
// permanently stranding a half-renamed install (#1383).
expect(r.code).toBe(1);
expect(r.stderr).toContain('PENDING');
expect(r.stderr).toContain('INCOMPLETE');
// Honest remediation: direct invocation, not the nonexistent
// /setup-gbrain --rerun-migration flag.
expect(r.stderr).toContain('Re-run manually with:');
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
expect(r.stderr).not.toContain('--rerun-migration');
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
const journal = fs.readFileSync(
path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.journal'),
'utf-8'
);
expect(journal).not.toContain('gh_repo_renamed');
// Later steps DID run and are journaled (independent of step 1)...
expect(journal).toContain('remote_txt_renamed');
// ...so a retry with working gh only redoes step 1 and completes.
makeFakeGh({});
const r2 = run();
expect(r2.code).toBe(0);
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(true);
});
});
describe('v1.27.0.0 migration — interruption resume', () => {
beforeEach(() => {
fs.writeFileSync(
+240
View File
@@ -0,0 +1,240 @@
/**
* v1.65.0.0 migration — remove rebrand-poisoned Chromium bundles (#2242)
* and re-fetch a clean one, gating .done on a VERIFIED end state.
*
* Exercises the migration in a hermetic temp HOME + temp Playwright cache
* (HOME / GSTACK_HOME / PLAYWRIGHT_BROWSERS_PATH all redirected) with a
* stubbed bunx on PATH — nothing touches the real cache. Covers:
* - whole-revision-dir removal (INSTALLATION_COMPLETE marker included, so
* `playwright install` can't no-op with "is already downloaded")
* - clean bundles untouched
* - re-fetch runs from the gstack install root (repo-pinned playwright)
* - .done only written once a Chromium executable verifiably exists
* - failed re-fetch → warning + retry on next run (needs-refetch sentinel)
* - stranded revision dir (markers without .app) re-triggers the re-fetch
* - idempotent re-run after success
* - non-Darwin early exit
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.65.0.0.sh');
let tmpHome: string;
let fakeBinDir: string;
let pwCache: string;
const POISONED_PLIST =
'<plist><dict><key>CFBundleName</key><string>GStack Browser</string></dict></plist>';
const CLEAN_PLIST =
'<plist><dict><key>CFBundleName</key><string>Google Chrome for Testing</string></dict></plist>';
/** Build a Playwright-cache-shaped bundle: <revDir>/chrome-mac/<name>.app */
function makeBundle(
revDir: string,
opts: { plist: string; withExecutable?: boolean; withMarkers?: boolean }
): string {
const appDir = path.join(revDir, 'chrome-mac', 'Google Chrome for Testing.app');
const contents = path.join(appDir, 'Contents');
fs.mkdirSync(contents, { recursive: true });
fs.writeFileSync(path.join(contents, 'Info.plist'), opts.plist);
if (opts.withExecutable) {
const macos = path.join(contents, 'MacOS');
fs.mkdirSync(macos, { recursive: true });
fs.writeFileSync(path.join(macos, 'Google Chrome for Testing'), '#!/bin/sh\n', {
mode: 0o755,
});
}
if (opts.withMarkers ?? true) {
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), '');
}
return appDir;
}
/**
* Stub bunx: records every invocation (args + cwd) and, unless told
* otherwise, simulates a successful `playwright install chromium` by
* creating a fresh revision dir with an executable in the temp cache.
*/
function makeFakeBunx(opts: { createsExecutable?: boolean } = {}): string {
const creates = opts.createsExecutable ?? true;
const callLog = path.join(fakeBinDir, 'bunx-calls.log');
const script = `#!/bin/bash
echo "bunx $@ (pwd=$(pwd))" >> "${callLog}"
${
creates
? `FRESH="\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/chrome-mac/Chromium.app/Contents/MacOS"
mkdir -p "\${FRESH}"
printf '#!/bin/sh\\n' > "\${FRESH}/Chromium"
chmod 755 "\${FRESH}/Chromium"
touch "\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/INSTALLATION_COMPLETE"`
: '# simulates an offline / failed download: no files created'
}
exit 0
`;
fs.writeFileSync(path.join(fakeBinDir, 'bunx'), script, { mode: 0o755 });
return callLog;
}
function run(extraEnv: Record<string, string> = {}): {
code: number;
stdout: string;
stderr: string;
} {
const r = spawnSync(MIGRATION, [], {
env: {
PATH: `${fakeBinDir}:/usr/bin:/bin`,
HOME: tmpHome,
GSTACK_HOME: path.join(tmpHome, '.gstack'),
PLAYWRIGHT_BROWSERS_PATH: pwCache,
...extraEnv,
},
encoding: 'utf-8',
cwd: tmpHome,
});
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
const doneFile = () => path.join(tmpHome, '.gstack', '.migrations', 'v1.65.0.0.done');
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-'));
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-fake-'));
pwCache = path.join(tmpHome, 'pw-cache', 'ms-playwright');
fs.mkdirSync(pwCache, { recursive: true });
fs.mkdirSync(path.join(tmpHome, '.gstack'), { recursive: true });
// The migration gates on `uname -s` = Darwin; on the Linux CI runner the
// real uname made every test early-exit with empty output. Shim Darwin so
// the Darwin-path tests run everywhere; the non-Darwin test overwrites
// this shim with its own Linux uname.
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Darwin\n', {
mode: 0o755,
});
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
});
describe('v1.65.0.0 migration — poisoned bundle removal', () => {
test('poisoned revision dir removed WHOLE (markers included); clean bundle untouched; verified re-fetch → .done', () => {
const poisonedRev = path.join(pwCache, 'chromium-1234');
makeBundle(poisonedRev, { plist: POISONED_PLIST });
const cleanRev = path.join(pwCache, 'chromium-5678');
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
const log = makeFakeBunx();
const r = run();
expect(r.code).toBe(0);
// The WHOLE revision dir is gone — .app AND the INSTALLATION_COMPLETE /
// DEPENDENCIES_VALIDATED markers. Removing only the .app leaves markers
// that make `playwright install chromium` no-op ("is already
// downloaded"), stranding the user with NO browser + a success message.
expect(fs.existsSync(poisonedRev)).toBe(false);
// Clean bundle untouched.
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
expect(fs.existsSync(path.join(cleanRev, 'INSTALLATION_COMPLETE'))).toBe(true);
// Re-fetch invoked...
const calls = fs.readFileSync(log, 'utf-8');
expect(calls).toContain('playwright install chromium');
// ...from the gstack install root (repo-pinned playwright version), not
// from the arbitrary cwd the migration runner happened to use.
const realRoot = fs.realpathSync(ROOT);
expect(calls.includes(`pwd=${ROOT}`) || calls.includes(`pwd=${realRoot}`)).toBe(true);
// Verified end state → .done written.
expect(fs.existsSync(doneFile())).toBe(true);
});
test('re-fetch produces no executable → WARNING, .done NOT written; next run retries and completes', () => {
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
const log = makeFakeBunx({ createsExecutable: false });
const r = run();
expect(r.code).toBe(0); // non-fatal per the migration contract
expect(r.stderr).toContain('WARNING');
expect(r.stderr).toContain('bunx playwright install chromium');
// Removal succeeded but the end state is "no browser": .done must NOT
// be written so a re-run retries instead of recording success.
expect(fs.existsSync(doneFile())).toBe(false);
// Re-run with a working fetch: the pending state re-triggers the
// re-fetch even though the scans have nothing left to remove.
fs.rmSync(log, { force: true });
makeFakeBunx({ createsExecutable: true });
const r2 = run();
expect(r2.code).toBe(0);
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
expect(fs.existsSync(doneFile())).toBe(true);
});
test('stranded revision dir (install markers, no .app) re-triggers removal + re-fetch', () => {
// The state an older version of this migration could leave behind:
// .app removed, INSTALLATION_COMPLETE still present → `playwright
// install` no-ops while the user has no browser at all.
const strandedRev = path.join(pwCache, 'chromium-1234');
fs.mkdirSync(path.join(strandedRev, 'chrome-mac'), { recursive: true });
fs.writeFileSync(path.join(strandedRev, 'INSTALLATION_COMPLETE'), '');
const log = makeFakeBunx();
const r = run();
expect(r.code).toBe(0);
expect(fs.existsSync(strandedRev)).toBe(false);
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
expect(fs.existsSync(doneFile())).toBe(true);
});
test('clean cache → no-op, no bunx call, .done written', () => {
const cleanRev = path.join(pwCache, 'chromium-5678');
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
const log = makeFakeBunx();
const r = run();
expect(r.code).toBe(0);
expect(r.stderr).toContain('no-op');
expect(fs.existsSync(log)).toBe(false); // bunx never invoked
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
expect(fs.existsSync(doneFile())).toBe(true);
});
test('second run after success → silent no-op (no rescan, no bunx)', () => {
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
const log = makeFakeBunx();
const r1 = run();
expect(r1.code).toBe(0);
expect(fs.existsSync(doneFile())).toBe(true);
fs.rmSync(log, { force: true });
const r2 = run();
expect(r2.code).toBe(0);
expect(r2.stderr).toBe('');
expect(fs.existsSync(log)).toBe(false);
});
test('non-Darwin → early exit, cache untouched, no bunx, .done written', () => {
const poisonedApp = makeBundle(path.join(pwCache, 'chromium-1234'), {
plist: POISONED_PLIST,
});
makeFakeBunx();
// The script gates on `uname -s` != Darwin; shadow uname on PATH.
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Linux\n', {
mode: 0o755,
});
const r = run();
expect(r.code).toBe(0);
expect(fs.existsSync(poisonedApp)).toBe(true); // nothing removed
expect(fs.existsSync(path.join(fakeBinDir, 'bunx-calls.log'))).toBe(false);
expect(fs.existsSync(doneFile())).toBe(true);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Regression pin for #2091: /codex was broken on every macOS install because
* its mktemp templates carried a suffix after the X's ("codex-err-XXXXXX.txt").
* BSD mktemp (macOS) requires the X's to be the trailing characters of the
* template; with a suffix it fails ("mkstemp failed ... File exists"), the
* temp file never exists, and the skill dies before Codex ever runs.
*
* GNU mktemp accepts a --suffix flag but ALSO rejects inline suffixes in the
* template argument on BusyBox, so the portable form is: X's last, no suffix.
*
* This scans every .tmpl (the sources of truth — generated SKILL.md files
* follow at regen time) for the broken shape.
*/
import { describe, it, expect } from "bun:test";
import { execFileSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
function trackedTmplFiles(): string[] {
const out = execFileSync("git", ["ls-files", "*.tmpl", "**/*.tmpl"], {
cwd: ROOT,
encoding: "utf-8",
});
return out.split("\n").filter(Boolean);
}
describe("mktemp portability (#2091)", () => {
it("no .tmpl file uses a mktemp template with characters after XXXXXX", () => {
const offenders: string[] = [];
for (const rel of trackedTmplFiles()) {
const src = readFileSync(join(ROOT, rel), "utf-8");
src.split("\n").forEach((line, i) => {
// Broken shape: the X-run followed by a non-quote, non-whitespace,
// non-closing character inside a mktemp invocation. X{6,} is greedy,
// so longer X-runs (spec's XXXXXXXX) stay valid — only a genuine
// suffix after the final X trips it.
if (/mktemp[^\n]*X{6,}[^"'\s)X]/.test(line)) {
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
}
});
}
expect(offenders).toEqual([]);
});
it("BSD-portable form actually works on this platform", () => {
// Live sanity: the exact template shape the skills now emit.
const tmp = process.env.TMPDIR || "/tmp";
const created = execFileSync("mktemp", [`${tmp.replace(/\/$/, "")}/gstack-portability-XXXXXX`], {
encoding: "utf-8",
}).trim();
expect(created.length).toBeGreaterThan(0);
execFileSync("rm", ["-f", created]);
});
});
+6
View File
@@ -88,6 +88,12 @@ const SCAN_PATHS = [
'review/SKILL.md.tmpl',
'ship/SKILL.md.tmpl',
'test/',
// Fork port wave 2: docs were deliberately excluded and that exact gap let
// a dead command (gstack-brain-init) survive ~36 releases as a
// command-not-found instruction. Docs are user-facing surface; scan them.
'docs/',
'README.md',
'USING_GBRAIN_WITH_GSTACK.md',
];
function grepRefs(pattern: string): string[] {
+56
View File
@@ -0,0 +1,56 @@
/**
* R2 pin (fork port wave 2): the Apple release adapter loads BEFORE ship's
* branch gate, and the non-Apple gate is byte-unchanged.
*
* Two failure modes this prevents: a future ship-template refactor that
* re-blocks store releases behind "ship from a feature branch" (the exact
* live failure the fork hit — a solo dev with a clean tree on main shipping
* to TestFlight got aborted over branch topology), and the reverse — the
* Apple path accidentally weakening the branch gate for normal
* repository-landing ships.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const SKELETON = readFileSync(join(ROOT, "ship", "SKILL.md"), "utf-8");
const GATE_TEXT =
'If on the base branch or the repo\'s default branch, **abort**: "You\'re on the base branch. Ship from a feature branch."';
describe("ship Apple gate ordering (R2)", () => {
test("the Apple adapter read directive precedes the branch gate", () => {
const appleRead = SKELETON.indexOf("sections/apple-release.md");
const gate = SKELETON.indexOf(GATE_TEXT);
expect(appleRead).toBeGreaterThan(-1);
expect(gate).toBeGreaterThan(-1);
expect(appleRead).toBeLessThan(gate);
});
test("store distribution explicitly bypasses the branch/PR ceremony", () => {
expect(SKELETON).toContain("Store distribution proceeds");
expect(SKELETON).toMatch(/branch gate and repository-landing pipeline below apply ONLY to\s*\n?repository-landing asks/);
});
test("the non-Apple branch gate is byte-unchanged and appears exactly once", () => {
const first = SKELETON.indexOf(GATE_TEXT);
expect(first).toBeGreaterThan(-1);
expect(SKELETON.indexOf(GATE_TEXT, first + 1)).toBe(-1);
});
test("the adapter section exists in the union with its battle-tested spine", () => {
const section = readFileSync(join(ROOT, "ship", "sections", "apple-release.md"), "utf-8");
for (const anchor of [
"one authorization moment",
"fastlane spaceauth",
"iris/v1/apiKeys",
"appPriceSchedules",
"CLASSIFY the error before touching credentials",
"Never abort an App Store release over branch topology",
]) {
expect(section).toContain(anchor);
}
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Regression: /ship Step 4 test-framework detection was blind to Django.
*
* A real Django project (manage.py + <app>/tests.py, green `python manage.py
* test`, no pytest.ini and no tests/ directory) read as "no test framework",
* so /ship bootstrapped pytest on top of a working suite. Same blindness hit
* any config-less-but-tested project (Go *_test.go, in-source Rust #[test],
* package.json with only a test script).
*
* These run the resolver's OWN emitted detection block against fixtures, so
* the shell is checked, not just the prose — and the test is independent of
* generated-SKILL.md regen state.
*
* Ported from time-attack/gstack commit e3259078 (GStack 2), adapted from the
* fork's generated-markdown target to our resolver source of truth.
*/
import { describe, test, expect } from 'bun:test';
import { execFileSync } from 'node:child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { generateTestBootstrap } from '../scripts/resolvers/testing';
const section = generateTestBootstrap({} as never);
/** The detection block the skill tells the agent to run (first bash fence). */
function detectionScript(): string {
const open = section.indexOf('```bash\n');
expect(open).toBeGreaterThan(-1);
const start = open + '```bash\n'.length;
const end = section.indexOf('```', start);
return section.slice(start, end);
}
/** Run the detection block in a throwaway git repo laid out by `files`. */
function detect(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-detect-'));
try {
for (const [rel, body] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
}
const script = path.join(dir, '.detect.sh');
fs.writeFileSync(script, detectionScript());
const git = (...args: string[]) => execFileSync('git', args, { cwd: dir });
git('init', '-q', '.');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
// The block's last line is a `[ -f marker ] && echo`, so a clean project
// exits 1 by design — read stdout, don't trust the status.
return execFileSync('bash', [script], { cwd: dir, encoding: 'utf-8' });
} catch (err: unknown) {
const e = err as { stdout?: string };
if (typeof e.stdout === 'string') return e.stdout;
throw err;
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const testFileCount = (out: string): number =>
Number(/^TESTFILES:(\d+)$/m.exec(out)?.[1] ?? -1);
describe('/ship Step 4 detection is multi-ecosystem', () => {
test('Django project reports manage.py and its existing tests', () => {
const out = detect({
'manage.py': '#!/usr/bin/env python\n',
'requirements.txt': 'Django==5.0\n',
'polls/tests.py': 'from django.test import TestCase\n',
});
expect(out).toContain('MARKER:manage.py');
expect(out).toContain('FRAMEWORK:django');
expect(testFileCount(out)).toBeGreaterThan(0);
});
test('config-less projects with tests still report test evidence', () => {
expect(testFileCount(detect({
'go.mod': 'module x\n',
'x_test.go': 'package x\n',
}))).toBeGreaterThan(0);
const node = detect({
'package.json': '{"name":"n","scripts":{"test":"node --test"}}\n',
});
expect(node).toContain('SCRIPT:package.json test');
const rust = detect({
'Cargo.toml': '[package]\nname="x"\n',
'src/lib.rs': '#[cfg(test)]\nmod t{ #[test] fn x(){} }\n',
});
expect(rust).toContain('TESTS:rust in-source');
});
test('a genuinely untested project reports no test evidence', () => {
const out = detect({ 'go.mod': 'module x\n', 'x.go': 'package x\n' });
expect(out).toContain('RUNTIME:go');
expect(testFileCount(out)).toBe(0);
expect(out).not.toContain('TESTS:');
});
test('every ecosystem marker maps to a command to OFFER, not to run blind', () => {
expect(section).toContain('never a command to run blind');
for (const marker of ['manage.py', 'go.mod', 'Cargo.toml', 'pom.xml',
'build.gradle', 'mix.exs', 'composer.json', 'Gemfile', 'pytest.ini']) {
expect(section).toContain(marker);
}
// The ask-and-persist contract, not a hardcoded project command.
expect(section).toContain('AskUserQuestion');
expect(section).toContain('persist the answer to CLAUDE.md');
});
});
+2 -2
View File
@@ -111,7 +111,7 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
skillName: 'plan-ceo-review',
inPlanMode: true,
extraArgs: ['--disallowedTools', 'AskUserQuestion'],
timeoutMs: 300_000,
timeoutMs: 540_000,
env: { GSTACK_HOME: tmpHome, CONDUCTOR_WORKSPACE_PATH: tmpHome },
});
@@ -135,5 +135,5 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
} finally {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}, 360_000);
}, 660_000);
});
+31 -26
View File
@@ -34,6 +34,12 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
// Stage a fresh GSTACK_HOME with artifacts_sync_mode_prompted=false.
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-gstack-'));
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-bin-'));
// Fresh HOME with NO ~/.claude.json: on a machine where gbrain is
// registered type=http, the preamble's remote-mode detection reads the
// operator's ~/.claude.json and echoes "ARTIFACTS_SYNC: remote-mode" —
// and the local privacy gate legitimately never fires. An empty HOME
// makes the detection find nothing.
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-home-'));
// Seed the config so the gate's condition passes.
fs.writeFileSync(
@@ -59,12 +65,16 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
const binary = resolveClaudeBinary();
// Ambient env mutations — restored in finally so other tests in the file
// don't inherit them.
const origGstackHome = process.env.GSTACK_HOME;
const origPath = process.env.PATH;
process.env.GSTACK_HOME = gstackHome;
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
// Per-test env, merged LAST by the hermetic env builder (safe post-v1.39:
// the runner always passes a COMPLETE hermetic env, so overrides can't
// break auth). Ambient process.env.GSTACK_HOME mutation does NOT work
// here — hermetic-env scrubs GSTACK_* and repoints GSTACK_HOME at its
// own singleton dir, so the staged config would never reach the child.
const childEnv = {
GSTACK_HOME: gstackHome,
HOME: tempHome,
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
};
try {
// Pick a small skill with the preamble and load it via Read to force
@@ -85,12 +95,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
workingDirectory: gstackHome,
maxTurns: 10,
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
// NOTE: do NOT pass `env:` here. When the Agent SDK gets an explicit
// env object, its auth pipeline doesn't pick up ANTHROPIC_API_KEY the
// same way as when env is undefined (SDK-internal detail, verified
// against the plan-mode-no-op test which passes no env and auths
// cleanly). Instead, mutate process.env before the call so the SDK
// inherits our overrides ambiently.
env: childEnv,
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
canUseTool: async (toolName, input) => {
if (toolName === 'AskUserQuestion') {
@@ -141,13 +146,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
// (The preamble is supposed to be idempotent within a session.)
expect(privacyQuestions.length).toBe(1);
} finally {
// Restore ambient env before other tests.
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = origGstackHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = origPath;
fs.rmSync(gstackHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
fs.rmSync(tempHome, { recursive: true, force: true });
}
}, 180_000);
@@ -155,6 +156,10 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
// Same staging, but prompted=true this time. Gate should be silent.
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-'));
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-bin-'));
// Fresh HOME without a .claude.json — same rationale as the first test:
// without it the operator's ~/.claude.json flips the preamble into
// remote-mode and this negative test passes vacuously.
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-home-'));
fs.writeFileSync(
path.join(gstackHome, 'config.yaml'),
@@ -171,11 +176,13 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
const binary = resolveClaudeBinary();
// Ambient env mutations (see note on the first test).
const origGstackHome = process.env.GSTACK_HOME;
const origPath = process.env.PATH;
process.env.GSTACK_HOME = gstackHome;
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
// Per-test env, merged LAST by the hermetic env builder (see note on the
// first test — ambient GSTACK_HOME mutation is scrubbed by hermetic-env).
const childEnv = {
GSTACK_HOME: gstackHome,
HOME: tempHome,
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
};
try {
await runAgentSdkTest({
@@ -185,6 +192,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
workingDirectory: gstackHome,
maxTurns: 4,
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
env: childEnv,
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
canUseTool: async (toolName, input) => {
if (toolName === 'AskUserQuestion') {
@@ -216,12 +224,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
});
expect(privacyQuestions.length).toBe(0);
} finally {
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = origGstackHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = origPath;
fs.rmSync(gstackHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
fs.rmSync(tempHome, { recursive: true, force: true });
}
}, 180_000);
});
+7 -2
View File
@@ -447,8 +447,13 @@ Do NOT use AskUserQuestion.`,
// Match by filename timestamp (stable, unambiguous) plus a looser
// prose check.
const showsMain = /20260101-120000|main-work/.test(out);
const hidesAlpha = !/20260202-120000/.test(out);
const hidesBeta = !/20260303-120000/.test(out);
// Hide checks scope to the FINAL text output only: fullOutputSurface
// includes bash tool_results, and a legitimate `ls` of the checkpoint
// dir lists every branch's filename. The filtering under test happens
// in the user-facing list, not in the agent's intermediate reads.
const finalText = result.output ?? '';
const hidesAlpha = !/20260202-120000|LISTCURR_ALPHA_TOKEN/.test(finalText);
const hidesBeta = !/20260303-120000|LISTCURR_BETA_TOKEN/.test(finalText);
const routed = skillCalls(result).includes('context-save');
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
+4 -2
View File
@@ -126,7 +126,7 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
'Color': ['color', 'colour', 'palette', 'colors'],
'Spacing': ['spacing', 'space', 'whitespace', 'gap'],
'Layout': ['layout', 'grid', 'structure', 'composition'],
'Motion': ['motion', 'animation', 'transition', 'movement'],
'Motion': ['motion', 'animation', 'transition', 'movement', 'easing', 'duration', 'micro-interaction'],
};
const missingSections = Object.entries(sectionSynonyms).filter(
([_, synonyms]) => !synonyms.some(s => designContent.toLowerCase().includes(s))
@@ -152,7 +152,9 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
expect(['success', 'error_max_turns']).toContain(result.exitReason);
expect(designExists).toBe(true);
if (designExists) {
expect(missingSections).toHaveLength(0);
// join() so a failure names the offending section(s) — a bare
// toHaveLength(0) failure never prints WHICH synonym set missed.
expect(missingSections.join(', ')).toBe('');
}
if (claudeExists) {
const claude = fs.readFileSync(claudePath, 'utf-8');
+7 -4
View File
@@ -81,7 +81,10 @@ Write your complete review directly to ${planDir}/review-output.md
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
// 540s: the evidence-before-claimed-limitations directive and the
// design-doc discovery block (fork port wave 2) add real probing turns;
// main cleared this at 243s, the enriched skill needs more headroom.
timeout: 540_000,
testName: 'plan-ceo-review',
runId,
model: 'claude-opus-4-7',
@@ -100,7 +103,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
const review = fs.readFileSync(reviewPath, 'utf-8');
expect(review.length).toBeGreaterThan(200);
}
}, 420_000);
}, 660_000);
});
// --- Plan CEO Review (SELECTIVE EXPANSION) E2E ---
@@ -168,7 +171,7 @@ Write your complete review directly to ${planDir}/review-output-selective.md
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
timeout: 540_000,
testName: 'plan-ceo-review-selective',
runId,
model: 'claude-opus-4-7',
@@ -185,7 +188,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
const review = fs.readFileSync(reviewPath, 'utf-8');
expect(review.length).toBeGreaterThan(200);
}
}, 420_000);
}, 660_000);
});
// --- Plan CEO Review SCOPE EXPANSION energy (V1.1 mode-posture regression gate) ---
+4 -2
View File
@@ -406,7 +406,9 @@ Do NOT fix any bugs. Do NOT use AskUserQuestion — just pick vitest.`,
}, 120_000);
});
// Module-level afterAll — finalize eval collector after all tests complete
// Module-level afterAll — finalize eval collector after all tests complete.
// Explicit 60s timeout: finalize does a JSON save + cross-run comparison and
// has been observed at 6.26s, past bun's 5s default hook timeout.
afterAll(async () => {
await finalizeEvalCollector(evalCollector);
});
}, 60_000);
+29 -12
View File
@@ -130,15 +130,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
const binary = resolveClaudeBinary();
// Ambient env mutations. Restored in finally.
const orig = {
gstackHome: process.env.GSTACK_HOME,
pathEnv: process.env.PATH,
mcpToken: process.env.GBRAIN_MCP_TOKEN,
// Per-test child env, passed via opts.env (merges last over the complete
// hermetic env). Ambient process.env mutations DO NOT reach children:
// hermetic-env scrubs GBRAIN_*/GSTACK_* by allowlist — this test's token
// silently never arrived from the day hermetic env landed, and the child
// correctly stopped at Step 4c with NEEDS_CONTEXT.
const childEnv = {
GSTACK_HOME: gstackHome,
GBRAIN_MCP_TOKEN: SECRET_TOKEN,
PATH: `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
};
process.env.GSTACK_HOME = gstackHome;
process.env.PATH = `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
process.env.GBRAIN_MCP_TOKEN = SECRET_TOKEN;
let modelTextOutput = '';
@@ -146,6 +147,7 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
const skillPath = path.resolve(import.meta.dir, '..', 'setup-gbrain', 'SKILL.md');
const result = await runAgentSdkTest({
systemPrompt: { type: 'preset', preset: 'claude_code' },
env: childEnv,
userPrompt:
`Read the skill file at ${skillPath} and follow Path 4 (Remote MCP) only. ` +
`Use this MCP URL: ${stubServer.url}. ` +
@@ -195,6 +197,19 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
// Assertion 2: claude mcp add was called with --transport http.
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, 'utf-8') : '';
if (!/mcp add.*--transport http/.test(calls)) {
// Failure evidence: without this, the transcript dies with the test
// and every triage pass starts blind (three did).
const bashCmds = result.toolCalls
.filter((t) => t.tool === 'Bash')
.map((t) => String((t.input as { command?: string })?.command ?? '').slice(0, 200));
console.error(
`[setup-gbrain-remote] mcp-add never hit the fake shim.\n` +
`exitReason=${result.exitReason} turns=${result.turnsUsed}\n` +
`--- bash commands (${bashCmds.length}) ---\n${bashCmds.join('\n')}\n` +
`--- final text (last 1500 chars) ---\n${result.output.slice(-1500)}`,
);
}
expect(calls).toMatch(/mcp add.*--transport http/);
// Assertion 3: the secret token NEVER appears in the final CLAUDE.md.
@@ -207,14 +222,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
// Assertion 5: classifier — the model didn't write findings before
// asking. The Path 4 prose has 5 STOP gates; if any of them got
// skipped, that's the wrote_findings_before_asking pattern.
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(modelTextOutput);
// Scan the ASSISTANT's text only: modelTextOutput serializes every
// event including the child's Read of the skill file, whose generated
// footer contains the literal "GSTACK REVIEW REPORT" — a guaranteed
// false positive on both trees.
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(result.output);
// Setup-gbrain doesn't have a review report contract, so this is
// a structural shape check, not a hard failure mode.
expect(wroteBefore).toBe(false);
} finally {
if (orig.gstackHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = orig.gstackHome;
if (orig.pathEnv === undefined) delete process.env.PATH; else process.env.PATH = orig.pathEnv;
if (orig.mcpToken === undefined) delete process.env.GBRAIN_MCP_TOKEN; else process.env.GBRAIN_MCP_TOKEN = orig.mcpToken;
// (no ambient process.env mutations to restore — env goes via opts.env)
await stubServer.close();
fs.rmSync(gstackHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
+9 -3
View File
@@ -158,7 +158,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
const session = await launchClaudePty({
permissionMode: 'plan',
cwd: fixture.workTree,
timeoutMs: 720_000,
timeoutMs: 1_080_000,
// Disable network-y pieces so the agent can't reach actual github.
env: { GH_TOKEN: 'mock-not-real', NO_COLOR: '1' },
seedSkills: true,
@@ -172,7 +172,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
const since = session.mark();
session.send('/ship\r');
const budgetMs = 600_000;
const budgetMs = 900_000;
const start = Date.now();
let lastPermSig = '';
while (Date.now() - start < budgetMs) {
@@ -234,6 +234,12 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
break;
}
}
// Budget exhausted without a terminal signal: capture the tail NOW,
// while the session is still alive. Only the break paths above set
// evidence — without this, the timeout throw ships evidence: "".
if (outcome === 'timeout') {
evidence = session.visibleSince(since).slice(-3000);
}
} finally {
await session.close();
}
@@ -273,6 +279,6 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
try { fs.rmSync(path.dirname(fixture.workTree), { recursive: true, force: true }); } catch { /* ignore */ }
}
},
900_000, // 15 min wall clock
1_200_000, // 20 min wall clock
);
});
+5 -3
View File
@@ -1216,9 +1216,11 @@ describe('Step 3.4 test coverage audit', () => {
describe('ship step numbering', () => {
// Allowed sub-steps that are resolver-generated and intentionally nested:
// 8.1 (Plan Verification), 8.2 (Scope Drift), 9.1 (Review Army), 9.2 (Findings Merge),
// 9.3 (Cross-review dedup), 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
const ALLOWED_SUBSTEPS = new Set(['8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
// 0.9 (Apple target detection — MUST precede Step 1's branch gate, R2-pinned
// by test/ship-apple-gate.test.ts), 8.1 (Plan Verification), 8.2 (Scope
// Drift), 9.1 (Review Army), 9.2 (Findings Merge), 9.3 (Cross-review dedup),
// 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
const ALLOWED_SUBSTEPS = new Set(['0.9', '8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
test('ship/SKILL.md.tmpl contains no unexpected fractional step numbers', () => {
const tmpl = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md.tmpl'), 'utf-8');
+106
View File
@@ -0,0 +1,106 @@
/**
* Regression pin for #2018: /autoplan Phase 4's task aggregator emitted zero
* tasks on every run, forever, for everyone.
*
* Root cause: the branch+commit filter in scripts/resolvers/tasks-section.ts
* piped to the split commit array and THEN referenced `.commit` —
*
* select(.branch == $branch and ($commits | split("|") | index(.commit) != null))
*
* In jq, the pipe rebinds the context, so `.commit` was evaluated against the
* split ARRAY ("Cannot index array with string \"commit\""), every input line
* errored, stderr went to /dev/null, `|| true` swallowed the exit code, and
* the aggregate was empty. A dead feature indistinguishable from "no tasks".
*
* These tests run the ACTUAL jq program extracted from the resolver source
* against fixture JSONL, so they were RED against the broken filter and stay
* red if anyone reintroduces a context-rebinding shape.
*/
import { describe, it, expect } from "bun:test";
import { execFileSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
const SOURCE_PATH = join(import.meta.dir, "..", "scripts", "resolvers", "tasks-section.ts");
/**
* Extract the emitted jq filter program from the resolver source. The
* resolver builds bash inside a TS template literal, so `\\` in source is a
* bash line-continuation `\` — strip it when unescaping. We match the
* single-quoted jq program on the line that filters by $branch + $commits.
*/
function extractBranchCommitFilter(): string {
const src = readFileSync(SOURCE_PATH, "utf-8");
const m = src.match(/'([^']*select\(\.branch == \$branch[^']*)'/);
if (!m) throw new Error("branch+commit jq filter not found in tasks-section.ts");
return m[1].replace(/\\\\/g, "\\");
}
function runJq(program: string, inputLines: string[], branch: string, commits: string): string[] {
const out = execFileSync(
"jq",
["-c", "--arg", "branch", branch, "--arg", "commits", commits, program],
{ input: inputLines.join("\n"), encoding: "utf-8" },
);
return out.split("\n").filter(Boolean);
}
const RECORD = (branch: string, commit: string) =>
JSON.stringify({
phase: "ceo-review",
run_id: "20260814T000000Z-1",
branch,
commit,
id: "T1",
priority: "P1",
component: "demo",
files: ["a.ts"],
effort_human: "~1h",
effort_cc: "~5min",
title: "demo task",
source_finding: "demo finding",
});
describe("tasks-section jq filter (#2018)", () => {
it("matches a record whose branch and commit are in the window", () => {
const program = extractBranchCommitFilter();
const matched = runJq(
program,
[RECORD("feature/x", "abc123")],
"feature/x",
"abc123|def456",
);
// The broken filter returned [] here (every line errored) — the exact
// #2018 symptom: reviews produced tasks, the aggregate table showed none.
expect(matched).toHaveLength(1);
expect(JSON.parse(matched[0]).id).toBe("T1");
});
it("filters out other branches and out-of-window commits", () => {
const program = extractBranchCommitFilter();
const matched = runJq(
program,
[
RECORD("feature/x", "abc123"),
RECORD("other-branch", "abc123"),
RECORD("feature/x", "zzz999"),
],
"feature/x",
"abc123|def456",
);
expect(matched).toHaveLength(1);
});
it("errors on no input line at all rather than fabricating output", () => {
const program = extractBranchCommitFilter();
expect(runJq(program, [], "feature/x", "abc123")).toHaveLength(0);
});
it("source does not reference .commit after a context-rebinding pipe", () => {
const src = readFileSync(SOURCE_PATH, "utf-8");
// The bug shape: split("|") piped, then a bare `.commit` in the new array
// context. Binding first (`.commit as $c`) is the required form.
expect(src).not.toMatch(/split\("\|"\)\s*\|\s*index\(\.commit\)/);
});
});
+107 -24
View File
@@ -12,14 +12,20 @@
* (bin/gstack-telemetry-log)
*
* gstack-telemetry-sync MUST strip every one of those fields before the remote
* POST (bin/gstack-telemetry-sync). This test enforces that contract three ways:
* POST (bin/gstack-telemetry-sync). The script has TWO strip paths — jq del()
* is PRIMARY (structural, escape-proof), sed is the jq-less fallback — and
* this test enforces the contract on both:
*
* 1. Coverage — every repo/branch field the producers emit is also stripped.
* Catches "added a new repo field, forgot to strip it" (the rename-to-_repo
* landmine, or any future producer drift).
* 2. Behavior — run the ACTUAL sed strip expressions from the sync script over
* a sample event line and assert no repo/branch field survives, while benign
* fields do. Catches a broken/edited regex, not just a missing line.
* 1. Coverage — every repo/branch field the producers emit is also stripped,
* by every jq del() list AND by the sed expressions. Catches "added a new
* repo field, forgot to strip it" (the rename-to-_repo landmine, or any
* future producer drift) on whichever path a machine takes.
* 2. Behavior — run the ACTUAL jq expression and the ACTUAL sed strip
* expressions from the sync script over a sample event line and assert no
* repo/branch field survives, while benign fields do. Catches a
* broken/edited filter, not just a missing line. The jq leg also pins the
* malformed-line contract: a line jq can't parse is dropped, never
* forwarded unstripped.
* 3. Floor — the three known fields are always in the stripped set, so deleting
* a strip rule fails CI even if a producer also stops emitting it.
*/
@@ -45,6 +51,16 @@ function extractSedExprs(scriptText: string): string[] {
return [...scriptText.matchAll(/-e\s+'(s\/[^']*)'/g)].map((m) => m[1]);
}
/** Pull every `jq -c 'del(...)'` filter out of the sync script, verbatim. */
function extractJqDelFilters(scriptText: string): string[] {
return [...scriptText.matchAll(/jq -c '(del\([^']*\))'/g)].map((m) => m[1]);
}
/** The JSON keys a jq del() filter removes, e.g. `del(._repo_slug, .repo)`. */
function fieldsFromJqDel(filter: string): string[] {
return [...filter.matchAll(/\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
}
/** The JSON key a strip expression targets, e.g. `,"repo":"[^"]*"` -> `repo`. */
function fieldFromSedExpr(expr: string): string | null {
const m = expr.match(/,"([A-Za-z_][A-Za-z0-9_]*)":/);
@@ -73,6 +89,25 @@ describe('telemetry no-repo-identity-egress invariant', () => {
const strippedFields = new Set(
strippedRepoExprs.map(fieldFromSedExpr).filter((f): f is string => f !== null),
);
const jqFilters = extractJqDelFilters(syncText);
// Repo-identity fields the producers emit into the synced file — computed
// once, asserted against BOTH strip paths (jq primary, sed fallback). Only
// emission lines that target the synced file (skill-usage.jsonl) count: the
// preamble appends directly; gstack-telemetry-log builds the synced event
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
const preambleSynced = fs
.readFileSync(PREAMBLE, 'utf-8')
.split('\n')
.filter((l) => l.includes('skill-usage.jsonl'));
const telLogSynced = fs
.readFileSync(TEL_LOG, 'utf-8')
.split('\n')
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
const emitted = new Set<string>([
...emittedRepoFields(preambleSynced),
...emittedRepoFields(telLogSynced),
]);
test('floor: the three known repo-identity fields are stripped', () => {
for (const field of REPO_IDENTITY_FLOOR) {
@@ -80,33 +115,34 @@ describe('telemetry no-repo-identity-egress invariant', () => {
}
});
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped', () => {
// Only emission lines that target the synced file (skill-usage.jsonl). The
// preamble appends directly; gstack-telemetry-log builds the synced event
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
const preambleSynced = fs
.readFileSync(PREAMBLE, 'utf-8')
.split('\n')
.filter((l) => l.includes('skill-usage.jsonl'));
const telLogSynced = fs
.readFileSync(TEL_LOG, 'utf-8')
.split('\n')
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
const emitted = new Set<string>([
...emittedRepoFields(preambleSynced),
...emittedRepoFields(telLogSynced),
]);
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped (sed fallback path)', () => {
// The preamble must emit "repo" — guards against the test silently passing
// because a regex stopped matching the producer.
expect(emitted.has('repo')).toBe(true);
for (const field of emitted) {
expect(
strippedFields.has(field),
`producer emits repo-identity field "${field}" but gstack-telemetry-sync does not strip it (would leak to remote)`,
`producer emits repo-identity field "${field}" but gstack-telemetry-sync's sed fallback does not strip it (would leak to remote)`,
).toBe(true);
}
});
test('coverage: every jq del() list (the PRIMARY strip path) covers every emitted repo-identity field', () => {
// Both tiers run a del() filter; each must strip full repo identity on its
// own — a machine only ever takes one branch.
expect(jqFilters.length).toBeGreaterThanOrEqual(2);
expect(emitted.has('repo')).toBe(true); // producer-regex canary, as above
for (const filter of jqFilters) {
const delFields = new Set(fieldsFromJqDel(filter));
for (const field of [...emitted, ...REPO_IDENTITY_FLOOR]) {
expect(
delFields.has(field),
`jq filter "${filter}" does not del repo-identity field "${field}" (primary strip path would leak it to remote)`,
).toBe(true);
}
}
});
test('behavior: the real sed expressions remove repo identity, keep benign fields', () => {
const sample =
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
@@ -134,4 +170,51 @@ describe('telemetry no-repo-identity-egress invariant', () => {
expect(cleaned).toContain('"sessions":3');
expect(cleaned).toContain('"ts":"2026-06-02T00:00:00Z"');
});
test('behavior: the real jq del() filters strip repo identity and drop malformed lines', () => {
if (!Bun.which('jq')) return; // jq-less machine: the sed-fallback behavior test above is the live path
const sample =
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
'"repo":"my-secret-repo","_repo_slug":"acme-my-secret-repo","_branch":"feature-x",' +
'"sessions":3,"installation_id":"abc123"}';
// The identified-tier filter (no installation_id in its del list) and the
// anonymous-tier filter (installation_id included) — run each verbatim.
const identified = jqFilters.find((f) => !f.includes('installation_id'));
const anonymous = jqFilters.find((f) => f.includes('installation_id'));
expect(identified).toBeTruthy();
expect(anonymous).toBeTruthy();
const runJq = (filter: string, input: string) => {
const out = spawnSync(['jq', '-c', filter], { stdin: Buffer.from(input) });
return { exitCode: out.exitCode, stdout: out.stdout.toString().trim() };
};
const id = runJq(identified!, sample);
expect(id.exitCode).toBe(0);
// No repo/branch identity survives, value or key.
expect(id.stdout).not.toContain('my-secret-repo');
expect(id.stdout).not.toContain('feature-x');
expect(id.stdout).not.toContain('"repo"');
expect(id.stdout).not.toContain('_repo_slug');
expect(id.stdout).not.toContain('_branch');
// Benign fields are untouched; identified tier keeps installation_id.
expect(id.stdout).toContain('"skill":"design-shotgun"');
expect(id.stdout).toContain('"sessions":3');
expect(id.stdout).toContain('"installation_id":"abc123"');
// Anonymous tier additionally drops installation_id.
const anon = runJq(anonymous!, sample);
expect(anon.exitCode).toBe(0);
expect(anon.stdout).not.toContain('installation_id');
expect(anon.stdout).not.toContain('my-secret-repo');
// Malformed line: jq fails and emits nothing — the sync script's
// `|| CLEAN=""` + `[ -z "$CLEAN" ] && continue` drops it, so bytes the
// strip never touched are never forwarded.
const bad = runJq(identified!, '{"v":1,"repo":"my-secret-repo"');
expect(bad.exitCode).not.toBe(0);
expect(bad.stdout).toBe('');
});
});
+359
View File
@@ -0,0 +1,359 @@
/**
* gstack-verify-gate — Stop-hook enforcement tier.
*
* Pins the behaviours the gate exists for:
* trust — a declared command NEVER runs until the user records it via
* `gstack-verify-gate --trust` (per-repo command trust store).
* block — trusted check fails, exit 2, turn cannot end.
* allow — trusted check passes, exit 0.
* fail open — nothing declared, exit 0. Absence never blocks.
*
* Plus the two safety branches: the Stop re-entry guard, and the static
* opt-in contract (our adaptation): ./setup never registers the gate; the
* settings-hook helper.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createHash } from 'crypto';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const GATE = path.join(ROOT, 'bin', 'gstack-verify-gate');
let dir: string;
let gstackHome: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-'));
gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-home-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(gstackHome, { recursive: true, force: true });
});
/** Declare a verification command in the project's CLAUDE.md (comment form). */
function declareCheck(command: string): void {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `# Fixture\n\n<!-- gstack:verify: ${command} -->\n`);
}
/** Write the check script the declaration points at. Touches `check-ran` when executed. */
function check(exitCode: number, message: string): void {
const script = path.join(dir, 'check.sh');
fs.writeFileSync(script, `#!/bin/sh\ntouch check-ran\necho "${message}"\nexit ${exitCode}\n`);
fs.chmodSync(script, 0o755);
}
/** Did the declared check actually execute? */
function checkRan(): boolean {
return fs.existsSync(path.join(dir, 'check-ran'));
}
interface RunOpts {
stopHookActive?: boolean;
cwd?: string;
/** When false, CLAUDE_PROJECT_DIR is removed from the child env (walk-up mode). */
projectDirEnv?: boolean;
/** Hook-input session id, keys the re-entry attempt counter. */
sessionId?: string;
}
function gateEnv(projectDirEnv: boolean): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_HOME: gstackHome };
if (projectDirEnv) env.CLAUDE_PROJECT_DIR = dir;
else delete env.CLAUDE_PROJECT_DIR;
return env;
}
function runGate(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
const input: Record<string, unknown> = { stop_hook_active: opts.stopHookActive ?? false };
if (opts.sessionId) input.session_id = opts.sessionId;
const r = spawnSync(GATE, {
cwd: opts.cwd ?? dir,
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 15000,
env: gateEnv(opts.projectDirEnv ?? true),
});
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
/** Record the currently-declared command in the trust store. */
function trust(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
const r = spawnSync(GATE, ['--trust'], {
cwd: opts.cwd ?? dir,
encoding: 'utf-8',
timeout: 15000,
env: gateEnv(opts.projectDirEnv ?? true),
});
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
describe('gstack-verify-gate trust store', () => {
test('an untrusted declared command is NOT executed and does not block', () => {
declareCheck('touch sentinel-ran');
const r = runGate();
expect(r.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
expect(r.stderr).toContain('--trust');
expect(r.stderr).toContain('touch sentinel-ran');
});
test('--trust records the command and prints a confirmation naming it', () => {
declareCheck('./check.sh');
check(0, 'all good');
const t = trust();
expect(t.code).toBe(0);
expect(t.stdout).toContain('./check.sh');
});
test('after --trust the hook executes the command and block semantics work', () => {
declareCheck('./check.sh');
check(1, 'totals mismatch');
expect(trust().code).toBe(0);
const r = runGate();
expect(checkRan()).toBe(true);
expect(r.code).toBe(2);
expect(r.stderr).toContain('FAILED');
expect(r.stderr).toContain('totals mismatch');
});
test('a changed command is not executed until re-trusted', () => {
declareCheck('true');
expect(trust().code).toBe(0);
// Attacker (or anyone) edits the declaration after trust was granted.
declareCheck('touch sentinel-ran');
const r = runGate();
expect(r.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
expect(r.stderr).toContain('--trust');
// Re-trusting the new command restores execution.
expect(trust().code).toBe(0);
const r2 = runGate();
expect(r2.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(true);
});
test('walk-up: hook run from a nested subdir keys trust on the CLAUDE.md root', () => {
declareCheck('./check.sh');
check(0, 'all good');
const nested = path.join(dir, 'a', 'b');
fs.mkdirSync(nested, { recursive: true });
// No CLAUDE_PROJECT_DIR: both trust and hook must walk up from $PWD.
expect(trust({ cwd: nested, projectDirEnv: false }).code).toBe(0);
const r = runGate({ cwd: nested, projectDirEnv: false });
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
expect(checkRan()).toBe(true);
});
test('non-comment declaration form (gstack:verify: cmd without <!-- -->) is honored', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\ngstack:verify: ./check.sh\n');
check(0, 'all good');
expect(trust().stdout).toContain('./check.sh');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
expect(checkRan()).toBe(true);
});
test('the trust store file is created 0600 under GSTACK_HOME', () => {
declareCheck('./check.sh');
check(0, 'all good');
expect(trust().code).toBe(0);
const store = path.join(gstackHome, 'verify-gate-trust');
expect(fs.existsSync(store)).toBe(true);
expect(fs.statSync(store).mode & 0o777).toBe(0o600);
});
test('--trust fails cleanly when nothing is declared', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
const t = trust();
expect(t.code).not.toBe(0);
});
});
describe('gstack-verify-gate', () => {
test('blocks the turn when the trusted check fails', () => {
declareCheck('./check.sh');
check(1, 'totals mismatch');
expect(trust().code).toBe(0);
const r = runGate();
expect(r.code).toBe(2);
expect(r.stderr).toContain('FAILED');
expect(r.stderr).toContain('totals mismatch');
});
test('allows the turn when the trusted check passes', () => {
declareCheck('./check.sh');
check(0, 'all good');
expect(trust().code).toBe(0);
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
});
test('fails open when CLAUDE.md declares no check', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
});
test('fails open when there is no CLAUDE.md at all', () => {
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('no CLAUDE.md');
});
test('never invents a command: an empty declaration fails open', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '<!-- gstack:verify: -->\n');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
});
});
describe('gstack-verify-gate re-entry enforcement (no one-shot bypass)', () => {
test('re-entry with a still-failing trusted check is blocked again', () => {
declareCheck('./check.sh');
check(1, 'still failing');
expect(trust().code).toBe(0);
const r = runGate({ stopHookActive: true, sessionId: 'sess-refail' });
expect(r.code).toBe(2);
expect(r.stderr).toContain('FAILED');
expect(r.stderr).toContain('still failing');
expect(checkRan()).toBe(true);
});
test('re-entry after the check now passes is allowed', () => {
declareCheck('./check.sh');
check(1, 'totals mismatch');
expect(trust().code).toBe(0);
expect(runGate({ sessionId: 'sess-fixed' }).code).toBe(2);
check(0, 'fixed now');
const r = runGate({ stopHookActive: true, sessionId: 'sess-fixed' });
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
});
test('attempt bound: repeated failing re-entries allow with a loud warning at the bound', () => {
declareCheck('./check.sh');
check(1, 'never passing');
expect(trust().code).toBe(0);
const sid = 'sess-bound';
// First entry blocks and resets the episode counter.
expect(runGate({ sessionId: sid }).code).toBe(2);
// Re-entries: bounded number of blocks, then allow-with-warning.
const codes: number[] = [];
let final: { code: number; stdout: string; stderr: string } | null = null;
for (let i = 0; i < 6; i++) {
const r = runGate({ stopHookActive: true, sessionId: sid });
codes.push(r.code);
if (r.code === 0) {
final = r;
break;
}
expect(r.code).toBe(2);
}
expect(codes).toEqual([2, 2, 2, 0]);
expect(final).not.toBeNull();
expect(final!.stdout + final!.stderr).toContain('WARNING');
// A fresh first-entry run starts a new episode: blocked again, not allowed.
expect(runGate({ sessionId: sid }).code).toBe(2);
});
test('re-entry with an untrusted command keeps the exit-0-with-hint path', () => {
declareCheck('touch sentinel-ran');
const r = runGate({ stopHookActive: true, sessionId: 'sess-untrusted' });
expect(r.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
expect(r.stderr).toContain('--trust');
});
});
describe('gstack-verify-gate trust-grant audit trail', () => {
test('every --trust grant appends a JSON audit line (right sha256, 0600, verbatim cmd)', () => {
declareCheck('./check.sh');
check(0, 'all good');
const t = trust();
expect(t.code).toBe(0);
// --trust prints the VERBATIM command being trusted.
expect(t.stdout).toContain('./check.sh');
const log = path.join(gstackHome, 'security', 'verify-gate-trust-grants.jsonl');
expect(fs.existsSync(log)).toBe(true);
expect(fs.statSync(log).mode & 0o777).toBe(0o600);
const lines = fs.readFileSync(log, 'utf-8').trim().split('\n');
expect(lines.length).toBe(1);
const entry = JSON.parse(lines[0]);
expect(entry.cmd).toBe('./check.sh');
expect(entry.cmd_sha256).toBe(createHash('sha256').update('./check.sh').digest('hex'));
expect(entry.root).toBe(fs.realpathSync(dir));
expect(typeof entry.tty).toBe('boolean');
expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// A second grant appends, never truncates.
declareCheck('true');
expect(trust().code).toBe(0);
const lines2 = fs.readFileSync(log, 'utf-8').trim().split('\n');
expect(lines2.length).toBe(2);
expect(JSON.parse(lines2[1]).cmd).toBe('true');
});
});
describe('opt-in contract (adapted from the fork: NOT registered by default)', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const gate = fs.readFileSync(GATE, 'utf-8');
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
expect(setup).not.toContain('verify-gate');
});
test('the bin documents its own registration and removal commands', () => {
expect(gate).toContain('remove-source --source verify-gate');
expect(gate).toContain('gstack:verify:');
});
});