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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: coverage backfill from the ship review

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: update project documentation for v1.65.0.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

2413 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Real-PTY runner for Claude Code plan-mode E2E tests.
*
* Spawns the actual `claude` binary via `Bun.spawn({terminal:})`, drives
* it through stdin/stdout, parses the rendered terminal frames, and exposes
* primitives the 5 plan-mode tests need. Replaces the SDK-based
* `runPlanModeSkillTest` from plan-mode-helpers.ts which never worked
* because plan mode doesn't use the AskUserQuestion tool — it uses its
* own TTY-rendered native confirmation UI.
*
* Why this exists: the SDK harness intercepts `canUseTool` for
* `AskUserQuestion`. Claude in plan mode renders its "Ready to execute"
* confirmation as a native option list (1-4 numbered options) without
* invoking the AskUserQuestion tool. The SDK never sees it. Real PTY
* does — it shows up as text on screen with `` cursor markers.
*
* Architecture: pure Bun.spawn — no node-pty, no native modules, no chmod
* fixes. Bun 1.3.10+ has built-in PTY support via the `terminal:` spawn
* option. Pattern borrowed from cc-pty-import branch's terminal-agent.ts
* (the WS/cookie/Origin scaffolding there is for the browser sidebar;
* 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';
import { hermeticChildEnv, hermeticSkillsConfigDir, isHermeticEnabled } from './hermetic-env';
/** Strip ANSI escapes for pattern-matching against visible text. */
export function stripAnsi(s: string): string {
return s
.replace(/\x1b\[[\d;]*[a-zA-Z]/g, '')
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
.replace(/\x1b[()][AB012]/g, '')
.replace(/\x1b[78=>]/g, '');
}
/** Find claude on PATH, with fallback locations. Mirrors terminal-agent.ts. */
export function resolveClaudeBinary(): string | null {
const override = process.env.BROWSE_TERMINAL_BINARY;
if (override && fs.existsSync(override)) return override;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const which = (Bun as any).which?.('claude');
if (which) return which;
const candidates = [
'/opt/homebrew/bin/claude',
'/usr/local/bin/claude',
`${process.env.HOME}/.local/bin/claude`,
`${process.env.HOME}/.bun/bin/claude`,
`${process.env.HOME}/.npm-global/bin/claude`,
];
for (const c of candidates) {
try {
fs.accessSync(c, fs.constants.X_OK);
return c;
} catch {
/* keep searching */
}
}
return null;
}
export interface ClaudePtyOptions {
/** Register the repo's shipped skills in the child's user scope via
* hermeticSkillsConfigDir(). Required by any test that types a /skill
* slash command; without it hermetic claude rejects the command as
* Unknown before any model turn. No effect when EVALS_HERMETIC=0. */
seedSkills?: boolean;
/**
* Permission mode for the session.
* - 'plan' (default) — launches with --permission-mode plan
* - undefined — no --permission-mode flag at all (regular interactive)
* Other valid SDK modes ('default', 'acceptEdits', 'bypassPermissions',
* 'auto', 'dontAsk') are passed through verbatim.
*/
permissionMode?: 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' | 'auto' | 'dontAsk' | null;
/** Extra args after the permission-mode flag. */
extraArgs?: string[];
/**
* Model for the spawned interactive `claude`. Without an explicit --model the
* child inherits the operator's ~/.claude/settings.json model (e.g.
* claude-fable-5[1m]), which can spend 5+ min in extended thinking on an empty
* plan-mode context and blow every smoke budget. Resolution mirrors
* session-runner.ts:144 exactly: opts.model ?? EVALS_MODEL ?? 'claude-sonnet-4-6'.
* Pushed BEFORE extraArgs so a test-supplied --model still wins (last flag wins).
*/
model?: string;
/** Terminal size. Default 120x40. Plan-mode UI lays out cleanly at this size. */
cols?: number;
rows?: number;
/** Working directory. Default: process.cwd(). The repo cwd has the gstack
* skill registry and trusted-folder cookie, so most tests want this. */
cwd?: string;
/** Extra env on top of process.env. */
env?: Record<string, string>;
/** Total run timeout (ms). Default 240000 (4 min). */
timeoutMs?: number;
}
export interface ClaudePtySession {
/** Send raw bytes to PTY stdin. Newlines = "\r" in TTY world. */
send(data: string): void;
/** Send a key by name. Limited set used by these tests. */
sendKey(key: 'Enter' | 'Up' | 'Down' | 'Esc' | 'Tab' | 'ShiftTab' | 'CtrlC'): void;
/** Raw accumulated stdout (with ANSI). For forensics. */
rawOutput(): string;
/** Visible (ANSI-stripped) output for the entire session. For pattern matching. */
visibleText(): string;
/**
* Mark the current buffer position. Subsequent waitForAny / visibleSince
* calls only look at output AFTER this mark. Use to scope assertions to
* "after I sent the skill command" — avoids matching against the trust
* dialog or boot banner residue. Returns a marker handle.
*/
mark(): number;
/** Visible text since the most recent (or specific) mark. */
visibleSince(marker?: number): string;
/**
* Wait for any of the supplied patterns to appear in visibleText. Resolves
* with the first match. Throws on timeout (with last 2KB of visible text).
* If `since` is supplied, only matches text after that mark.
*/
waitForAny(
patterns: Array<RegExp | string>,
opts?: { timeoutMs?: number; pollMs?: number; since?: number },
): Promise<{ matched: RegExp | string; index: number }>;
/** Convenience: single-pattern wait. */
waitFor(
pattern: RegExp | string,
opts?: { timeoutMs?: number; pollMs?: number; since?: number },
): Promise<void>;
/** Process pid (for debug). */
pid(): number | undefined;
/** Whether the underlying process has exited. */
exited(): boolean;
/** Exit code, if known. */
exitCode(): number | null;
/**
* The hermetic CLAUDE_CONFIG_DIR this session's claude was pointed at, or
* null when EVALS_HERMETIC=0. Forensics: hermetic plan files live under
* `<hermeticConfigDir>/plans/` (extractPlanFilePath still matches them —
* the dir name ends in `/.claude` by contract).
*/
hermeticConfigDir: string | null;
/**
* Send SIGINT, then SIGKILL after 1s. Always safe to call multiple times.
* Awaits process exit before resolving.
*/
close(): Promise<void>;
}
/** Detect the workspace-trust dialog rendering. */
export function isTrustDialogVisible(visible: string): boolean {
// Phrase Claude Code prints. Stable across versions in this branch's range.
return visible.includes('trust this folder');
}
/**
* Detect plan-mode's native "ready to execute" confirmation. Tests both the
* spaced and whitespace-collapsed forms because stripAnsi removes cursor-
* positioning escapes (e.g. `\x1b[40C`) that render visually as spaces but
* leave no character behind — so "ready to execute" can come through as
* "readytoexecute" depending on the rendering path.
*/
export function isPlanReadyVisible(visible: string): boolean {
if (/ready to execute|Would you like to proceed/i.test(visible)) return true;
const collapsed = visible.replace(/\s+/g, '');
return /readytoexecute|Wouldyouliketoproceed/i.test(collapsed);
}
/**
* Detect the AUTO_DECIDE preamble template firing. The model prints
* "Auto-decided <summary> → <option> (your preference). Change with /plan-tune."
* when it short-circuits an AskUserQuestion via the question-tuning resolver
* (`scripts/resolvers/question-tuning.ts:26`). The "Auto-decided ..." stem +
* "(your preference)" tail combination is the tightest signal. Whitespace-
* collapsed forms covered for the same TTY-rendering reason as
* isPlanReadyVisible.
*/
export function isAutoDecidedVisible(visible: string): boolean {
const stemMatch =
/Auto-decided\b/i.test(visible) || /Auto-decided/i.test(visible.replace(/\s+/g, ''));
if (!stemMatch) return false;
if (/\(your preference\)/i.test(visible)) return true;
return /\(yourpreference\)/i.test(visible.replace(/\s+/g, ''));
}
/**
* Extract the plan file path from rendered TTY output. Plan-mode's native
* confirmation includes one of these formats near the "Ready to execute?"
* prompt:
* - `Plan saved to: /path/to/plan.md`
* - `Plan file: /path/to/plan.md`
* - `ctrl-g to edit in VSCode · ~/.claude/plans/<name>.md`
*
* stripAnsi may collapse whitespace via cursor-positioning escape removal,
* so the regex tolerates variable spacing. Returns the resolved absolute
* path with `~` expanded, or null if no path was rendered.
*
* Used by v1.22 AskUserQuestion-blocked regression tests to read the plan
* file post-`plan_ready` and verify it contains a decisions section, which
* distinguishes the legitimate fallback flow ("write decision brief into
* plan file") from the silent-skip regression ("write a plan that didn't
* surface any decisions").
*/
export function extractPlanFilePath(visible: string): string | null {
// Patterns checked in order of specificity. Each captures the .md path.
// The visible buffer may have stripAnsi-collapsed whitespace ("yet at" can
// become "yetat"), so the captured path MUST start at a clear path-anchor
// character: `~/`, `/Users/`, `/home/`, `/var/`, or `/tmp/`. Anchoring on
// these prefixes prevents earlier non-whitespace characters from being
// glommed into the path (real bug seen in the wild: `yetat/Users/...`).
const PATH_ANCHOR = '(~\\/|\\/Users\\/|\\/home\\/|\\/var\\/|\\/tmp\\/|\\.\\/)';
const patterns: RegExp[] = [
new RegExp(`Plan\\s*saved\\s*to\\s*:?\\s*(${PATH_ANCHOR}\\S+\\.md)`, 'i'),
new RegExp(`Plan\\s*file\\s*:?\\s*(${PATH_ANCHOR}\\S+\\.md)`, 'i'),
new RegExp(`·\\s*(${PATH_ANCHOR}\\S*\\.claude\\/plans\\/\\S+\\.md)`, 'i'),
// Fallback: any path-anchored reference to a .claude/plans .md file.
new RegExp(`(${PATH_ANCHOR}\\S*\\.claude\\/plans\\/[\\w-]+\\.md)`, 'i'),
];
for (const p of patterns) {
const m = visible.match(p);
if (m && m[1]) {
let raw = m[1];
// Strip trailing punctuation that some patterns may capture.
raw = raw.replace(/\.+$/, '.md').replace(/\.md\.+$/, '.md');
// Tilde expansion to absolute path.
if (raw.startsWith('~')) {
const home = process.env.HOME ?? '';
raw = home + raw.slice(1);
}
return raw;
}
}
return null;
}
/**
* Read a plan file written by a plan-mode skill and verify it contains a
* "decisions" section — evidence the skill surfaced the decisions it was
* supposed to gate on, even when AskUserQuestion is --disallowedTools and
* the model used the plan-file fallback flow instead of a numbered prompt.
*
* Accepts any `## Decisions ...` heading (the canonical form from the
* preamble is `## Decisions to confirm`, but small variants like
* `## Decisions needed` or `## Decisions for review` are common). Returns
* false if the file is unreadable, missing, or has no decisions section.
*/
export function planFileHasDecisionsSection(planFile: string): boolean {
try {
const content = fs.readFileSync(planFile, 'utf-8');
return /^##\s+Decisions\b/im.test(content);
} catch {
return false;
}
}
/**
* Recent-tail window (in bytes of stripped TTY text) used when classifying
* permission dialogs. Old permission text persists in the visibleSince buffer
* after the dialog is dismissed, so callers should pass `visible.slice(-TAIL_SCAN_BYTES)`
* to avoid re-triggering on stale scrollback. Shared between `runPlanSkillObservation`
* and `navigateToModeAskUserQuestion` in the routing test so tuning stays in sync.
*/
export const TAIL_SCAN_BYTES = 1500;
/**
* Detect a Claude Code permission dialog. These render as a numbered
* option list (so isNumberedOptionListVisible matches them) but they
* are NOT a skill's AskUserQuestion — they're claude asking the user
* whether to grant a tool/file permission. Tests that look for skill
* AskUserQuestions must explicitly skip these.
*
* The English phrases below are stable across recent Claude Code
* versions. The check is permissive on whitespace because TTY rendering
* may wrap or reflow text.
*
* Co-trigger requirement: the bare phrase "Do you want to proceed?" is
* generic enough that a skill question could legitimately use it
* ("Do you want to proceed with HOLD SCOPE?"). To avoid mis-classifying
* skill questions as permission dialogs, this phrase only counts when it
* co-occurs with a file-edit context ("Edit to <path>" or "Write to <path>").
* The standalone permission signatures (`requested permissions to`,
* `allow all edits`, `always allow access to`, `Bash command requires permission`)
* remain unconditional.
*/
export function isPermissionDialogVisible(visible: string): boolean {
// Standalone signatures — high specificity, never appear in skill questions.
if (/requested\s+permissions?\s+to/i.test(visible)) return true;
// "Yes / Yes, allow all edits / No" shape — file-edit permission grants.
if (/\ballow\s+all\s+edits\b/i.test(visible)) return true;
// "Yes, and always allow access to <dir>" shape — workspace trust.
if (/always\s+allow\s+access\s+to/i.test(visible)) return true;
// Bash command permission prompts.
if (/Bash\s+command\s+.*\s+requires\s+permission/i.test(visible)) return true;
// "Do you want to proceed?" only counts as a permission dialog when paired
// with a file-edit context. Skill questions can use the bare phrase.
if (
/Do\s+you\s+want\s+to\s+proceed\?/i.test(visible) &&
/(Edit|Write)\s+to\s+\S+/i.test(visible)
) {
return true;
}
return false;
}
/** Detect any AskUserQuestion-shaped numbered option list with cursor. */
/**
* Strip terminal residue that survives ANSI-stripping and can interleave
* with AUQ text: DEC cursor-visibility fragments (`[?25l` / `[?25h` — the ESC
* byte is gone but the bracket sequence remains) and the spinner frames
* rendered between them. Observed in plan-design-with-ui's failure buffer,
* where `[?25l✻Sprouting…[?25h` fragments sat inside the option lines.
*/
export function stripPtyResidue(visible: string): string {
return visible.replace(/\[\?25[lh]/g, '');
}
export function isNumberedOptionListVisible(visible: string): boolean {
// cursor + at least two numbered options 1-9.
// Matches the trust dialog AND plan-ready prompt AND skill questions.
// Tighter classification happens via scope (after-trust, after-skill-cmd, etc).
//
// Note on the `2\.` regex: the TTY uses cursor-positioning escape codes
// (`\x1b[40C`) for whitespace which stripAnsi removes — collapsing
// `text 2.` to `text2.`. A `\b2\.` word-boundary regex therefore fails
// because `t-2` is a word-to-word transition. We use the weaker
// `[^0-9]2\.` to require a non-digit before `2` (so we don't match
// `12.0`) without requiring whitespace.
const cleaned = stripPtyResidue(visible);
return /\s*1\./.test(cleaned) && /(^|[^0-9])2\./.test(cleaned);
}
// ────────────────────────────────────────────────────────────────────────────
// LLM judge — "is the model waiting for user input, working, or hung?"
//
// Regex detectors (isNumberedOptionListVisible, isProseAUQVisible) are fast
// and deterministic but brittle to PTY rendering quirks (cursor-positioning
// escapes that collapse multi-line option lists onto a single logical line).
// When they miss, the polling loop times out at the full budget — even
// though the model is correctly surfacing a question via a format the regex
// can't reassemble.
//
// This LLM judge takes a TTY snapshot and answers a trichotomy:
// - 'waiting' — agent surfaced a question/options, sitting at input prompt
// - 'working' — agent is still generating (spinner, tool calls, "Musing")
// - 'hung' — agent stopped without surfacing anything (rare)
//
// Used by polling loops as a fallback after N seconds with no terminal
// classification. On 'waiting' verdict, return outcome='asked' early.
//
// Cost: ~$0.0005 per call using claude haiku 4.5. Cached by snapshot hash so
// identical TTY frames don't re-charge. All verdicts logged to
// ~/.gstack/analytics/pty-judge.jsonl for offline analysis.
// ────────────────────────────────────────────────────────────────────────────
import { spawnSync as nodeSpawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
export interface PtyStateVerdict {
state: 'waiting' | 'working' | 'hung' | 'unknown';
reasoning: string;
/** SHA-1 of the normalized snapshot input (for caching/dedup). */
hash: string;
/** Wall time (ms) the judge call took. */
elapsedMs: number;
}
const PTY_VERDICT_CACHE = new Map<string, PtyStateVerdict>();
/**
* Persist a verdict (or snapshot dump) to the analytics JSONL log.
* Best-effort — failures (disk full, permission denied, etc.) are swallowed
* so the harness never fails on logging.
*/
function logPtyJudge(record: Record<string, unknown>): void {
try {
const dir = `${process.env.HOME}/.gstack/analytics`;
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(`${dir}/pty-judge.jsonl`, JSON.stringify(record) + '\n');
} catch {
/* best-effort */
}
}
/**
* Snapshot dump for postmortem debugging when GSTACK_PTY_LOG=1.
* Writes the last 4KB of visible TTY plus context to
* ~/.gstack/analytics/pty-snapshots/<testName>-<elapsed>ms.txt.
*/
export function logPtySnapshot(visible: string, ctx: { testName: string; elapsedMs: number; tag?: string }): void {
if (process.env.GSTACK_PTY_LOG !== '1') return;
try {
const dir = `${process.env.HOME}/.gstack/analytics/pty-snapshots`;
fs.mkdirSync(dir, { recursive: true });
const tag = ctx.tag ? `-${ctx.tag}` : '';
const file = `${dir}/${ctx.testName}-${ctx.elapsedMs}ms${tag}.txt`;
fs.writeFileSync(
file,
`# testName: ${ctx.testName}\n# elapsedMs: ${ctx.elapsedMs}\n# tag: ${ctx.tag ?? ''}\n# visible.length: ${visible.length}\n\n${visible.slice(-4096)}`,
);
} catch {
/* best-effort */
}
}
/**
* Ask Claude Haiku 4.5 to classify a TTY snapshot as waiting/working/hung.
*
* Implementation: spawns `claude -p --model claude-haiku-4-5` synchronously
* with the prompt piped via stdin. Uses subscription auth (no API key env
* required). 30-second timeout; returns 'unknown' on any failure mode
* (timeout, malformed JSON, missing claude binary).
*
* Cache: identical snapshot hashes return the cached verdict without
* re-calling. Cache lives in-process; resets between test runs.
*/
export function judgePtyState(
visible: string,
ctx?: { testName?: string },
): PtyStateVerdict {
// Normalize: strip trailing whitespace lines + take last 4KB. Hash the
// normalized form so spinner-frame-only diffs (which all look "working")
// don't bust the cache and rack up cost.
const tail = visible.slice(-4096).replace(/[ \t]+$/gm, '');
const hash = createHash('sha1').update(tail).digest('hex').slice(0, 16);
const cached = PTY_VERDICT_CACHE.get(hash);
if (cached) return cached;
const judgeStart = Date.now();
const prompt = `You are reading a snapshot of a terminal where Claude Code is running in plan mode for an automated test. Your job: classify the agent's current state.
Pick exactly ONE:
- WAITING — agent surfaced a question or option list and is sitting at the input prompt waiting for user reply. Signs: numbered/lettered options visible (1./2./3. or A)/B)/C)), "Recommendation:" line, cursor at empty input prompt with no recent generation activity, OR a fully-rendered question + reply-instruction (e.g. "Reply with A, B, or C" / "Recommendation:") is visible.
- WORKING — agent is actively generating or running tools. Signs: spinner glyphs (✻ ✶ ✳ ✢ ✽), "Musing..." or "Churned for ..." text, recent tool-call blocks (Read/Edit/Bash/Grep), in-flight token output.
PRECEDENCE OVERRIDE: if a lettered/numbered option list (A)/B)/1./2.) AND a "Recommendation:" or "Reply with"/"Reply A" instruction are BOTH visible in this snapshot, classify WAITING even when spinner glyphs (✻ ✶ ✳ ✢ ✽) are still animating — Claude Code keeps the spinner up at an idle prose decision, so a spinner alongside a fully-rendered question + reply-instruction is a residual render artifact, not active generation.
- HUNG — agent has stopped without surfacing a question and without any spinner/work activity. Rare; usually means a crash.
Respond with strict JSON ONLY (no markdown fences, no prose):
{"state":"waiting","reasoning":"one short sentence"}
Terminal snapshot (last 4KB):
\`\`\`
${tail}
\`\`\``;
let verdict: PtyStateVerdict = {
state: 'unknown',
reasoning: 'judge call did not complete',
hash,
elapsedMs: 0,
};
try {
// Use the same binary resolution as every PTY launch in this file —
// judgePtyState previously hardcoded bare 'claude' three definitions
// below resolveClaudeBinary(), breaking under hermetic PATHs.
const result = nodeSpawnSync(
resolveClaudeBinary() ?? 'claude',
['-p', '--model', resolveEvalModel('warmup'), '--max-turns', '1'],
{
input: prompt,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30_000,
encoding: 'utf-8',
},
);
const elapsedMs = Date.now() - judgeStart;
if (result.status === 0 && result.stdout) {
// Pull the first {...} JSON object out of stdout. Haiku occasionally
// wraps in ```json ...``` despite the prompt; tolerate that.
const match = result.stdout.match(/\{[\s\S]*?"state"[\s\S]*?\}/);
if (match) {
try {
const parsed = JSON.parse(match[0]);
const state = ['waiting', 'working', 'hung'].includes(parsed.state)
? (parsed.state as 'waiting' | 'working' | 'hung')
: 'unknown';
verdict = {
state,
reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning.slice(0, 200) : '',
hash,
elapsedMs,
};
} catch {
verdict = { state: 'unknown', reasoning: 'malformed JSON', hash, elapsedMs };
}
} else {
verdict = { state: 'unknown', reasoning: 'no JSON in response', hash, elapsedMs };
}
} else {
verdict = {
state: 'unknown',
reasoning: `claude exited ${result.status} (${(result.stderr ?? '').slice(0, 80)})`,
hash,
elapsedMs,
};
}
} catch (err) {
verdict = {
state: 'unknown',
reasoning: `judge spawn failed: ${(err as Error).message}`.slice(0, 200),
hash,
elapsedMs: Date.now() - judgeStart,
};
}
PTY_VERDICT_CACHE.set(hash, verdict);
logPtyJudge({
ts: new Date().toISOString(),
testName: ctx?.testName ?? 'unknown',
state: verdict.state,
reasoning: verdict.reasoning,
hash: verdict.hash,
judgeMs: verdict.elapsedMs,
});
return verdict;
}
/**
* Detect a prose-rendered AskUserQuestion in plan mode.
*
* Plan-mode AUQs sometimes render as visible model output rather than via
* the native numbered-prompt UI — e.g., when --disallowedTools AskUserQuestion
* is set and no MCP variant is callable, the model surfaces the question as
* lettered or numbered options in plain text. isNumberedOptionListVisible
* doesn't catch these because the `` cursor sits on the empty input prompt,
* not on option 1.
*
* Detection patterns:
* - 2+ distinct lettered options (A) B) C) D)) at line starts — typical
* for plan-eng / plan-design / plan-devex prose AUQ
* - 3+ distinct numbered options (1. 2. 3.) at line starts WITHOUT a
* `<spaces>1.` cursor — typical for autoplan / office-hours prose AUQ
* - 3+ markdown bold-bullet options (`- **label**`) following an
* interrogative line — office-hours renders its mode question this way
* (`> - **Building a startup**`), which has no letter/number marker
* - Pattern 4/5 (collapsed-form): a reply-instruction OR recommendation
* marker PLUS 2+ distinct A-D letter markers each punctuated by ) : or (
* anywhere in the tail. stripAnsi destroys the newlines + inter-word
* spaces that the line-anchored patterns above need, so a real prose AUQ
* arrives collapsed ("ReplywithA,B,orC", "A(recommended)", "-B:") and is
* invisible to Patterns 1-3. This is the dominant Shape-B render mode in
* the plan-design smoke + floor timeouts (verified against real run bytes).
*
* Used by classifyVisible and runPlanSkillFloorCheck to return outcome='asked'
* (or auq_observed) instead of letting the harness time out when the model
* is correctly surfacing the question and waiting for user input via prose.
*
* The 4KB tail window avoids matching stale options from earlier prompts in
* scrollback. Permission dialogs are filtered out by the caller (see
* isPermissionDialogVisible callers in classifyVisible).
*/
export function isProseAUQVisible(visible: string): boolean {
const tail = visible.length > 4096 ? visible.slice(-4096) : visible;
// Pattern 1: 2+ distinct lettered options at line starts. Allow leading
// whitespace or `` cursor before the marker. PTY may collapse multiple
// option lines onto one logical line via stripped cursor-positioning
// escapes, but the NEWLINE before each option survives.
const letteredRe = /(?:^|\n)[ \t]*([A-D])\)/g;
const letteredHits = new Set<string>();
let lm: RegExpExecArray | null;
while ((lm = letteredRe.exec(tail)) !== null) {
if (lm[1]) letteredHits.add(lm[1]);
}
if (letteredHits.size >= 2) return true;
// Pattern 2: 2+ distinct numbered options at line starts, AND no
// `<spaces>1.` cursor IN THE RECENT TAIL (not the full buffer — a
// trust-dialog ` 1. Yes` at boot is in scrollback forever and
// would otherwise suppress this path for the rest of the run).
// The native-UI deferral only applies when the cursor list is
// currently rendered, not historically.
//
// Threshold 2 (matching the lettered branch): the tail is a 4KB window,
// and by the time the polling loop sees it, the model may have emitted
// option 1 several KB earlier and only 2/3/4 remain in tail. False
// positives on prose ("First, x. Second, y.") are extremely rare given
// the line-start anchor + the no-cursor gate.
if (/\s*1\./.test(tail)) return false;
const numberedRe = /(?:^|\n)[ \t]*([1-9])\./g;
const numberedHits = new Set<string>();
let nm: RegExpExecArray | null;
while ((nm = numberedRe.exec(tail)) !== null) {
if (nm[1]) numberedHits.add(nm[1]);
}
if (numberedHits.size >= 2) return true;
// Pattern 3: markdown bold-bullet option list. office-hours renders its
// mode question as `> - **Building a startup**` lines under
// --disallowedTools — no letter/number marker, so Patterns 1-2 miss it,
// and the model keeps a spinner up so the Haiku judge scores it 'working'
// and the run times out despite the question being on screen.
// Require both: an interrogative line (the question stem ends in '?') AND
// 3+ bold-bullet markers. The bold (`- **`) requirement is what separates
// an option list from incidental prose bullets; the line anchor is dropped
// because stripAnsi can collapse option lines (see Pattern 1 note), so we
// count markers anywhere in the tail. The ` 1.` cursor gate above already
// excludes a live native list.
if (/\?/.test(tail)) {
const boldBulletHits = (tail.match(/[-*•]\s+\*\*/g) || []).length;
if (boldBulletHits >= 3) return true;
}
// Pattern 4/5: collapsed-form prose AUQ. stripAnsi removes the
// cursor-positioning escapes that render option newlines + inter-word
// spaces, so "Reply with A, B, or C" arrives as "ReplywithA,B,orC" and
// "A) ..." as "A(recommended)" / "-B:" — defeating every line-anchored or
// ')'-anchored pattern above (Patterns 1-3 all return false on the real
// plan-design smoke + floor timeout bytes). Detect via two INDEPENDENT
// signals that must BOTH hold — the corroboration is what separates a real
// AUQ from incidental report prose that happens to mention a recommendation:
// (1) a reply-instruction matched space-insensitively OR a recommendation
// marker, AND
// (2) 2+ distinct A-D letter markers each punctuated by ) : or ( anywhere
// in the tail.
// A single 'B)' + the word "recommendation", or a comma-only collapsed
// "ReplywithA,B,orC" with no )/:/( punctuation on the letters, both stay
// false — the two-signal contract is pinned by unit tests.
const replyOrRec =
/reply\s*(?:with)?\s*[A-D]/i.test(tail) ||
/reply(?:with)?[A-D]/i.test(tail.replace(/\s+/g, '')) ||
/\bRecommendation\s*:/i.test(tail) ||
/\(recommended\)/i.test(tail);
if (replyOrRec) {
const collapsedLetterRe = /\b([A-D])[):(]/g;
const collapsedHits = new Set<string>();
let cm: RegExpExecArray | null;
while ((cm = collapsedLetterRe.exec(tail)) !== null) {
if (cm[1]) collapsedHits.add(cm[1]);
}
if (collapsedHits.size >= 2) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Scope-gate render detectors (plan-eng-review / plan-design-review)
// ---------------------------------------------------------------------------
//
// Both anchor on the RENDER SHAPE, not bare keywords, so model narration
// about the gate ("normally I'd ask what should I review…") stays false.
// Matching is whitespace-squished + lowercased because stripAnsi collapses
// TTY cursor-positioning escapes unpredictably (the same failure mode the
// Pattern-4/5 collapsed-form handling above exists for).
/**
* True when the scope-gate QUESTION is actually rendered: the question text
* plus option A's body text. Option-body anchoring (not `A)`/`B)` markers)
* because native AskUserQuestion renders NUMBERED options in the TTY while
* the --disallowedTools prose fallback renders lettered ones — the option
* body appears in both renders; narration rarely quotes both the question
* and an option body.
*/
export function isScopeGateQuestionVisible(visible: string): boolean {
const squished = visible.replace(/\s+/g, '').toLowerCase();
return squished.includes('whatshouldireview') && squished.includes('currentbranchdiff');
}
/**
* True when the plan-mode auto-select announcement is rendered:
* "Scope gate: plan mode — auto-selected B (reviewing <target>)."
* Requires BOTH the announcement prefix and an auto-select-B token so
* narration ("in plan mode I'd auto-select B") stays false. The token is
* tense-tolerant (selected/selecting/selects) because the smokes assert
* must-be-TRUE on it — a semantically-perfect paraphrase must not fail a
* paid run — while the prefix stays exact so paraphrase narration without
* the announcement frame stays false. A prefix immediately preceded by a
* quote character is a QUOTATION (e.g. the model explaining why it is NOT
* announcing), not a render — the announcement line itself never renders
* quoted.
*/
export function isScopeGateAutoSelectVisible(visible: string): boolean {
const squished = visible.replace(/\s+/g, '').toLowerCase();
const QUOTES = ['"', "'", '`', '“', ''];
const re = /scopegate:planmode/g;
let m: RegExpExecArray | null;
while ((m = re.exec(squished)) !== null) {
const before = m.index > 0 ? squished[m.index - 1]! : '';
if (QUOTES.includes(before)) continue; // quoted occurrence — narration, keep scanning
if (/auto-?select(?:ed|ing|s)?b/.test(squished.slice(m.index))) return true;
}
return false;
}
/**
* Parse a rendered numbered-option list out of the visible TTY text.
*
* Looks for lines like ` 1. label` (cursor) or ` 2. label` (no cursor)
* and returns them in order. Used by tests that need to ROUTE on a specific
* option label (e.g. answer "HOLD SCOPE" by sending its index + Enter)
* without hard-coding positional indexes that drift when option order
* changes between skill versions.
*
* Reads only the LAST 4KB of visible to avoid matching stale option lists
* from earlier prompts in the session.
*
* Returns [] when no list is rendered. Otherwise returns indices in the
* order they appear (1-based, matching what the user types). Labels are
* trimmed but otherwise verbatim from the TTY (may include trailing
* `(recommended)` markers, etc).
*/
export function parseNumberedOptions(
visible: string,
): Array<{ index: number; label: string }> {
visible = stripPtyResidue(visible);
const tail = visible.length > 4096 ? visible.slice(-4096) : visible;
// Split on lines, look for ` N.` or ` N.` patterns. Up to N=9.
// The `\s*` after `.` (not `\s+`) is required because stripAnsi removes
// TTY cursor-positioning escapes that render as spaces, so a label that
// visually reads "1. Option" can come through as "1.Option".
const optionRe = /^[\s]*([1-9])\.\s*(\S.*?)\s*$/;
// We anchor on the LATEST ` 1.` line in the buffer — the cursor marker
// for the active AskUserQuestion. Older numbered lists (e.g., a granted permission
// dialog still in scrollback) sit above it and must be ignored. Without
// this, parseNumberedOptions returns stale options after the dialog is
// dismissed.
const lines = tail.split('\n');
// Anchor on the LAST line containing `<spaces>1.` ANYWHERE on the line.
// The /plan-*-review skill's box-layout AUQ uses TTY cursor-positioning
// escapes that stripAnsi removes — leaving the cursor `1.` mid-line,
// after dividers + header + prompt text on the same logical line. The
// earlier `^\s*` anchor missed those entirely.
let cursorLineIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/\s*1\./.test(lines[i] ?? '')) {
cursorLineIdx = i;
break;
}
}
// Fallback: if cursor isn't on option 1 (user pressed Down), find the
// last `1.` line. Allow leading ` ` or ` ` prefixes; do NOT include ``
// in the leading character class because greedy matching would eat the
// sigil and prevent the literal-cursor anchor above from finding it.
if (cursorLineIdx < 0) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/^(?:\s*|\s*\s+)1\./.test(lines[i] ?? '')) {
cursorLineIdx = i;
break;
}
}
}
if (cursorLineIdx < 0) return [];
const found: Array<{ index: number; label: string }> = [];
const seenIndices = new Set<number>();
// Cursor line: option 1 may be inline after box dividers + prompt header
// (`...divider...header...1. label`) — and, when the PTY reflows the whole
// AUQ onto ONE logical line, options 2..N sit on the SAME line after it
// (observed with /plan-design-review's Step-0 scope gate: `1.Branch diff
// ... 2.Plan or design doc ... 5.Chat about this ... Enter to select`).
// Parse the cursor line as a STREAM: find every `N.` token (not preceded
// by a digit, not followed by one — excludes "12." and "1.5"), require
// ascending indices starting from the cursor's option, and take each
// label as the text between successive number tokens.
const cursorLine = lines[cursorLineIdx] ?? '';
const cursorStart = cursorLine.indexOf('');
const cursorSegment = cursorStart >= 0 ? cursorLine.slice(cursorStart) : cursorLine;
const tokenRe = /(?:^|[^0-9])([1-9])\.(?!\d)\s*/g;
const tokens: Array<{ idx: number; labelStart: number; matchStart: number }> = [];
for (let m = tokenRe.exec(cursorSegment); m !== null; m = tokenRe.exec(cursorSegment)) {
tokens.push({
idx: Number(m[1]),
labelStart: m.index + m[0].length,
matchStart: m.index === 0 ? 0 : m.index + 1, // skip the [^0-9] guard char
});
}
// Keep only the ascending run that starts the sequence (1, 2, 3, ...);
// stray numbers inside labels break ascension and end the run.
let expected = 1;
for (let t = 0; t < tokens.length; t++) {
const token = tokens[t]!;
if (token.idx !== expected) continue;
const next = tokens
.slice(t + 1)
.find((candidate) => candidate.idx === expected + 1 && candidate.matchStart > token.labelStart);
const labelEnd = next ? next.matchStart : cursorSegment.length;
const label = cursorSegment.slice(token.labelStart, labelEnd).trim();
if (label.length > 0 && !seenIndices.has(token.idx)) {
seenIndices.add(token.idx);
found.push({ index: token.idx, label });
expected += 1;
}
}
// Subsequent lines: standard start-of-line option parsing.
for (let i = cursorLineIdx + 1; i < lines.length; i++) {
const m = optionRe.exec(lines[i] ?? '');
if (!m) continue;
const idx = Number(m[1]);
const label = (m[2] ?? '').trim();
if (seenIndices.has(idx)) continue;
if (label.length === 0) continue;
seenIndices.add(idx);
found.push({ index: idx, label });
}
// Only return if we found a sequential 1.., 2.., ... block (at least 2
// consecutive options starting at 1). Otherwise it's noise (e.g. a
// numbered list inside prose, like "1. Read the file").
found.sort((a, b) => a.index - b.index);
if (found.length < 2) return [];
if (found[0]!.index !== 1) return [];
for (let i = 1; i < found.length; i++) {
if (found[i]!.index !== found[i - 1]!.index + 1) {
// Truncate at the first gap.
return found.slice(0, i);
}
}
return found;
}
/**
* The four /plan-ceo-review modes. Used by `skill-e2e-plan-ceo-mode-routing`
* to detect Step 0F mode-selection AskUserQuestions, and by the upcoming
* finding-count tests as a Step-0 boundary signal: an AUQ whose options
* match this regex IS the mode pick (the last Step-0 question for plan-ceo).
*
* Lifted out of the mode-routing test so multiple PTY tests can share one
* source of truth — when /plan-ceo-review adds a fifth mode, one regex updates
* everywhere instead of drifting per-test.
*/
export const MODE_RE = /HOLD SCOPE|SCOPE EXPANSION|SELECTIVE EXPANSION|SCOPE REDUCTION/i;
/**
* Stable signature for a parsed numbered-option list — used by tests to detect
* "is this AUQ the same as the last poll, or has the agent advanced to a new
* one?" Joins each option as `${index}:${label}` after sorting by index.
*
* Defensive sort means the signature is order-independent at the input level,
* even though `parseNumberedOptions` already returns indices in ascending order.
*/
export function optionsSignature(
opts: Array<{ index: number; label: string }>,
): string {
return [...opts]
.sort((a, b) => a.index - b.index)
.map((o) => `${o.index}:${o.label}`)
.join('|');
}
/**
* Pure classifier for the visible TTY buffer. Decides which outcome the
* polling loop should return on this tick, or `null` to keep polling.
*
* Extracted from `runPlanSkillObservation` so the unit suite can exercise
* the actual branch order with synthetic input strings — a future contributor
* who reorders the branches (e.g., moves the permission short-circuit) gets
* caught by the unit tests, not by a stochastic E2E run.
*
* Live-state branches (process exited, "Unknown command") stay in the runner
* since they need the session handle.
*/
export type ClassifyResult =
| { outcome: 'silent_write'; summary: string }
| { outcome: 'wrote_findings_before_asking'; summary: string }
| { outcome: 'auto_decided'; summary: string }
| { outcome: 'plan_ready'; summary: string }
| { outcome: 'asked'; summary: string }
| null;
const SANCTIONED_WRITE_SUBSTRINGS = [
'.claude/plans',
'.gstack/',
'/.context/',
'CHANGELOG.md',
'TODOS.md',
];
/**
* Find the position of the first AskUserQuestion-style numbered-option list
* that is NOT a permission dialog. Returns -1 if none has rendered yet.
*
* Used by the strict-plan-writes detector (D4) to distinguish legitimate
* post-AUQ plan writes from the transcript bug ("write findings to plan
* before asking").
*/
function findFirstAuqRenderIndex(visible: string): number {
const re = /\s*1\./g;
let m: RegExpExecArray | null;
while ((m = re.exec(visible)) !== null) {
// 200 bytes back + TAIL_SCAN_BYTES forward gives enough context for
// isPermissionDialogVisible to recognize the typical permission UI.
const surroundStart = Math.max(0, m.index - 200);
const surroundEnd = Math.min(visible.length, m.index + TAIL_SCAN_BYTES);
const surround = visible.slice(surroundStart, surroundEnd);
if (!isPermissionDialogVisible(surround)) {
return m.index;
}
}
return -1;
}
export function classifyVisible(
visible: string,
opts?: {
/**
* When true, treat Write/Edit to `.claude/plans/*` BEFORE any
* AskUserQuestion render as `wrote_findings_before_asking` rather than
* letting the sanctioned-write list silently approve it. Used by tests
* that seed a draft plan with guaranteed-finding-triggering complexity
* (D3-B), where a pre-AUQ plan write is the precise transcript bug.
* Default false — preserves existing behavior for unseeded smoke tests
* where zero-findings → write plan → plan_ready is legitimate.
*/
strictPlanWrites?: boolean;
},
): ClassifyResult {
// Silent-write detection: any Write/Edit tool render that targets a path
// OUTSIDE the sanctioned dirs, AND no numbered prompt is currently on screen
// (a numbered prompt means a permission/AskUserQuestion is gating the write,
// not an actual silent write).
const writeRe = /⏺\s*(?:Write|Edit)\(([^)]+)\)/g;
let m: RegExpExecArray | null;
const auqRenderIdx = opts?.strictPlanWrites ? findFirstAuqRenderIndex(visible) : -1;
while ((m = writeRe.exec(visible)) !== null) {
const target = m[1] ?? '';
const writePos = m.index;
const isPlanWrite = target.includes('.claude/plans');
const sanctioned = SANCTIONED_WRITE_SUBSTRINGS.some((s) => target.includes(s));
// D4-B: when strictPlanWrites is on, plan writes that precede the first
// AUQ render are flagged. Legitimate end-of-workflow plan writes happen
// AFTER an AUQ has rendered (i.e., the user has been asked). The
// transcript bug is a plan write WITHOUT any AUQ render preceding it.
if (opts?.strictPlanWrites && isPlanWrite) {
if (auqRenderIdx < 0 || writePos < auqRenderIdx) {
return {
outcome: 'wrote_findings_before_asking',
summary: `Write/Edit to ${target} fired before any AskUserQuestion render`,
};
}
// post-AUQ plan write — legitimate, fall through to other writes
continue;
}
if (!sanctioned && !isNumberedOptionListVisible(visible)) {
return {
outcome: 'silent_write',
summary: `Write/Edit to ${target} fired before any AskUserQuestion`,
};
}
}
// 'auto_decided' must beat 'plan_ready': when AUTO_DECIDE fires upstream of
// plan-ready, both signals are visible by the time the polling loop checks.
// The annotation text is the more informative outcome — it explains WHY
// we got to plan_ready without surfacing the question.
if (isAutoDecidedVisible(visible)) {
return {
outcome: 'auto_decided',
summary:
'skill auto-decided an AskUserQuestion via the AUTO_DECIDE preamble (the user never saw the prompt)',
};
}
if (isPlanReadyVisible(visible)) {
return {
outcome: 'plan_ready',
summary: 'skill ran end-to-end and emitted plan-mode "Ready to execute" confirmation',
};
}
if (isNumberedOptionListVisible(visible)) {
// Permission dialogs render numbered lists too. Skip them — the
// bug we want to catch is "skill question never fired."
if (isPermissionDialogVisible(visible.slice(-TAIL_SCAN_BYTES))) {
return null;
}
return {
outcome: 'asked',
summary: 'skill fired a numbered-option prompt (AskUserQuestion or routing-injection)',
};
}
// Prose-rendered AUQ: model surfaced the question as lettered or numbered
// options in plain text (typical under --disallowedTools AskUserQuestion
// when no MCP variant is callable). The model is waiting for user input
// via the plan-mode input prompt rather than via the AUQ tool UI; this
// is still a legitimate "asked" surface — semantically equivalent to a
// tool-call AUQ from the test's perspective.
if (isProseAUQVisible(visible)) {
if (isPermissionDialogVisible(visible.slice(-TAIL_SCAN_BYTES))) {
return null;
}
return {
outcome: 'asked',
summary: 'skill rendered a prose-style AskUserQuestion (model waiting for user input)',
};
}
return null;
}
// ────────────────────────────────────────────────────────────────────────────
// Per-finding AskUserQuestion count primitives (used by runPlanSkillCounting).
//
// These are pure helpers extracted up-front so the unit suite can exercise
// them deterministically before the live-PTY counter runs them. Each one is
// independently unit-testable against synthetic visible-buffer strings.
// ────────────────────────────────────────────────────────────────────────────
/**
* Captured identity of an AskUserQuestion — the rendered question text plus
* its numbered options. Used by `runPlanSkillCounting` to dedupe redrawn
* prompts and to feed `Step0BoundaryPredicate` callers.
*
* `signature` is the stable hash. Two AUQs with identical prompt + options
* produce the same signature; differences in either field produce different
* signatures. Critically: two AUQs with shared option labels (e.g. the
* generic "A) Add to plan / B) Defer / C) Build now" menu) but different
* question text get DIFFERENT signatures because the prompt is in the hash.
*/
export interface AskUserQuestionFingerprint {
/** Stable hash combining normalized prompt text + options signature. */
signature: string;
/** First 240 chars of the rendered question prompt (post-normalization). */
promptSnippet: string;
/** Captured option labels, in index order. */
options: Array<{ index: number; label: string }>;
/** Wall-clock when first observed (ms since the helper started polling). */
observedAtMs: number;
/** True if observed BEFORE the Step-0 boundary fired. */
preReview: boolean;
}
/**
* Predicate fired against the AUQ we just answered (not the visible buffer).
* Returns true if this AUQ's fingerprint marks the LAST Step-0 question for
* its skill — all subsequent AUQs are review-phase findings.
*
* Event-based by design: matching against an answered AUQ's fingerprint
* (prompt + options) is deterministic, whereas matching against later
* rendered content (section headers, summary text) races with the agent's
* output cadence. See plan §D14 for the rationale.
*/
export type Step0BoundaryPredicate = (
answeredFingerprint: AskUserQuestionFingerprint,
) => boolean;
/**
* Parse the rendered question prompt out of a visible TTY buffer. The prompt
* is the 13 lines of text immediately ABOVE the latest ` 1.` cursor line —
* not part of the option list, not the permission-dialog header.
*
* Returns the prompt normalized to a single-spaced 240-char snippet (strip
* ANSI residue, collapse internal whitespace, trim) — short enough to use as
* a hash key, long enough to disambiguate distinct questions.
*
* Returns "" when no prompt could be parsed (cursor not yet rendered, or
* cursor is at the top of the buffer with no preceding text). Callers that
* use the empty string as a fingerprint input should treat empty-prompt
* AUQs as "wait one more poll" rather than fingerprinting them — otherwise
* the same options + empty prompt across two distinct questions collide.
*/
export function parseQuestionPrompt(visible: string): string {
// Tail-only — older prompts higher in the buffer are stale.
const tail = visible.length > 4096 ? visible.slice(-4096) : visible;
const lines = tail.split('\n');
// Find the latest line containing `<spaces>1.` (matching parseNumberedOptions —
// unanchored to handle the box-layout case where cursor is mid-line after
// divider + header + prompt text on the same logical line).
let cursorLineIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/\s*1\./.test(lines[i] ?? '')) {
cursorLineIdx = i;
break;
}
}
if (cursorLineIdx < 0) return '';
// Box-layout case: prompt text may be ON the cursor line, BEFORE `1.`.
// Extract that prefix (after stripping leading box-drawing characters and
// dividers) as the last piece of the prompt — appended after any prior
// multi-line prompt text we walk up to find.
const cursorLine = lines[cursorLineIdx] ?? '';
let inlinePrompt = '';
const cursorPos = cursorLine.search(/\s*1\./);
if (cursorPos > 0) {
inlinePrompt = cursorLine
.slice(0, cursorPos)
// Strip box-drawing chars + dividers + leading checkbox sigil.
.replace(/^[─━┄┅┈┉─┌┐└┘├┤┬┴┼│┃☐□■\s]+/, '')
.trim();
}
// Walk up at most 6 lines collecting prompt text. Stop at:
// - a blank line preceded by another blank line (paragraph break)
// - top of buffer
// - a line that itself starts with `N.` (we're inside an option list)
const promptLines: string[] = [];
let blankRun = 0;
for (let i = cursorLineIdx - 1; i >= 0 && promptLines.length < 6; i--) {
const raw = lines[i] ?? '';
const trimmed = raw.trim();
if (trimmed === '') {
blankRun += 1;
if (blankRun >= 2 && promptLines.length > 0) break;
continue;
}
blankRun = 0;
// Stop if we hit what looks like a previous numbered list.
if (/^[\s]*[1-9]\.\s+\S/.test(raw)) break;
promptLines.unshift(trimmed);
}
const all = inlinePrompt.length > 0 ? [...promptLines, inlinePrompt] : promptLines;
const joined = all.join(' ').replace(/\s+/g, ' ').trim();
return joined.slice(0, 240);
}
/**
* Stable hash for an AskUserQuestion's identity — combines normalized prompt
* text with the options signature so two distinct questions with shared menu
* labels (the generic A/B/C TODO-proposal menu, for instance) get different
* fingerprints.
*
* Uses Bun's fast non-crypto hash since these strings are short and we only
* need collision resistance against accidental TTY redraws, not adversaries.
* Hex-encoded for diagnostic dumps.
*/
export function auqFingerprint(
promptSnippet: string,
opts: Array<{ index: number; label: string }>,
): string {
const normalized = promptSnippet.replace(/\s+/g, ' ').trim();
const sig = optionsSignature(opts);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (Bun as any).hash(normalized + '||' + sig).toString(16);
}
/**
* Detects when a plan-* skill has reached its Completion Summary / Review
* Report — a terminal signal complementary to plan-mode's "Ready to execute"
* confirmation. Each plan-review skill writes one of these phrasings near
* the end of its run; matching any one is enough to stop counting.
*
* Best-effort: this is a content marker, not a deterministic event. Hard
* ceiling (`reviewCountCeiling` in `runPlanSkillCounting`) is the reliable
* stop signal; this regex is the "we're done, go gracefully" hint.
*/
export const COMPLETION_SUMMARY_RE =
/(GSTACK REVIEW REPORT|## Completion [Ss]ummary|Status:\s*(clean|issues_open)|^VERDICT:)/m;
/**
* Result of asserting that a plan file ends with `## GSTACK REVIEW REPORT`
* as its last `## ` heading. `ok` is true iff the report is present AND no
* other `## ` heading appears after it. Diagnostic fields are populated only
* on failure to keep the success path cheap.
*/
export interface ReviewReportAtBottomResult {
ok: boolean;
reason?: string;
trailingHeadings?: string[];
}
/**
* Assert that `## GSTACK REVIEW REPORT` is the last `## ` heading in a plan
* file's content. Pure string operation — no filesystem access. Used by the
* finding-count E2E tests as a second assertion on each test's produced plan.
*
* The plan-mode skill template mandates the agent move/append the review
* report so it's always the last `##` section. A regression where the agent
* appends additional sections after the report (or skips it entirely) ships
* silently today; this assertion catches both.
*/
export function assertReviewReportAtBottom(
content: string,
): ReviewReportAtBottomResult {
const re = /^## GSTACK REVIEW REPORT\s*$/m;
const match = re.exec(content);
if (!match) {
return { ok: false, reason: 'no GSTACK REVIEW REPORT section' };
}
const after = content.slice(match.index + match[0].length);
// Match any `## ` heading after the report. Reject `## ` followed by
// newline-only (trailing-whitespace ## headers) to avoid false positives.
const trailingHeadings = Array.from(
after.matchAll(/^## \S.*$/gm),
).map((m) => m[0]);
if (trailingHeadings.length > 0) {
return {
ok: false,
reason: 'trailing ## heading(s) after GSTACK REVIEW REPORT',
trailingHeadings,
};
}
return { ok: true };
}
/**
* Test helper: if `obs.planFile` was set, read it and assert
* `## GSTACK REVIEW REPORT` is the last `## ` section. Throws on
* violation with a diagnostic message including the plan path,
* the reason, any trailing headings, and the last 2KB of TTY output.
*
* Used by the four plan-mode E2E tests
* (skill-e2e-plan-{eng,ceo,design,devex}-plan-mode.test.ts) to enforce
* the {{PLAN_FILE_REVIEW_REPORT}} resolver contract uniformly. Gates on
* `obs.planFile` (artifact existing), not on `obs.outcome === 'plan_ready'`,
* so it also catches the report-missing case under `'asked'` /
* `'wrote_findings_before_asking'` when a plan was already written.
*/
export function assertReportAtBottomIfPlanWritten(
obs: { planFile?: string; evidence: string; outcome?: string },
): void {
if (!obs.planFile) return;
// Skip when the plan file path was detected from TTY output but no file
// exists on disk. This happens when the model mentions a path mid-stream
// (e.g., as a tool-call argument that was interrupted, or in a draft that
// was never persisted). The report-at-bottom contract is for fully-written
// plan files; ENOENT means there's no file content to enforce against.
if (!fs.existsSync(obs.planFile)) return;
// Skip on 'asked' outcomes — these are smoke tests that exited at the
// first AUQ render (Step 0 only). The model never reached the workflow's
// report-writing step, so a partial plan file without the report section
// is the expected mid-flight state, not a contract violation. The
// report-at-bottom check applies to outcomes that imply the workflow
// ran end-to-end (plan_ready, completion_summary, etc.).
if (obs.outcome === 'asked') return;
const content = fs.readFileSync(obs.planFile, 'utf-8');
const verdict = assertReviewReportAtBottom(content);
if (!verdict.ok) {
const trailing = verdict.trailingHeadings?.length
? `\ntrailing headings: ${verdict.trailingHeadings.join(', ')}`
: '';
throw new Error(
`GSTACK REVIEW REPORT contract violation in ${obs.planFile}: ${verdict.reason}${trailing}\n` +
`--- evidence (last 2KB) ---\n${obs.evidence}`,
);
}
}
/**
* Per-skill Step-0 boundary predicates. Each fires `true` when the answered
* AUQ's fingerprint matches the LAST question of that skill's Step 0 phase.
*
* - `ceoStep0Boundary`: matches the mode-pick AUQ (options match `MODE_RE`).
* - `engStep0Boundary`: matches the cross-project-learnings or scope-reduction
* AUQ that closes plan-eng-review's preamble.
* - `designStep0Boundary`: matches plan-design-review's first dimension /
* posture AUQ.
* - `devexStep0Boundary`: matches plan-devex-review's persona-selection AUQ.
*
* Predicates live alongside the helper so the unit suite can exercise each
* against synthetic fingerprints (positive AND negative cases). Skill test
* files import them directly.
*/
export const ceoStep0Boundary: Step0BoundaryPredicate = (fp) =>
// Mode-pick path (Step 0F): one of HOLD SCOPE / SCOPE EXPANSION / etc.
fp.options.some((o) => MODE_RE.test(o.label)) ||
// Skip-interview path: scope-selection AUQ has "Skip interview and plan
// immediately" — picking it bypasses the rest of Step 0 and routes
// directly to review-phase. Boundary fires on the scope AUQ itself.
fp.options.some((o) => /skip\s+interview|plan\s+immediately/i.test(o.label));
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(
fp.promptSnippet,
);
export const devexStep0Boundary: Step0BoundaryPredicate = (fp) =>
/developer persona|target persona|persona selection|TTHW target/i.test(
fp.promptSnippet,
);
/**
* Spawn `claude --permission-mode plan` in a real PTY and return a session
* handle. Caller is responsible for `await session.close()` to release the
* subprocess and any timers.
*
* Auto-handles the workspace-trust dialog (presses "1\r" if it appears
* during the boot window). Tests should NOT have to handle it themselves.
*/
export async function launchClaudePty(
opts: ClaudePtyOptions = {},
): Promise<ClaudePtySession> {
const claudePath = resolveClaudeBinary();
if (!claudePath) {
throw new Error(
'claude binary not found on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
);
}
const cwd = opts.cwd ?? process.cwd();
const cols = opts.cols ?? 120;
const rows = opts.rows ?? 40;
const timeoutMs = opts.timeoutMs ?? 240_000;
let buffer = '';
let exited = false;
let exitCodeCaptured: number | null = null;
const args: string[] = [];
// Pin the model so smokes don't inherit the operator's settings.json model
// (see ClaudePtyOptions.model). Chain mirrors session-runner.ts:144 so PTY and
// `claude -p` evals always agree. Pushed before extraArgs => a test-supplied
// --model wins (last flag wins).
const model = opts.model ?? process.env.EVALS_MODEL ?? 'claude-sonnet-4-6';
args.push('--model', model);
// Permission mode: 'plan' default, null => omit flag entirely.
const permissionMode = opts.permissionMode === undefined ? 'plan' : opts.permissionMode;
if (permissionMode !== null) {
args.push('--permission-mode', permissionMode);
}
// Hermetic children get zero MCP servers; gated on the same call-time
// check as the env scrub so EVALS_HERMETIC=0 restores operator MCP too.
// Before opts.extraArgs so a test could theoretically supply --mcp-config.
const hermetic = isHermeticEnabled();
if (hermetic) args.push('--strict-mcp-config');
if (opts.extraArgs) args.push(...opts.extraArgs);
// Hermetic by default (test/helpers/hermetic-env.ts): operator session
// context never reaches the child; per-test opts.env merges last.
const childEnv = hermeticChildEnv(opts.env);
if (opts.seedSkills && hermetic && !opts.env?.CLAUDE_CONFIG_DIR) {
childEnv.CLAUDE_CONFIG_DIR = hermeticSkillsConfigDir();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proc = (Bun as any).spawn([claudePath, ...args], {
terminal: {
cols,
rows,
data(_t: unknown, chunk: Buffer) {
buffer += chunk.toString('utf-8');
},
},
cwd,
env: childEnv,
});
// Track exit so waitForAny can fail fast if claude crashes.
let exitedPromise: Promise<void> = Promise.resolve();
if (proc.exited && typeof proc.exited.then === 'function') {
exitedPromise = proc.exited
.then((code: number | null) => {
exitCodeCaptured = code;
exited = true;
})
.catch(() => {
exited = true;
});
}
// Top-level timeout. If a test forgets to close, this kills it eventually.
const wallTimer = setTimeout(() => {
try {
proc.kill?.('SIGKILL');
} catch {
/* ignore */
}
}, timeoutMs);
// Auto-handle the workspace-trust dialog. Runs once during the boot
// window; idempotent (only fires if the phrase is still on screen).
let trustHandled = false;
const trustWatcher = setInterval(() => {
if (trustHandled || exited) return;
const visible = stripAnsi(buffer);
if (isTrustDialogVisible(visible)) {
trustHandled = true;
try {
proc.terminal?.write?.('1\r');
} catch {
/* ignore */
}
}
}, 200);
// Stop the watcher after 15s — by then the dialog has either fired or
// doesn't exist on this run.
const trustWatcherStop = setTimeout(() => clearInterval(trustWatcher), 15_000);
function send(data: string): void {
if (exited) return;
try {
proc.terminal?.write?.(data);
} catch {
/* ignore */
}
}
type Key = Parameters<ClaudePtySession['sendKey']>[0];
function sendKey(key: Key): void {
const map: Record<string, string> = {
Enter: '\r',
Up: '\x1b[A',
Down: '\x1b[B',
Esc: '\x1b',
Tab: '\t',
ShiftTab: '\x1b[Z',
CtrlC: '\x03',
};
send(map[key] ?? '');
}
let lastMark = 0;
function mark(): number {
lastMark = buffer.length;
return lastMark;
}
function visibleSince(marker?: number): string {
const offset = marker ?? lastMark;
return stripAnsi(buffer.slice(offset));
}
async function waitForAny(
patterns: Array<RegExp | string>,
waitOpts?: { timeoutMs?: number; pollMs?: number; since?: number },
): Promise<{ matched: RegExp | string; index: number }> {
const wTimeout = waitOpts?.timeoutMs ?? 60_000;
const poll = waitOpts?.pollMs ?? 250;
const since = waitOpts?.since;
const start = Date.now();
while (Date.now() - start < wTimeout) {
if (exited) {
throw new Error(
`claude exited (code=${exitCodeCaptured}) before any pattern matched. ` +
`Last visible:\n${stripAnsi(buffer).slice(-2000)}`,
);
}
const visible = since !== undefined ? stripAnsi(buffer.slice(since)) : stripAnsi(buffer);
for (let i = 0; i < patterns.length; i++) {
const p = patterns[i]!;
const matchIdx = typeof p === 'string' ? visible.indexOf(p) : visible.search(p);
if (matchIdx >= 0) {
return { matched: p, index: matchIdx };
}
}
await Bun.sleep(poll);
}
throw new Error(
`Timed out after ${wTimeout}ms waiting for any of: ${patterns
.map((p) => (typeof p === 'string' ? JSON.stringify(p) : p.source))
.join(', ')}\nLast visible (since=${since ?? 'all'}):\n${
since !== undefined ? stripAnsi(buffer.slice(since)).slice(-2000) : stripAnsi(buffer).slice(-2000)
}`,
);
}
async function waitFor(
pattern: RegExp | string,
waitOpts?: { timeoutMs?: number; pollMs?: number; since?: number },
): Promise<void> {
await waitForAny([pattern], waitOpts);
}
async function close(): Promise<void> {
clearTimeout(wallTimer);
clearTimeout(trustWatcherStop);
clearInterval(trustWatcher);
if (exited) return;
try {
proc.kill?.('SIGINT');
} catch {
/* ignore */
}
// Wait up to 2s for graceful exit.
await Promise.race([exitedPromise, Bun.sleep(2000)]);
if (!exited) {
try {
proc.kill?.('SIGKILL');
} catch {
/* ignore */
}
await Promise.race([exitedPromise, Bun.sleep(1000)]);
}
}
return {
send,
sendKey,
rawOutput: () => buffer,
visibleText: () => stripAnsi(buffer),
mark,
visibleSince,
waitForAny,
waitFor,
pid: () => proc.pid as number | undefined,
exited: () => exited,
exitCode: () => exitCodeCaptured,
hermeticConfigDir: hermetic ? childEnv.CLAUDE_CONFIG_DIR ?? null : null,
close,
};
}
/**
* High-level: invoke a slash command and observe the response. Used by the
* 5 plan-mode tests so each only has ~10 LOC of orchestration.
*
* The `expectations` object names the patterns the caller cares about.
* Returns which one matched first (or throws on timeout).
*
* @example
* const session = await launchClaudePty();
* const result = await invokeAndObserve(session, '/plan-ceo-review', {
* askUserQuestion: /\s*1\./,
* planReady: /ready to execute/i,
* silentWrite: /⏺\s*Write\(/,
* silentEdit: /⏺\s*Edit\(/,
* exitedPlanMode: /Exiting plan mode/i,
* });
* await session.close();
*/
export async function invokeAndObserve(
session: ClaudePtySession,
slashCommand: string,
expectations: Record<string, RegExp | string>,
opts?: { boot_grace_ms?: number; timeoutMs?: number },
): Promise<{ matched: string; rawPattern: RegExp | string; visibleAtMatch: string }> {
// Brief grace period so the trust-dialog auto-press has time to clear and
// claude is back at the input prompt before we type the command.
const boot = opts?.boot_grace_ms ?? 6000;
await Bun.sleep(boot);
// Mark buffer position. All pattern matching scopes to text AFTER this point,
// so the trust-dialog residue and boot banner numbered options don't cause
// false positives.
const sinceMark = session.mark();
// Type and submit.
session.send(slashCommand + '\r');
const patterns = Object.entries(expectations);
const result = await session.waitForAny(
patterns.map(([, p]) => p),
{ timeoutMs: opts?.timeoutMs ?? 240_000, since: sinceMark },
);
// Map back to the named key.
const idx = patterns.findIndex(([, p]) => p === result.matched);
const [name, rawPattern] = patterns[idx]!;
return {
matched: name,
rawPattern,
visibleAtMatch: session.visibleText(),
};
}
// ---------------------------------------------------------------------------
// High-level skill-mode test contract
// ---------------------------------------------------------------------------
export interface PlanSkillObservation {
/**
* What happened first. One of:
* - 'asked' — skill emitted a numbered-option prompt (its Step 0
* AskUserQuestion or the routing-injection prompt)
* - 'auto_decided' — visible TTY shows "Auto-decided ... → ..." (the
* AUTO_DECIDE preamble template fired). Distinguishes
* "the regression we're tracking" (auto-mode silently
* auto-deciding questions the user wanted to see) from
* "skill legitimately reached plan_ready". Detected
* before plan_ready/silent_write so the auto-decide
* evidence wins when both are present.
* - 'plan_ready' — claude wrote a plan and emitted its native
* "Ready to execute" confirmation
* - 'silent_write' — a Write/Edit landed BEFORE any prompt, to a path
* outside the sanctioned plan/project directories
* - 'wrote_findings_before_asking' — strictPlanWrites only (seeded runs):
* the plan file was rewritten with findings before any
* AskUserQuestion render (the May-2026 transcript bug)
* - 'exited' — claude process died before any of the above
* - 'timeout' — none of the above within budget
*/
outcome:
| 'asked'
| 'auto_decided'
| 'plan_ready'
| 'silent_write'
| 'wrote_findings_before_asking'
| 'exited'
| 'timeout';
/** Human-readable summary. */
summary: string;
/** Visible terminal text since the slash command was sent (last 2KB). */
evidence: string;
/** Wall time (ms) until the outcome was decided. */
elapsedMs: number;
/**
* Path to the plan file the skill wrote (if outcome is 'plan_ready').
* Extracted from the visible TTY via {@link extractPlanFilePath}. Lets the
* v1.22 AskUserQuestion-blocked regression tests verify the plan file
* contains a `## Decisions to confirm` section under --disallowedTools —
* a model that silently skips Step 0 reaches plan_ready WITHOUT writing
* the section, and that's the regression we want to catch.
*/
planFile?: string;
/**
* High-water-mark flag: did the polling loop ever observe a
* prose-rendered AskUserQuestion (lettered or numbered options visible)
* during the run? Set true the first poll iteration that
* isProseAUQVisible returns true on the recent buffer; remains true
* for the rest of the observation.
*
* The 2KB `evidence` window often misses the prose-AUQ moment because
* by the time outcome=plan_ready fires, the ExitPlanMode "Ready to
* execute" UI has pushed the options out of the tail. Tests that need
* to assert "the user saw the question at SOME point" should check
* this flag rather than re-running isProseAUQVisible on the truncated
* evidence.
*/
proseAUQEverObserved?: boolean;
/**
* High-water-mark flag: did the LLM judge ever return state='waiting'
* during the run? Same shape as proseAUQEverObserved but driven by the
* Haiku judge fallback rather than the regex detector.
*/
waitingEverObserved?: boolean;
/**
* High-water-mark flag: did the scope-gate QUESTION ("What should I
* review?" plus option-body text) ever render during the run? Same
* lossy-2KB-evidence rationale as proseAUQEverObserved. The plan-mode
* smokes assert this stays false (gate bypassed via auto-select B); the
* no-op regression asserts it fires outside plan mode.
*/
scopeGateQuestionObserved?: boolean;
/**
* High-water-mark flag: did the plan-mode auto-select announcement
* ("Scope gate: plan mode — auto-selected B …") ever render? The
* plan-mode smokes assert true; the no-op regression asserts false.
*/
scopeGateAutoSelectObserved?: boolean;
/**
* High-water map for opts.trackTokens: token → did it EVER appear in the
* cumulative visible buffer? Consumption asserts (e.g. "the pasted target's
* distinctive token shows up in the review output") must not depend on the
* lossy 2KB evidence tail — plan-file fallbacks are unreachable outside
* plan mode (extractPlanFilePath only matches plan-mode save renders).
*/
tokensObserved?: Record<string, boolean>;
}
/**
* The contract for "skill X invoked in plan mode behaves correctly."
*
* PASS: outcome is 'asked' or 'plan_ready'.
* - 'asked' = the skill is gating decisions on the user, as expected.
* - 'plan_ready' = the skill ran end-to-end, wrote a plan file, and
* surfaced claude's native confirmation. Some skills (like
* plan-design-review on a no-UI branch) legitimately reach plan_ready
* without firing AskUserQuestion because they short-circuit.
*
* FAIL: 'silent_write' or 'exited' or 'timeout'.
*
* This replaces the SDK-based runPlanModeSkillTest which never worked
* because plan mode renders its native confirmation as TTY UI, not via
* the AskUserQuestion tool — so canUseTool never fired and the assertion
* counted zero questions.
*/
export async function runPlanSkillObservation(opts: {
/** Skill name, e.g. 'plan-ceo-review'. */
skillName: string;
/** Whether to launch in plan mode. Default true. The no-op regression
* test sets this false to verify skills work outside plan mode. */
inPlanMode?: boolean;
/** Working directory. Default process.cwd(). */
cwd?: string;
/** Total budget for skill to reach a terminal outcome. Default 180000. */
timeoutMs?: number;
/** Extra CLI args appended after --permission-mode. Used by the v1.22+
* AskUserQuestion-blocked regression tests to pass
* `['--disallowedTools', 'AskUserQuestion']` (the flag set Conductor
* uses to remove native AskUserQuestion in favor of its MCP variant).
* Plumbs straight through to launchClaudePty. */
extraArgs?: string[];
/**
* Extra env merged into the spawned `claude` process. `launchClaudePty`
* already supports this; exposing it here lets per-skill tests isolate
* from local config that would mask the regression they're trying to
* catch (e.g., `QUESTION_TUNING=true` causing AUTO_DECIDE to skip the
* rendered AskUserQuestion list).
*/
env?: Record<string, string>;
/**
* Seed an initial plan that the spawned `claude` process operates on.
* STOP-gate regression tests need a plan with guaranteed-finding-triggering
* complexity (8+ files, custom-vs-builtin smell) so the skill MUST emit
* AskUserQuestion or fall back to a Decisions section. Without this,
* plan-mode creates a fresh empty plan and the skill has nothing to find
* issues with.
*
* Implementation: claude has no `--plan-file` flag (verified via
* `claude --help`). We pre-pump a user message containing the draft
* plan, wait for it to register, then invoke the skill. The skill's
* Step 0 reads the prior conversation context so it sees the draft.
*/
initialPlanContent?: string;
/** Override the spawned model. Defaults via launchClaudePty's chain
* (opts.model ?? EVALS_MODEL ?? 'claude-sonnet-4-6'). */
model?: string;
/** Literal tokens to track as high-water marks over the CUMULATIVE visible
* buffer (case-sensitive). Results land in obs.tokensObserved. Use for
* consumption asserts that must survive the 2KB evidence tail. */
trackTokens?: string[];
}): Promise<PlanSkillObservation> {
const startedAt = Date.now();
const session = await launchClaudePty({
permissionMode: opts.inPlanMode === false ? null : 'plan',
cwd: opts.cwd,
timeoutMs: (opts.timeoutMs ?? 180_000) + 30_000,
extraArgs: opts.extraArgs,
env: opts.env,
model: opts.model,
seedSkills: true,
});
try {
// Boot grace + trust-dialog auto-handle.
await Bun.sleep(8000);
if (opts.initialPlanContent) {
// Pre-pump the draft as a user message so the skill's Step 0 has
// concrete content to scope-challenge. The trailing `\r` submits
// the message; embedded `\n` are preserved as line breaks within
// the message (claude-code uses Enter to send, Shift+Enter for
// newlines, but raw `\r` from a PTY just submits whatever's in
// the input buffer).
const seed = `Please review the following draft plan when I run the skill below:\n\n${opts.initialPlanContent}`;
session.send(`${seed}\r`);
// Wait for the seed message to render before sending the skill
// command. Without this gap the two messages can fuse and the
// skill name becomes part of the user prompt instead of a slash
// command.
await Bun.sleep(3000);
}
const since = session.mark();
session.send(`/${opts.skillName}\r`);
const budgetMs = opts.timeoutMs ?? 180_000;
const start = Date.now();
let lastJudgeAt = 0;
let lastJudgeVerdict: PtyStateVerdict | null = null;
// High-water marks: did we EVER see a prose-AUQ surface or a judge
// 'waiting' verdict during the run? Models may surface options
// briefly, then resume thinking when no user response comes (test
// env has no responder). At timeout we trust historical signals
// even if the current state is 'working'.
let proseAUQEverObserved = false;
let waitingEverObserved = false;
let scopeGateQuestionObserved = false;
let scopeGateAutoSelectObserved = false;
const tokensObserved: Record<string, boolean> = {};
for (const t of opts.trackTokens ?? []) tokensObserved[t] = false;
// Single source for the high-water flags at EVERY return site. Hand-
// spreading them per-site already drifted once (the judge-waiting return
// omitted the prose/waiting flags); a site that forgets a must-stay-false
// flag makes `obs.flag ?? false` negative assertions pass vacuously.
const highWaterFlags = () => ({
proseAUQEverObserved,
waitingEverObserved,
scopeGateQuestionObserved,
scopeGateAutoSelectObserved,
...(opts.trackTokens?.length ? { tokensObserved } : {}),
});
const JUDGE_AFTER_MS = 60_000;
const JUDGE_INTERVAL_MS = 30_000;
while (Date.now() - start < budgetMs) {
await Bun.sleep(2000);
const visible = session.visibleSince(since);
if (session.exited()) {
return {
outcome: 'exited',
summary: `claude exited (code=${session.exitCode()}) before reaching a terminal outcome`,
evidence: visible.slice(-2000),
elapsedMs: Date.now() - startedAt,
...highWaterFlags(),
};
}
if (visible.includes('Unknown command:')) {
return {
outcome: 'exited',
summary: `claude rejected /${opts.skillName} as unknown command (skill not registered in this cwd)`,
evidence: visible.slice(-2000),
elapsedMs: Date.now() - startedAt,
...highWaterFlags(),
};
}
// Cheap surface-tracking: did the model ever surface a prose AUQ in
// this tick's recent buffer? Track once-true (high water).
if (!proseAUQEverObserved && isProseAUQVisible(visible)) {
proseAUQEverObserved = true;
logPtySnapshot(visible, {
testName: opts.skillName,
elapsedMs: Date.now() - start,
tag: 'prose-auq-surfaced',
});
}
// Scope-gate render tracking (same high-water shape). Full-run
// detection matters because the 2KB evidence tail usually scrolls
// past the gate render before the outcome fires.
if (!scopeGateQuestionObserved && isScopeGateQuestionVisible(visible)) {
scopeGateQuestionObserved = true;
}
if (!scopeGateAutoSelectObserved && isScopeGateAutoSelectVisible(visible)) {
scopeGateAutoSelectObserved = true;
}
for (const t of opts.trackTokens ?? []) {
if (!tokensObserved[t] && visible.includes(t)) tokensObserved[t] = true;
}
const classified = classifyVisible(visible, {
strictPlanWrites: !!opts.initialPlanContent,
});
if (classified) {
const obs: PlanSkillObservation = {
...classified,
evidence: visible.slice(-2000),
elapsedMs: Date.now() - startedAt,
...highWaterFlags(),
};
// Capture the plan file path on any outcome where one may have been
// written. Gating only on 'plan_ready' missed two cases: (1) the
// 'asked' outcome where the model wrote a plan partway through then
// paused on a question, and (2) 'wrote_findings_before_asking' where
// the bug is precisely that the plan was written. The
// assertReviewReportAtBottom checks downstream gate on planFile
// existing, not on the outcome.
const planFile = extractPlanFilePath(visible);
if (planFile) obs.planFile = planFile;
return obs;
}
// LLM judge fallback: if regex detectors didn't classify and we've
// burned >60s with periodic ticks, ask Haiku "is the model waiting,
// working, or hung?" Treat 'waiting' as 'asked' (model surfaced a
// question via prose the regex couldn't reassemble). Snapshot the
// visible buffer at each judge call when GSTACK_PTY_LOG=1.
const elapsed = Date.now() - start;
if (elapsed > JUDGE_AFTER_MS && Date.now() - lastJudgeAt > JUDGE_INTERVAL_MS) {
lastJudgeAt = Date.now();
logPtySnapshot(visible, { testName: opts.skillName, elapsedMs: elapsed, tag: 'judge-tick' });
lastJudgeVerdict = judgePtyState(visible, { testName: opts.skillName });
if (lastJudgeVerdict.state === 'waiting') {
waitingEverObserved = true;
return {
outcome: 'asked',
summary: `LLM judge: ${lastJudgeVerdict.reasoning} (state=waiting after ${Math.round(elapsed / 1000)}s)`,
evidence: visible.slice(-2000),
elapsedMs: Date.now() - startedAt,
...highWaterFlags(),
};
}
}
}
// Timeout fallback: if we observed a prose-AUQ surface OR a judge
// 'waiting' verdict at any point during the run, treat as 'asked'.
// This catches the model-surfaced-then-resumed-thinking case where
// by the time the timeout fires, the buffer has moved past the
// options into spinner state but the question DID surface earlier.
const finalVisible = session.visibleSince(since);
if (proseAUQEverObserved || waitingEverObserved) {
return {
outcome: 'asked',
summary:
`prose-AUQ surface observed during run (proseAUQEverObserved=${proseAUQEverObserved}, waitingEverObserved=${waitingEverObserved}); model surfaced the question and the test budget elapsed without a follow-up classification` +
(lastJudgeVerdict
? ` (last LLM judge: ${lastJudgeVerdict.state}${lastJudgeVerdict.reasoning})`
: ''),
evidence: finalVisible.slice(-2000),
elapsedMs: Date.now() - startedAt,
...highWaterFlags(),
};
}
return {
outcome: 'timeout',
summary:
`no terminal outcome within ${budgetMs}ms` +
(lastJudgeVerdict
? ` (last LLM judge: state=${lastJudgeVerdict.state}${lastJudgeVerdict.reasoning})`
: ''),
evidence: finalVisible.slice(-2000),
elapsedMs: Date.now() - startedAt,
...highWaterFlags(),
};
} finally {
await session.close();
}
}
// ────────────────────────────────────────────────────────────────────────────
// runPlanSkillCounting — drives a plan-* skill end-to-end through Step 0 then
// counts distinct review-phase AskUserQuestion fingerprints. The actual
// product asserted by the per-finding-count tests.
// ────────────────────────────────────────────────────────────────────────────
/**
* Result of a `runPlanSkillCounting` run. Includes both the count summary
* (`step0Count`, `reviewCount`) and the full fingerprint list for diagnostic
* dumps when an assertion fails.
*/
export interface PlanSkillCountObservation {
outcome:
| 'plan_ready'
| 'completion_summary'
| 'ceiling_reached'
| 'silent_write'
| 'exited'
| 'timeout';
summary: string;
/** Visible terminal text at terminal time (last 3KB). */
evidence: string;
/** Wall time (ms) until the outcome was decided. */
elapsedMs: number;
/** All distinct AskUserQuestions observed, in observation order. */
fingerprints: AskUserQuestionFingerprint[];
/** Count of fingerprints with `preReview === true`. */
step0Count: number;
/** Count of fingerprints with `preReview === false`. */
reviewCount: number;
}
/**
* Drive a plan-* skill in plan mode and count distinct review-phase
* AskUserQuestions until a terminal signal fires.
*
* Flow:
* 1. Boot PTY in plan mode (8s grace + auto-trust dialog).
* 2. Send `slashCommand` alone. Sleep ~3s.
* 3. Send `followUpPrompt` as a chat message — this is the plan content
* the skill reviews. Slash commands with trailing args are rejected by
* Claude Code unless the skill defines them, so the plan goes as a
* follow-up message (the proven pattern at
* skill-e2e-plan-design-with-ui.test.ts:57-71).
* 4. Poll loop:
* - Skip permission dialogs (auto-grant with `defaultPick`).
* - On a new numbered-option list, parse prompt + options, build
* fingerprint via `auqFingerprint`. Empty-prompt parses are skipped
* and re-polled (avoids the empty-prompt collision documented in
* the auqFingerprint contract).
* - First time we see a fingerprint: push it, classify as Step 0 or
* review-phase based on `boundaryFired`, press `defaultPick` to
* advance.
* - After pressing, evaluate `isLastStep0AUQ(fingerprint)`. If true,
* all subsequent AUQs are review-phase.
* - Hard ceiling: if `reviewCount >= reviewCountCeiling`, return
* `ceiling_reached`. This bounds runaway counts; tests should set
* the ceiling above their assertion CEILING.
* - Soft terminals: `COMPLETION_SUMMARY_RE` match → `completion_summary`;
* plan-ready confirmation → `plan_ready`; silent write outside
* sanctioned dirs → `silent_write`; process exited → `exited`;
* wall clock exceeded → `timeout`.
*
* Boundary detection (D14): event-based, fired against the answered AUQ's
* fingerprint, not against later rendered content. This avoids the race
* where Step-0-final and Section-1-first AUQs straddle a section header
* regex match.
*
* Fingerprint composition (D9): `auqFingerprint(prompt, options)` mixes
* normalized prompt text with the options signature so distinct findings
* with shared menu structure (the generic A/B/C TODO menu) get distinct
* fingerprints.
*/
export async function runPlanSkillCounting(opts: {
/** Skill name, e.g. 'plan-ceo-review'. Used for diagnostic strings only. */
skillName: string;
/** Slash command to send alone, e.g. '/plan-ceo-review'. No trailing args. */
slashCommand: string;
/** Plan content sent as a follow-up message ~3s after the slash command. */
followUpPrompt: string;
/** Per-skill predicate: which answered AUQ is the last Step-0 question. */
isLastStep0AUQ: Step0BoundaryPredicate;
/** Hard cap on review-phase count; helper returns when reached. Should be
* set ABOVE the test's assertion ceiling so the test sees the cap as a
* failure rather than a silent stop. */
reviewCountCeiling: number;
/** Numbered option to press by default. Defaults to 1 (recommended). */
defaultPick?: number;
/**
* Optional override for the FIRST AUQ observed. Receives the fingerprint;
* returns the option index to press. Subsequent AUQs always use defaultPick.
*
* Skill-specific routing helper: /plan-ceo-review's first AUQ asks "what
* scope?" with options like "branch diff" / "describe inline" / "skip
* interview". Pressing the default 1 routes to "branch diff" (the wrong
* review target for a seeded fixture). firstAUQPick lets the test pick
* "Skip interview" or "describe inline" so the agent reviews the
* follow-up plan content the test sent, not the git diff.
*/
firstAUQPick?: (fp: AskUserQuestionFingerprint) => number;
/** Working directory. Default process.cwd() (repo cwd holds skill registry). */
cwd?: string;
/** Total budget for skill to reach a terminal outcome. Default 1_500_000 (25 min). */
timeoutMs?: number;
/** Extra env merged into the spawned `claude` process. */
env?: Record<string, string>;
/** Override the spawned model. Defaults via launchClaudePty's chain. */
model?: string;
}): Promise<PlanSkillCountObservation> {
const startedAt = Date.now();
const defaultPick = opts.defaultPick ?? 1;
const timeoutMs = opts.timeoutMs ?? 1_500_000;
const session = await launchClaudePty({
permissionMode: 'plan',
cwd: opts.cwd,
timeoutMs: timeoutMs + 60_000,
env: opts.env,
model: opts.model,
seedSkills: true,
});
const fingerprints: AskUserQuestionFingerprint[] = [];
const seen = new Set<string>();
let boundaryFired = false;
let step0Count = 0;
let reviewCount = 0;
let isFirstAUQ = true;
let lastSig = '';
function snapshot(
outcome: PlanSkillCountObservation['outcome'],
summary: string,
visible: string,
): PlanSkillCountObservation {
return {
outcome,
summary,
evidence: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
fingerprints,
step0Count,
reviewCount,
};
}
try {
await Bun.sleep(8000); // boot grace + auto-trust handler window
const since = session.mark();
session.send(`${opts.slashCommand}\r`);
await Bun.sleep(3000);
session.send(`${opts.followUpPrompt}\r`);
const budgetStart = Date.now();
while (Date.now() - budgetStart < timeoutMs) {
await Bun.sleep(2000);
const visible = session.visibleSince(since);
// Process exited?
if (session.exited()) {
return snapshot(
'exited',
`claude exited (code=${session.exitCode()}) during counting (step0=${step0Count}, review=${reviewCount})`,
visible,
);
}
if (visible.includes('Unknown command:')) {
return snapshot(
'exited',
`claude rejected ${opts.slashCommand} as unknown command (skill not registered in this cwd)`,
visible,
);
}
// Silent write detection — only fires if no numbered prompt is on
// screen (otherwise the write is gated by a permission/AUQ).
const writeRe = /⏺\s*(?:Write|Edit)\(([^)]+)\)/g;
let m: RegExpExecArray | null;
while ((m = writeRe.exec(visible)) !== null) {
const target = m[1] ?? '';
const sanctioned = SANCTIONED_WRITE_SUBSTRINGS.some((s) =>
target.includes(s),
);
if (!sanctioned && !isNumberedOptionListVisible(visible)) {
return snapshot(
'silent_write',
`Write/Edit to ${target} fired before any AskUserQuestion`,
visible,
);
}
}
// Soft terminal signals — check before AUQ processing so a final
// completion-summary doesn't get misclassified as a bonus AUQ.
if (COMPLETION_SUMMARY_RE.test(visible)) {
return snapshot(
'completion_summary',
`skill emitted completion summary / verdict / status line (step0=${step0Count}, review=${reviewCount})`,
visible,
);
}
if (isPlanReadyVisible(visible)) {
return snapshot(
'plan_ready',
`skill emitted plan-mode "Ready to execute" confirmation (step0=${step0Count}, review=${reviewCount})`,
visible,
);
}
// Numbered option list?
if (!isNumberedOptionListVisible(visible)) continue;
// Permission dialog? Auto-grant with defaultPick. Only act on the
// recent tail to avoid re-triggering on stale dialogs in scrollback.
if (isPermissionDialogVisible(visible.slice(-TAIL_SCAN_BYTES))) {
session.send(`${defaultPick}\r`);
await Bun.sleep(1500);
continue;
}
// Parse the active AUQ. Skip same-redraw and empty-prompt cases.
const options = parseNumberedOptions(visible);
if (options.length < 2) continue;
const sig = optionsSignature(options);
if (sig === lastSig) continue;
const promptSnippet = parseQuestionPrompt(visible);
if (promptSnippet === '') continue; // not yet rendered, poll again
lastSig = sig;
const fingerprintHash = auqFingerprint(promptSnippet, options);
if (seen.has(fingerprintHash)) {
// Same content, already counted (TTY redrew with whitespace diff).
continue;
}
seen.add(fingerprintHash);
const fp: AskUserQuestionFingerprint = {
signature: fingerprintHash,
promptSnippet,
options,
observedAtMs: Date.now() - startedAt,
preReview: !boundaryFired,
};
fingerprints.push(fp);
if (boundaryFired) reviewCount += 1;
else step0Count += 1;
// Press to advance — first AUQ may use the override pick.
const pickIdx =
isFirstAUQ && opts.firstAUQPick ? opts.firstAUQPick(fp) : defaultPick;
isFirstAUQ = false;
session.send(`${pickIdx}\r`);
// Evaluate boundary AFTER pressing — if THIS AUQ was the last Step 0
// question, all subsequent AUQs go to reviewCount.
if (!boundaryFired && opts.isLastStep0AUQ(fp)) {
boundaryFired = true;
}
// Hard ceiling — runaway protection.
if (reviewCount >= opts.reviewCountCeiling) {
return snapshot(
'ceiling_reached',
`review-phase AUQ count reached ceiling (${opts.reviewCountCeiling})`,
session.visibleSince(since),
);
}
// Give the agent a beat to advance to the next state.
await Bun.sleep(2000);
}
return snapshot(
'timeout',
`no terminal outcome within ${timeoutMs}ms (step0=${step0Count}, review=${reviewCount})`,
session.visibleSince(since),
);
} finally {
await session.close();
}
}
// ────────────────────────────────────────────────────────────────────────────
// runPlanSkillFloorCheck — minimal "did the agent fire ANY AskUserQuestion?"
// observer for gate-tier floor tests catching the May 2026 transcript bug
// (model wrote plan + ExitPlanMode'd with reviewCount=0).
//
// Why this exists separately from runPlanSkillCounting: plan-mode AUQs render
// every option on a single logical line via cursor-positioning escapes that
// stripAnsi can't simulate. parseNumberedOptions therefore returns < 2 options
// from those frames and never records a fingerprint. The full counting helper
// works for periodic finding-count tests because their 25-min budgets give the
// agent enough redraws that one frame eventually parses cleanly. Gate-tier
// floor tests don't have that wall-time budget and need to exit early on the
// first observation. This helper trades fingerprint precision for early-exit
// reliability.
//
// Contract:
// - PASS → outcome === 'auq_observed' (agent rendered any non-permission
// numbered-option list; we exit immediately and report success)
// - FAIL → outcome === 'plan_ready' | 'completion_summary' | 'silent_write'
// (agent reached a terminal state without ever firing an AUQ —
// this IS the transcript bug)
// - SOFT → outcome === 'timeout' (neither happened in budget; agent may
// just be slow — test should retry with a larger budget rather
// than treat as a hard regression)
// ────────────────────────────────────────────────────────────────────────────
export interface PlanSkillFloorObservation {
/** True iff a review-phase AUQ render was observed. */
auqObserved: boolean;
outcome:
| 'auq_observed'
| 'plan_ready'
| 'silent_write'
| 'exited'
| 'timeout';
summary: string;
/** Visible TTY tail (last 3KB) at terminal time. */
evidence: string;
/** Wall time (ms) until the outcome was decided. */
elapsedMs: number;
}
/**
* Drive a plan-* skill in plan mode and exit at the first non-permission
* numbered-option render. See block comment above for the contract.
*/
export async function runPlanSkillFloorCheck(opts: {
/** Skill name, e.g. 'plan-eng-review'. Used for diagnostic strings only. */
skillName: string;
/** Slash command to send alone, e.g. '/plan-eng-review'. */
slashCommand: string;
/** Plan content sent as a follow-up message ~3s after the slash command. */
followUpPrompt: string;
/** Working directory. Default process.cwd(). */
cwd?: string;
/** Total budget. Default 600000 (10 min). Tests exit early on AUQ. */
timeoutMs?: number;
/** Extra env merged into the spawned `claude` process. */
env?: Record<string, string>;
/** Override the spawned model. Defaults via launchClaudePty's chain. */
model?: string;
}): Promise<PlanSkillFloorObservation> {
const startedAt = Date.now();
const timeoutMs = opts.timeoutMs ?? 600_000;
const session = await launchClaudePty({
permissionMode: 'plan',
cwd: opts.cwd,
timeoutMs: timeoutMs + 60_000,
env: opts.env,
model: opts.model,
seedSkills: true,
});
try {
await Bun.sleep(8000); // boot grace + auto-trust handler window
const since = session.mark();
session.send(`${opts.slashCommand}\r`);
await Bun.sleep(3000);
session.send(`${opts.followUpPrompt}\r`);
const start = Date.now();
let lastJudgeAt = 0;
let lastJudgeVerdict: PtyStateVerdict | null = null;
// Positional anchor for the scope-gate exclusion. The visible buffer is
// append-only (old renders never leave scrollback), so a gate question
// rendered in the 3s pre-target window would keep satisfying the
// full-buffer acceptance checks forever while a tail-only exclusion
// stops seeing it after ~TAIL_SCAN_BYTES of output — a vacuous
// auq_observed (found independently by 4 review passes). Once the gate
// render is seen, acceptance only counts AUQ renders in content APPENDED
// after that point.
let gateSeenIdx = -1;
const JUDGE_AFTER_MS = 60_000;
const JUDGE_INTERVAL_MS = 30_000;
while (Date.now() - start < timeoutMs) {
await Bun.sleep(2000);
const visible = session.visibleSince(since);
if (gateSeenIdx === -1 && isScopeGateQuestionVisible(visible)) {
gateSeenIdx = visible.length;
}
if (session.exited()) {
return {
auqObserved: false,
outcome: 'exited',
summary: `claude exited (code=${session.exitCode()}) before any AUQ render`,
evidence: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
};
}
if (visible.includes('Unknown command:')) {
return {
auqObserved: false,
outcome: 'exited',
summary: `claude rejected ${opts.slashCommand} as unknown command`,
evidence: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
};
}
// Success: ANY non-permission numbered-option list is an AUQ render —
// either via the native numbered-prompt UI (isNumberedOptionListVisible)
// OR via prose-rendered options under --disallowedTools when no MCP
// variant is callable (isProseAUQVisible). Both surface the question
// to the user; the bug we're catching is "fired zero AUQs."
//
// Scope-gate renders do NOT count: the gate's "What should I review?"
// can fire inside the 3s pre-target window and would trivially satisfy
// the floor, but the floor measures FINDING-driven questions. Once a
// gate render has been seen, acceptance scans only the content APPENDED
// after it (positional anchor above) — the buffer is append-only, so a
// whole-buffer acceptance would keep matching the stale gate render
// forever.
//
// The gate veto is ACTIVE-RENDER-aware, not blanket-tail: when a
// numbered menu is up, parseNumberedOptions anchors on the LAST cursor
// line, so we veto only when the pending menu IS the gate — a finding
// AUQ that renders within TAIL_SCAN_BYTES of the gate (model waiting,
// no further output) still satisfies the floor. Prose renders have no
// cursor anchor, so the prose path falls back to the tail check
// (accepted residual: prose gate + prose finding inside one tail can
// suppress until timeout; floors run the native-menu path in practice).
const tail = visible.slice(-TAIL_SCAN_BYTES);
const acceptWindow = gateSeenIdx === -1 ? visible : visible.slice(gateSeenIdx);
const activeMenu = parseNumberedOptions(visible);
const gateIsActiveRender =
activeMenu.length > 0
? activeMenu.some((o) => /current\s*branch\s*diff/i.test(o.label))
: isScopeGateQuestionVisible(tail);
if (
(isNumberedOptionListVisible(acceptWindow) || isProseAUQVisible(acceptWindow)) &&
!isPermissionDialogVisible(tail) &&
!gateIsActiveRender
) {
return {
auqObserved: true,
outcome: 'auq_observed',
summary: 'agent rendered an AskUserQuestion (floor met)',
evidence: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
};
}
// LLM judge fallback: same shape as runPlanSkillObservation. After 60s
// of polling without a regex hit, ask Haiku to classify the snapshot.
// 'waiting' verdict counts as floor met (model surfaced a question via
// prose the regex couldn't catch). 'working' / 'hung' / 'unknown' don't
// change the outcome — they enrich the eventual timeout summary so the
// failure diagnostic is more actionable than "no AUQ render."
const elapsed = Date.now() - start;
if (elapsed > JUDGE_AFTER_MS && Date.now() - lastJudgeAt > JUDGE_INTERVAL_MS) {
lastJudgeAt = Date.now();
logPtySnapshot(visible, { testName: opts.skillName, elapsedMs: elapsed, tag: 'floor-judge-tick' });
lastJudgeVerdict = judgePtyState(visible, { testName: opts.skillName });
// The judge can't tell a scope-gate question from a finding question,
// so a 'waiting' verdict while the gate menu is the pending render
// must NOT satisfy the floor — same active-render exclusion as the
// regex path.
if (lastJudgeVerdict.state === 'waiting' && !gateIsActiveRender) {
return {
auqObserved: true,
outcome: 'auq_observed',
summary: `LLM judge: ${lastJudgeVerdict.reasoning} (state=waiting after ${Math.round(elapsed / 1000)}s; floor met)`,
evidence: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
};
}
}
// Silent write outside sanctioned dirs is the transcript-bug shape.
const writeRe = /⏺\s*(?:Write|Edit)\(([^)]+)\)/g;
let m: RegExpExecArray | null;
while ((m = writeRe.exec(visible)) !== null) {
const target = m[1] ?? '';
const sanctioned = SANCTIONED_WRITE_SUBSTRINGS.some((s) => target.includes(s));
if (!sanctioned && !isNumberedOptionListVisible(visible)) {
return {
auqObserved: false,
outcome: 'silent_write',
summary: `Write/Edit to ${target} fired before any AskUserQuestion`,
evidence: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
};
}
}
// Reached terminal without AUQ → transcript-bug regression.
// Note: COMPLETION_SUMMARY_RE is intentionally NOT checked here — it
// matches "GSTACK REVIEW REPORT" anywhere in the buffer, including
// when the agent does recon by reading existing plan files (which
// contain that string as a generated section). The plan_ready check
// (claude's actual "Ready to execute" confirmation) is the reliable
// terminal signal for "agent finished without asking."
if (isPlanReadyVisible(visible)) {
return {
auqObserved: false,
outcome: 'plan_ready',
summary: 'agent reached plan_ready without firing any AskUserQuestion',
evidence: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
};
}
}
return {
auqObserved: false,
outcome: 'timeout',
summary: `no AUQ render and no terminal outcome within ${timeoutMs}ms`,
evidence: session.visibleSince(since).slice(-3000),
elapsedMs: Date.now() - startedAt,
};
} finally {
await session.close();
}
}