mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-20 21:17:19 +02:00
* test(helpers): shared skill-census helper with three explicit counts
physicalSkillFiles (symlinked dirs included, root router included),
authoredSkills (realpath-deduped, router excluded), registryEntries
(what ./setup registers: unique frontmatter names + _gstack-command).
One counting authority for the hermetic seeder, context-bill ground
truth, and the catalog-budget test — connect-chrome's dir symlink and
the root router otherwise produce three subtly different hand-rolled
censuses. Ported-wave foundation (C11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): stop the harness grading itself
findPreviousRun excluded only the file being written, by name, so every
suite compared against _partial-e2e.json — the current run's own
accumulator, relabelled with the current tier just before each flush.
That is why every block read '+$0.00, +0s, Stable run, no regressions.'
This harness has never been able to detect a regression, and reassuring
output that cannot fail is worse than none. In-progress runs are now
excluded by role, and a run with nothing to compare against says NO
BASELINE instead of claiming stability.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit f3140b5245221fff7fb9411c7ec07c2ca11587b5)
* refactor(evals): shared partial-run predicate + finalized-run lookup
isPartialEval(data, filename) is the one place that decides what counts
as an in-progress accumulator (the _partial flag OR a _partial-prefixed
filename), and findLatestFinalizedRun(evalDir, tier) is the one place
that finds the newest real run — scanning the eval dir plus one level of
shards/<slug>/ subdirs, where the sharded paid runner points each
shard's collector. skill-budget-regression.test.ts's hand-rolled
findLatestRun (flag-blind: a flagged-but-renamed accumulator passed its
name check) is replaced by the shared helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b55fcf6966366fd21a8cdc46de61aab6e1b1d100)
* feat(evals): register shipped skills for hermetic PTY children
Hermetic children get a config dir that deliberately seeds no skills —
right for children that install their own, fatal for the PTY family that
TYPES /office-hours or /plan-ceo-review: claude rejects the command as
Unknown before any model turn, so the plan-family gate smokes measure
nothing. hermeticSkillsConfigDir() is a second, opt-in config dir under
the same runRoot that mirrors ./setup's registration exactly (real dir
per registry name, SKILL.md + sections/ symlinks, frontmatter-name
resolution, _gstack-command root alias), driven by the shared
skill-census so connect-chrome's dir symlink collapses the same way
setup's idempotent overwrite does.
Ported from fork commit 03c4eca2, tree walk rewritten for the upstream
layout (top-level <skill>/SKILL.md dirs, no skills/ tree). Unit tests
are new: seed shape, census parity, symlink resolution, connect-chrome
collapse, idempotence, no-API-key seed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 93dae6107b30ce453a07c2d342b60262bba6ce0b)
* feat(evals): seedSkills opt-in for PTY slash-command tests + tripwire
Wire ClaudePtyOptions.seedSkills through launchClaudePty: when set (and
hermetic, and no per-test CLAUDE_CONFIG_DIR override), the child gets
hermeticSkillsConfigDir() so typed /skill slash commands resolve instead
of dying as Unknown command before any model turn. Opted in at the three
runPlanSkill* helpers and the four direct-launch slash-command tests
(plan-design-with-ui, plan-ceo-mode-routing, autoplan-chain,
ship-idempotency).
New static tripwire (test/pty-skill-seeding-wiring.test.ts): any test
file that sends a slash command over the PTY must route through a
runPlanSkill* helper or pass seedSkills: true — an unseeded slash-command
test spends money and measures nothing. hermetic-wiring.test.ts now
blesses the repo-tree seeding path explicitly (config dir under runRoot,
symlinks into the repo checkout, never operator ~/.claude).
The CI "Register gstack skills for PTY smoke" step keeps a keep-me note:
container cross-mount symlinks defeat the TUI scanner and HOME is not
hermeticized, so the real-file copies there must survive this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 63c52269daaffb833b3105ea9b4b99be6df8fec7)
* refactor(evals): single shared paid-test-set module
test/helpers/paid-test-set.ts is now the one definition of which test
files are paid (the exact globs package.json's test:gate expands).
scripts/test-free-shards.ts derives its free/paid exclusion from it
instead of a private regex list, dropping the dead
browse/test/security-review-fullstack.test.ts pattern (file no longer
exists). The sharded paid runner derives its enumeration from the same
module, so a file added to one list can no longer silently miss the
other.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit a7f36479a6a1f3656452370f5883371f3cb65623)
* feat(evals): env-driven lazy eval dir + shard-aware store and tooling
Importing eval-store no longer spawns the gstack-slug subprocess: the
module-level DEFAULT_EVAL_DIR constant is now a memoized defaultEvalDir()
resolved at collector construction. Resolution order: explicit
constructor arg, then GSTACK_EVAL_DIR, then slug detection — so the
sharded paid runner can point each shard child at its own
<evalDir>/shards/<slug>/ dir with plain env, no --preload.
Runs collected under a shards/ subdir record their slug in the eval
JSON (EvalResult.shard). findPreviousRun scans one shards/<slug>/ level
and prefers same-slug priors, so each shard baselines against its own
history instead of whichever shard flushed last. eval:list,
eval:summary, and eval:compare enumerate the same one level of shard
subdirs; eval:compare's no-arg mode also stops picking an in-progress
accumulator as the after-run.
eval-watch stays flat (documented follow-up): it tails a single dir for
live progress and gains nothing from per-shard baselines until the
runner emits a merged stream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e1f53f7d9c7fe6b65877d843f2e25bd2e2d12ffd)
* feat(evals): sharded paid tier runner
scripts/test-paid-shards.ts runs the gate/periodic tier one Bun process
per test file, with an EXTERNAL wall-clock timeout that SIGKILLs the
shard's detached process group and an aggregate that distinguishes
passed / failed / timed-out / never-started — partial execution can no
longer read as a pass. Bun's native --shard/--isolate covers none of
this: no process-group kill (hung claude/codex PTY grandchildren
survive in-process isolation), no never-started taxonomy, no per-shard
env. Each shard child gets GSTACK_EVAL_DIR=<evalDir>/shards/<slug>/
(slug = test filename sans extension, stable across runs) so shard
baselines compare against their own prior runs.
Output classification lives in scripts/test-strict-output.ts (strict
exit-code derivation, incremental fail-line classifier, child signal
forwarding) so the runner and any future strict bun-test wrapper share
one implementation. Enumeration derives from the shared paid-test-set
module; tier exclusion fires only on an explicit whole-file
EVALS_TIER === '<other>' guard.
package.json gains test:gate:sharded / test:periodic:sharded, and
eval:bg:gate / eval:bg:periodic now run the sharded scripts with detach
timeouts sized to the worst case (gate: 49 shards x 30min / 4 jobs ~
6.2h -> 25200s; periodic: 59 -> 28800s).
test/paid-shards.test.ts pins enumeration, tier classification, and the
kill-and-continue property with a real busy-loop shard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5e76bd5931836257f896cedfe4e93912cb759c70)
* feat(security): hash-chained egress receipt ledger (core)
Port lib/egress-receipt from the v2 fork as TypeScript: writeReceipt
(sync, fail-closed via typed EGRESS_RECEIPT_FAILED), best-effort
writeOutcome, readLedger/listReceipts/verifyLedger, GSTACK_HOME ->
GSTACK_STATE_DIR -> ~/.gstack resolution, 0600 ledger under a 0700
security dir, and an mkdir spin lock (2.5s budget) with documented
>10s-mtime stale-lock reclaim.
Changes vs the fork:
- lastRawLine tail-reads the final 4KB instead of loading the whole
ledger, so appends stay O(1) as the file grows.
- WARN-at-size: past 25MB writeReceipt emits one self-explanatory
stderr warning per process (what the ledger is, how to inspect it,
rotation TODO); verifyLedger gains a sizeWarning field. Rotation
TODO carries the chain-genesis sketch (new generation's first record
embeds the prior file's tail hash).
bin/gstack-egress-receipt is a bun script bridging shell callers:
write|outcome subcommands, exit 3 + EGRESS_RECEIPT_FAILED on stderr on
failure; --no-payload records sha256:null for git-class ops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 619726a3d77d987a2e50151a5727b3faaaf5fc6a)
* chore(bin): delete dead brain-consumer/reader scripts
bin/gstack-brain-consumer and bin/gstack-brain-reader are byte-identical
dead scripts that POST the repo URL + a Bearer token to a /ingest-repo
endpoint gbrain removed (docs/gbrain-sync.md already documents the
removal in past tense). No live references remain; CHANGELOG mentions
are historical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 254ddc69fc5a0270fcc973e36b6a81766d835d2d)
* feat(security): shared shell receipt helpers
bin/gstack-egress-lib.sh (sourced library, gstack-gbrain-lib.sh
precedent) provides _receipted_curl and _receipted_git: write the
egress receipt BEFORE the send via gstack-egress-receipt, hand curl the
SAME payload file via --data-binary @file so the receipt hash matches
the wire bytes exactly, then append a best-effort outcome. Per-call
fail policy: 'closed' refuses the send (return 3, problem/cause/fix
message on stderr) and 'open' warns and proceeds. Payload temp files
are consumed immediately per call — no EXIT traps, since callers like
gstack-telemetry-sync own their own EXIT trap and a sourced trap would
clobber it.
Tested end-to-end against a local Bun.serve listener: receipt sha256
equals the sha256 of the bytes the listener received, fail-closed
refusal never touches the network and carries the problem/cause/fix
stderr shape, fail-open warns and proceeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 6d067dce2d4c8815dec98be551763c85a3671357)
* feat(security): receipt core shell sinks
Wire the three core bash egress sinks through gstack-egress-lib.sh:
- gstack-telemetry-sync: the batch POST now writes the payload to a
temp file, receipts those exact bytes fail-closed, and hands curl the
SAME file. On refusal nothing is sent and the cursor does not
advance, so the batch stays buffered for the next run. The HTTP
status is recorded as the receipt outcome.
- gstack-update-check: fail-open receipts (warn + proceed) on the
Supabase ping POST, both VERSION curls (via a local
_receipted_version_fetch helper that skips non-network schemes), and
git ls-remote. The ping receipt is written inside the backgrounded
subshell, so it can never block the script's exit.
- gstack-brain-sync: fail-closed git-class receipts. The push receipt
is written BEFORE the commit consumes the queue, so a refused receipt
leaves the queue intact and the next run retries the whole drain
(pinned by a new queue-intact-on-refusal test, including the
problem/cause/fix refusal message shape). The retry-path fetch and
retry push carry their own fail-closed receipts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 3c60f699acceaf1c92a218874711e05fc17dca5d)
* feat(security): receipt TS module sinks + tunnel
writeReceipt (fail-closed, sha256:null — a subprocess or SDK owns the
wire bytes) before every TS-module network-bearing operation:
- bin/gstack-gbrain-sync.ts: before the gbrain code walk that ships
repo content to the user's gbrain DB (may be remote Postgres). A
refused receipt fails the stage with status refused-egress-receipt.
- bin/gstack-memory-ingest.ts: before the gbrain batch import of
transcript pages. A refused receipt returns a system_error verdict
without spawning the import.
- browse/src/server.ts: before both ngrok.forward call sites (start-up
BROWSE_TUNNEL=1 path and the /tunnel/start endpoint). A receipt
failure lands in the existing catch that tears the tunnel listener
back down and refuses the start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5677d618a48fcd0ae2b068bf868781d90f809cb5)
* feat(design): receipted fetch for OpenAI calls
design/src/receipted-fetch.ts wraps every api.openai.com call: a
content-free egress receipt (sink design-openai, sha256 of the JSON
body — hash only, never the body) is written BEFORE the send. Polarity
is FAIL-OPEN: user-facing generation must not die because an audit log
hiccuped, so a receipt failure warns on stderr and the call proceeds.
Streams pass through untouched (response bodies returned as-is;
non-string request bodies receipted as sha256:null rather than drained
to hash).
All ten call sites converted with per-command payload classes:
generate, variants (injected fetchFn passes through), iterate (both
threaded and fresh paths), evolve (image + screenshot analysis), check,
diff, design-to-code, memory.
Unit-tested with injected fetch: receipt-before-send ordering, stream
passthrough, and fail-open on an unwritable ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c0e5ff6639414ac2fd98e8ac3affb51401746b55)
* feat(security): receipt admin scripts + user git-ops (zero exceptions)
Wire the remaining shell egress through gstack-egress-lib.sh:
- gstack-gbrain-mcp-verify: both JSON-RPC probe POSTs (initialize +
tools/list) receipted fail-closed via payload files (hash == wire
bytes). A refused receipt lands in the NETWORK class — no send.
- gstack-security-dashboard / gstack-community-dashboard: the
community-pulse GETs receipted fail-open (read-only stats must not
break over an audit hiccup).
- gstack-gbrain-supabase-provision: api_call receipted fail-closed.
Each retry attempt hands the helper a fresh copy of the body file
(the helper consumes its payload). The receipt hashes the request
body only — the PAT never reaches the ledger or any log. Refusal
exits 8 without retrying.
- git-class sha256:null receipts, fail-open: gstack-artifacts-init
(ls-remote, initial push, fetch/pull recovery, retry push),
gstack-brain-restore (staging clone, existing-repo fetch),
gstack-session-update (self-update pull).
gstack-team-init needs no wiring: every git clone in it is inside an
echoed instruction string, not an executed command.
The lib now self-locates with shell builtins only (no dirname), so
sourcing works under the whitelist-PATH test harnesses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b8c5e2055b21ab72878b3e46f8047782ee65a11c)
* test(security): egress wiring tripwire + polarity contract
Static-grep tripwire pinning the egress-receipt wiring (threat model in
the header: the ledger is forensic observability of ATTEMPTED egress,
not an exfiltration control):
- Per-sink assertions: every wired TS module imports egress-receipt and
calls writeReceipt; every wired shell sink sources
gstack-egress-lib.sh with each network op under a receipt;
ngrok-proximity check for server.ts; every design api.openai.com call
routes through receiptedFetch.
- Absence assertions: the dead brain-consumer/reader scripts stay
deleted (lstat, so a dangling symlink also fails).
- Polarity table pinned as data (fail-closed: brain-sync,
memory-ingest, gbrain-sync, telemetry-sync, ngrok, mcp-verify,
supabase-provision; fail-open: design-openai, update-check,
dashboards, git-class user ops, context-bill --exact) plus per-file
polarity spot-checks.
- NEW-SINK SCANNER with zero KNOWN_UNWIRED: sweeps bin/, lib/,
scripts/, design/src, browse/src for curl, absolute-URL fetch(, and
git remote ops (never local rev-parse/get-url; heredoc bodies and
message strings excluded) and requires every hit to be receipted or
in a REASONED exemption list where each entry carries its why.
Preamble-generated skill prose documented out-of-scope in the header.
- Shebang tripwire: no bin/gstack-* file may carry a node shebang.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ff69ceeafaf9c017d539b6ad77ff8f95b680b979)
* feat(cli): gstack-egress reader
bin/gstack-egress (bun) — the auditor's view of the receipts ledger:
- list: one row per receipt (what gstack ATTEMPTED to send), with
--since/--host/--sink filters and --json.
- verify: recompute the hash chain; exit 3 on tamper naming the first
broken line; prints the sizeWarning when the ledger passes 25MB.
- grants: what CAN leave, built on the upstream config keys only
(telemetry, artifacts_sync_mode, redact_repo_visibility,
redact_prepush_hook via gstack-config get) — each grant names its
file, key, and the exact revoke command.
CLI smoke tests spawn the real bin against a temp GSTACK_HOME,
including a broken-chain fixture asserting exit 3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 9e24eca0f1069fea2ea69e7df4e9b256e93d59a3)
* feat(cli): context-bill — token bill-of-materials (stripped port)
lib/context-bill.ts, ported from the v2 fork and STRIPPED to the tiers
this repo's skills can exercise: ALWAYS-ON (per-skill frontmatter bytes
with dead-key and foreign-host-file flags), EAGER (SKILL.md + any
forced 'for every invocation' references), on-disk totals, --diff,
--budget, and --exact with the calibration table. The fork's
CONDITIONAL/TRANSITIVE/LAZY/FAST-PATH parsers understand only its
dispatcher layout and were dropped; the tier fields stay in the report
shape (empty/zero/null) so re-adding a parser is additive.
TOKEN_DIVISORS and their provenance docblock kept; --help notes
recalibration via --exact's calibration block.
Three upstream fixes over the fork:
(a) findSkillDirs treats the walk ROOT as a container — the repo root's
router SKILL.md is billed AND its children are walked (the fork
short-circuited and billed one skill); walkMd skips node_modules
and dot-directories.
(b) installed-tree layout: subdirs that are their own repo checkout
(a gstack/ clone inside ~/.claude/skills, detected by .git) are
skipped, and directory symlinks (connect-chrome) are followed with
a container-recursion cycle guard.
(c) ROUTER_KEYS widened to the upstream frontmatter contract {name,
description, version, allowed-tools, triggers, preamble-tier}.
--exact writes an egress receipt (sink context-bill-exact, host
api.anthropic.com) BEFORE any count_tokens POST; if the receipt cannot
be written the run degrades to the offline estimate with a warning —
nothing is sent unrecorded. bin/gstack-context-bill is the bun shim.
Tests: fixture-tree ledgers, the three fixes, --diff/--budget exit
codes, --exact with injected fetch (envelope subtraction, receipt
ordering, fail-open degradation), CLI smoke test, and ground truth
against THIS repo via test/helpers/skill-census.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 675c19876b87ec927b555f5f64c7f93130b3de90)
* test(catalog): aggregate discovery-surface budget with ratchet protocol
Every host loads every skill's frontmatter name + description at
discovery, every session. applyCatalogTrim in scripts/gen-skill-docs.ts
shapes each description and the 160KB per-file warn covers body size,
but nothing capped the aggregate frontmatter — the catalog could grow
one reasonable-looking description at a time. This test is that
enforcement layer.
Measures the catalog via test/helpers/skill-census.ts authoredSkills
(symlink-deduped, root router counted separately as the _gstack-command
alias line item): 53 skills + router = 4,420 bytes = 1,105
token-equivalents today, asserted <= 1,150 (~4% headroom). Per-skill
sub-cap of 260 bytes (largest today: design-consultation at 229), plus
a non-empty-description check.
Failure messages are self-service ratchets: they print the new total,
the delta, and the update protocol (bump the constant AND the
derivation comment in the same commit; trim instead of grow for
existing descriptions). Parser handles folded block scalars
(description: >-) for fork parity; import-free by design so it
survives generator refactors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c106fb36f768181b80c257e5cff1cde4f435f9c0)
* fix(browse): extension token bootstrap moves to pinned-origin POST; /health carries no token
GET /health is now liveness/status only in every mode — both token
carve-outs (headed-mode disjunct AND chrome-extension:// Origin
disjunct) are removed. Token bootstrap is POST /extension-token on the
local listener: the Origin header must be exactly
chrome-extension://<GSTACK_EXTENSION_ID> and the Host header's hostname
must parse to 127.0.0.1 or localhost (parsed via new URL, never literal
equality — Host arrives as '127.0.0.1:34567'). Wrong origin/host → 403
with no detail. The tunnel surface 404s the endpoint (not in
TUNNEL_PATHS, verified by test).
The extension ID is pinned by a new "key" field (RSA public key) in
extension/manifest.json; browse/scripts/extension-id.ts reproduces the
ID derivation (first 16 bytes of SHA-256 of the DER public key, hex
mapped 0-9a-f → a-p). The private key is not committed anywhere —
unpacked/baked-in loads only need the public key.
Extension side: background.js bootstraps and refreshes the token via
POST /extension-token (403 → disconnected state); sidepanel.js direct
connect path does the same; sidepanel-terminal.js's dead /health token
fallback (read AUTH_TOKEN/authToken keys the server never sent,
hardcoded port) is replaced with the window.gstackAuthToken path.
MIGRATION NOTE: the manifest key pins the extension ID, so existing
installs' side-panel local state (saved port, snoozes) resets once —
explained in-product via a one-time notice (flag
gstack_id_migrated_v162). After upgrading the server, restart the
browser so the old service worker stops polling for a token GET /health
no longer serves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e9a0b6847a2d17fe6656a4686b4efd0c8380eb09)
* docs: correct stale compiled-binaries claim; file three egress/eval follow-ups
CLAUDE.md's compiled-binaries section claimed browse/dist binaries are
tracked by git and appear as modified in git status — false since
64d5a3e4 (v0.11.16.0) untracked them, and actively harmful: it trained
agents to ignore dist binaries in git status. The section now states
the truth (untracked + gitignored; a dist binary in git status means
someone force-added it) and covers make-pdf/dist too.
TODOS.md gains the three follow-ups filed by the v1.62 port-wave
reviews: ledger rotation with chain-genesis records, launch-nonce
token bootstrap, and eval-watch shard-awareness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: pre-landing review fixes for the v2 port wave
Review army (checklist + 5 specialists) + coverage/plan audits on the
assembled branch. Genuine correctness/security/hygiene fixes:
- test-paid-shards: strictTestExitCode now receives expectedFiles on the
real bun path, so a shard that runs fewer files than planned (harness
crash, nothing loaded) with exit 0 is no longer recorded 'passed' — the
invisible-non-execution class the runner exists to kill. Pinned by the
new test/strict-output.test.ts (also covers the chunk-boundary classifier).
- test-paid-shards: EVALS_TIER env is validated (gate|periodic) like the
--tier flag, so a typo can't self-skip every test and exit 0 green.
- package.json: test:periodic:sharded sets EVALS_ALL=1, restoring the
full-tier semantics the pre-shard script had (CI already set it; local
eval:bg:periodic silently under-measured without it).
- brain-sync.test: run() pins HOME to the temp home so gstack-artifacts-init
stops writing/clobbering the operator's real ~/.gstack-artifacts-remote.txt
every free-suite run; afterEach now also scrubs the current filename.
- egress-receipt: cap each receipt field at 512B so a serialized line always
fits the 4KB tail-read window — a longer line would make the next append
hash a truncated prior line and verifyLedger report a permanent false
TAMPER. warnLedgerSize short-circuits before statSync once fired (append
hot path).
- gstack-egress: import.meta.dir (Windows-safe) instead of new URL().pathname
so grants doesn't silently report defaults on Windows; strip control chars
from ledger-derived fields on render so a crafted receipt can't spoof the
auditor's view.
- extension/background.js + CLAUDE.md: renumber the identity-pin migration
refs v1.62 -> v1.63 (main claimed 1.62.0.0; this wave queue-advances).
- egress-receipt-wiring: pin lib/context-bill.ts unconditionally (both land
together now); drop the dead RunShardsOptions.tier field.
All fix-affected test files green; gate failures triaged as external-env
(codex/gemini CLI drift) or pre-existing (hermetic-canary fails identically
on base). Deferred polish tracked in the PR body + decision store.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version and changelog (v1.63.0.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: file TODO to harden plan-design-with-ui PTY detection
The v1.63 seedSkills change made this gate test execute for the first
time; it reliably times out because its terminal scraper can't parse the
(correctly-rendered) scope-gate AskUserQuestion out of a spinner-mangled
PTY buffer. Shipped skill behavior is correct — test-harness limitation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: re-slot release as v1.62.1.0 (PATCH per user)
Main claimed 1.62.0.0 while the wave was in flight; the user chose the
PATCH slot over queue-advancing MINOR. Renumbers the identity-pin
migration notice (now version-free flag name so a re-slot never orphans
an already-set flag), the CLAUDE.md /health note, the CHANGELOG heading,
and the TODOS section titles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): stop hard-requiring the literal ok) case label in gbrain-refresh guards
The extractor grepped for 'ok)' but the case label grew to
ok|timeout|thin-client) (#1964, #2051), so the whole file errored on
import — the free suite's only red for months. The extractor now matches
any label starting with ok and its alternations; all 7 guard assertions
run again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): hermetic-canary probes with ${VAR:-} so nounset shells can't fail success
The probe echoed bare $CONDUCTOR_WORKSPACE_PATH — when scrubbing WORKS
the var is unset, and under a nounset shell the echo errors, failing the
canary exactly when isolation succeeds. Defaulted expansions assert
identically under any shell. Fails identically on base; fixed here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): absorb codex/gemini CLI drift; external-service tests go periodic-tier
- codex exec gains --skip-git-repo-check: newer CLIs refuse exec in an
untrusted non-git dir (our temp skill dirs) — empirically verified.
- gemini: --skip-trust was removed in gemini-cli 0.34 (argv parse error);
dropped from the session runner and the benchmark adapter. A present-
but-unusable CLI (deprecated individual code-assist auth path) now
classifies as SKIP, not a false adapter failure; the benchmark live
smoke skips on auth/rate_limit error codes (environmental) while still
failing on timeout/unknown (the drift classes it exists to catch).
- codex-e2e, gemini-e2e, and benchmark-providers gain the canonical
whole-file EVALS_TIER === 'periodic' guard per CLAUDE.md tiering rule 3
(external service -> periodic) — the sharded gate runner now excludes
all three (gate: 45 -> 42 shards).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): parse single-logical-line AskUserQuestions in the PTY runner
When the PTY reflows a boxed AUQ, ALL options land on ONE logical line
after stripAnsi — parseNumberedOptions parsed one option per line, found
only '1.', and the >=2 check failed forever while the correct question
sat on screen (plan-design-with-ui timed out this way twice, with the
rendered scope-gate AUQ visible in both failure buffers). The cursor
line is now parsed as a stream of ascending N. tokens; DEC cursor-
visibility residue is stripped before matching; plan-design-with-ui's
budgets grow to fit observed ~6min preamble+thinking latency. Pinned by
test/pty-auq-single-line.test.ts using the real failure buffers; all 142
existing parser-consumer unit tests still green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: restore v1.63.0.0 (MINOR — user-confirmed final slot)
The wave ships new capability (egress receipts + two CLIs, sharded paid
runner, hermetic skill seeding) at ~8K lines — MINOR scale per the
scale-aware bump rules. Supersedes the brief v1.62.1.0 re-slot; the
version-free migration flag means no state churn from the renumber.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: spell out AskUserQuestion in the PTY single-line fixture
Rename test/pty-auq-single-line.test.ts to
test/pty-askuserquestion-single-line.test.ts and expand the AUQ
abbreviation in identifiers and comments. House style writes
AskUserQuestion in full in filenames, identifiers, and comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sync every doc surface with the v1.63 release
/document-release audit (4-lane, all claims verified against branch code):
- README: gstack-egress + gstack-context-bill rows in the standalone-binaries
table; Privacy & Telemetry gains the receipted-egress bullet (attempted-
egress framing per the shipped threat model).
- ARCHITECTURE: /health is liveness-only, POST /extension-token endpoint row
+ bootstrap mechanics paragraph; new Egress receipt ledger subsection under
Security model; eval persistence covers the sharded runner, GSTACK_EVAL_DIR,
and the finalized-run baseline rule.
- CLAUDE.md: sharded test scripts in Commands; sharded semantics in the
detached-evals section; PTY skill seeding in the hermetic section; egress
invariant block beside the other server-egress invariants; catalog-budget
ceiling beside the 160KB token ceiling; project-tree entries for
lib/egress-receipt.ts, lib/context-bill.ts, scripts/test-paid-shards.ts.
- CONTRIBUTING: seedSkills + live-tree seeding in the hermetic paragraph;
sharded runner in detached runs; catalog-budget in the Tier 1 list.
- BROWSER: extension token bootstrap section, tunnel egress receipts section,
identity-pin migration note in manual install.
- REMOTE_BROWSER_ACCESS: tunnel-start receipt bullet in the security model.
- gbrain docs: /sync-gbrain + brain-sync egress-receipt behavior documented;
dead consumer-token instructions removed (consumer machinery deleted this
release); new fail-closed refusal added to the error catalog.
- CHANGELOG: measured-vs-ceiling catalog numbers, contributor notes for the
external-service tier move and the PTY single-line AskUserQuestion parser,
release date.
- TODOS: /health token-distribution TODO resolved by this release, removed;
port-wave follow-up sections re-labeled to the shipped version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sweep drift that predates this release
Surfaced by the /document-release audit; every fix verified against the
current binaries:
- gstack-brain-init was replaced by gstack-artifacts-init in v1.27.0.0
(hard-delete, no compat shim), but README, USING_GBRAIN_WITH_GSTACK,
docs/gbrain-sync.md, and docs/gbrain-sync-errors.md still instructed
users to run it — command-not-found on every follow. Same sweep updates
~/.gstack-brain-remote.txt to the canonical ~/.gstack-artifacts-remote.txt
(legacy name still honored on restore, noted where users copy the file).
- gbrain-sync-errors.md headings re-matched to the literal messages the
binaries print today (the doc's whole value is grep-by-exact-message):
'gstack-artifacts-init: ~/.gstack/ is already a git repo pointing at:',
'Remote not reachable via SSH:', 'Failed to create or find ...'. The
already-a-repo fix now leads with the command's own set-url suggestion.
- docs/gbrain-sync.md 'Under the hood' linked a plan file that does not
exist in the repo; replaced with the decisions themselves.
- SIDEBAR_MESSAGE_FLOW startup timeline: /pty-session responds with
{terminalPort, sessionId, attachToken, leaseExpiresAt} (v1.44 shape,
verified at browse/src/server.ts:1860), not the retired
{terminalPort, ptySessionToken} pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fold the Codex accuracy review of the release docs
Six findings, all verified against source before fixing:
1. 'Every send writes a receipt' overclaimed — fail-open sinks proceed with
a stderr warning when the receipt write fails, so a fail-open send can go
unrecorded (lib/egress-receipt.ts:8-14). Descriptive prose now says so;
the receipted framing keeps 'attempted'.
2. 'Receipts hash the request body' is wrong for subprocess-owned sends —
git pushes record sha256: null (lib/egress-receipt.ts:71).
3. 'grants shows every consent in force' overclaimed — it reports the four
standing config settings (bin/gstack-egress:139-181). Reworded in
README, ARCHITECTURE, and the CHANGELOG entry.
4. 'Zero-exception scanner' vs reality: the new-sink scanner carries a
reasoned SCANNER_EXEMPT list (user-directed fetches, probes, instruction
strings, skill prose). CLAUDE.md now names it.
5. Error-catalog cause/fix for the receipt refusal: the writer mkdirs the
ledger dir itself, so 'missing' isn't a cause and bare chmod fails when
it is absent — cause reworded, fix is mkdir -p && chmod.
6. gbrain-sync first-run steps described the retired binary's behavior:
default repo is gstack-artifacts-$USER, and init PRINTS the gbrain
hookup command (never auto-executes; bin/gstack-artifacts-init:384-419).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
2401 lines
101 KiB
TypeScript
2401 lines
101 KiB
TypeScript
/**
|
||
* 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 * 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 {
|
||
const result = nodeSpawnSync(
|
||
'claude',
|
||
['-p', '--model', 'claude-haiku-4-5', '--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 1–3 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,
|
||
);
|
||
|
||
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();
|
||
}
|
||
}
|