Commit Graph
434 Commits
Author SHA1 Message Date
f4e84b4dc9 fix(browse): remove the dead security shield + unfed /health.security (re-derived from #2557)
The sidebar's SEC shield has been dead UI since the PTY terminal rewrite:
nothing set its data-status, nothing unhid it, and the /health.security
field behind it read getStatus() off ~/.gstack/security/session-state.json
— a file whose ONLY writer (sidebar-agent.ts) was deleted with the chat
path. /health therefore reported a permanent 'inactive', or a stale
FALSE-GREEN 'protected' wherever an old state file survived on disk (a
single unit-test run was enough to plant one). A green shield sourced from
leftover state reads as "no threats detected" when the real state is "not
measured" — the same fail-open class as #2026.

Removed (dead surfaces only): the shield markup/CSS and the stale
sidepanel.js comment; the /health security field and server.ts's getStatus
import; getStatus / SecurityStatus / StatusDetail / SessionState /
read+writeSessionState (and security.ts's dead child_process import); the
session-state + getStatus unit tests — including the round-trip test that
wrote real fixture data into ~/.gstack and left /health green forever.
(The PR's security-sidepanel-dom.test.ts deletion already happened on main
via #2230; its resolveDisconnectCause guard landed via the #2085 typeof
fix. Neither re-applied.)

Kept, per ENG-OV9 — security.ts has LIVE consumers: the pure combiner
(combineVerdict + THRESHOLDS), canary utilities, and extractDomain stay;
server.ts's /pty-inject-scan L4 path (isSidecarAvailable + scanWithSidecar)
is untouched. browse/test/server-security-surface.test.ts pins BOTH
directions: the dead surface stays dead (no /health security field, no
getStatus import, no reader of the security session-state file, shield
markup gone) and the live half stays live (sidecar wiring in server.ts,
combiner/canary exports in security.ts, /health carries no token — the
v1.63 regression wall). A future re-feed from LIVE signals must update
that test deliberately rather than resurrect the state-file path.

F13 (same commit): CLAUDE.md's Sidebar security stack section, ARCHITECTURE.md's
prompt-injection Visibility + critical-constraint paragraphs, and
BROWSER.md's security section now describe the removed surfaces as history,
not live features.

Net -166 lines. Tests: server-security-surface + security +
security-adversarial(+fixes) + security-integration + server-auth 114 pass;
sidepanel-* + extension-token + extension-sender-auth 58 pass / 2 skip.

Re-derived from PR #2557 by @frederik-kaster-noygear.

Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:08 -07:00
vaston-vijiandClaude Fable 5 af23375aa2 fix: raise gbrain version-probe timeout to 10s on Windows
On Windows the gbrain CLI is a .cmd shim that runs `bun run cli.ts`.
A cold spawn takes over the 2s timeout in resolveGbrainBin (warm runs
are ~700ms), so the probe times out, localEngineStatus classifies the
engine as "no-cli", and the 60s status cache then serves that false
negative to every skill preamble and sync run. /sync-gbrain skips the
memory stage with "gbrain CLI not on PATH" even though the CLI works.

Give the shim 10s of headroom, gated on NEEDS_SHELL_ON_WINDOWS so
POSIX keeps the cheap 2s probe. Applies to both resolveGbrainBin and
readGbrainVersion.

Observed on Windows 11, bun 1.3.14, gbrain 0.42.59.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:07 -07:00
c4e2233832 fix(browse): capture daemon stdout/stderr to browse-daemon.log + Windows polyfill spawn fixes (re-derived from #2461)
The detached daemon's stdout/stderr were wired to 'ignore' on every
platform, so every console.error('[browse] FATAL: ...') from a Chromium
crash, uncaughtException, or unhandledRejection was discarded at the OS
level — a crash-and-respawn looked identical to every other dropped
session, with nothing on disk recording why. Both spawn paths now redirect
to <stateDir>/browse-daemon.log (append mode, accumulates across respawns):
the Unix path via an fd from openDaemonLogSink(), the Windows path by
opening the fd INSIDE the node -e launcher string (an fd opened in cli.ts
would not cross the spawn boundary). Unwritable state dir falls back to
'ignore' rather than failing the launch.

Capturing daemon output is what surfaced the PR's second fix, still valid
on current main: bun-polyfill.cjs's Bun.spawn/spawnSync called Node's
child_process with a bare command name, which Windows can't resolve without
PATHEXT lookup ("spawn bun ENOENT" from the terminal-agent respawn path).
Routed through cross-spawn on win32 (now a direct dependency; already in
the tree transitively via @modelcontextprotocol/sdk) — the PR verified
empirically that shell:true does NOT neutralize cmd.exe metacharacters
reachable via `$B skill run` arg passthrough, and that Node refuses .cmd
spawns without a shell (CVE-2024-27980), so cross-spawn's combined PATHEXT
resolution + argument escaping is the only correct shape. The PR's third
fix (resolveDisconnectCause throwing "browser?.process is not a function")
already landed on main via the #2085 typeof guard — not re-applied.

F6 log hygiene (daemon-log-hygiene.test.ts): needle tests pin the log
wiring on both spawn paths (and that stdio 'ignore','ignore','ignore'
never returns), that bun-polyfill stays on cross-spawn with no shell:true,
that NO console.* call in src/ passes a token value (interpolated or bare
arg), and that the page-content carrier modules (tab-session, buffers,
content-security, activity) stay console-free — so neither AUTH_TOKEN nor
unsanitized page-derived strings can reach browse-daemon.log.

Tests: daemon-log-hygiene + bun-polyfill + windows-spawn-hide +
cli-setsid-daemonize 21 pass; stop-dead-daemon + busy-daemon-iron-rule
(exercises a REAL daemon boot through the new log-fd wiring) 10 pass.

Re-derived from PR #2461 by @phuttimatebenchanakatkul.

Co-authored-by: phuttimatebenchanakatkul <phuttimatebenchanakatkul@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:07 -07:00
Garry TanandClaude Fable 5 0d4d554e13 fix(browse): terminal-agent allocates from the fixed port scan range, not port:0 (#2314)
The terminal-agent bound `Bun.serve({ port: 0 })` and kept that OS-assigned
port for its whole (weeks-long) lifetime. `port: 0` draws from the OS
EPHEMERAL range (49152-65535 on macOS) — the exact pool every short-lived
`app.listen(0)` test server draws from — so the agent squatted ports that
test suites expected to receive and silently absorbed their traffic as
phantom 404s (two squatting daemons verified in the report).

Fix per decision 8: extract the main server's port allocation into
browse/src/port-allocator.ts (checkPortAvailable / isPortAvailable /
findAvailablePort + the 10000-60000 range constants and the actionable
sandbox-vs-occupied error formatters, all verbatim from server.ts) and make
BOTH long-lived listeners use it — server.ts's findPort is now a thin
findAvailablePort(BROWSE_PORT) wrapper, and terminal-agent's buildServer
takes a pre-allocated port from the same range. No terminal-port consumer
carries a range assumption (they read the port file), verified by grep.

Tests: terminal-agent-port-range (new — allocator stays inside
10000-60000 and below the 49152 ephemeral floor, explicit-port honored,
occupied-explicit throws, static tripwires pin no-port:0 in
terminal-agent.ts and the shared wrapper in server.ts) + findport +
terminal-agent-integration/session-routing/detach-reattach +
dual-listener: 67 pass, 0 fail.

Fixes #2314.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:07 -07:00
Garry TanandClaude Fable 5 908c5a6a69 fix(upgrade): /gstack-upgrade stops a stale daemon — deferring to a busy one (#2551)
A browse daemon started before an upgrade keeps serving the OLD binary's
code after `git reset --hard` + `./setup` — the running process holds the
old executable, so users on the "new" version kept getting pre-upgrade
behavior (and config-mismatch refusals against the new CLI) until they
happened to stop it by hand.

New unconditional Step 4.8 in gstack-upgrade/SKILL.md.tmpl (+ regen, same
commit): compare the running daemon's recorded binaryVersion (the
readVersionHash git-SHA the server stamps into its state file) against the
freshly built browse/dist/.version.

- Stale + responsive → `browse stop` (graceful), telling the user
  old→new hash; the next command boots a daemon on the new binary.
- Stale + BUSY → DEFER (decision 10): never kill a busy daemon during
  upgrade. Print the old→new hash and the escape hatch —
  `browse stop` when it finishes, or `browse --force-restart stop` now.
- Dead pid / matching hash / no state → silent no-op.

Tests: skill-validation + gen-skill-docs 731 pass after regen.

Fixes #2551.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:07 -07:00
Garry TanandClaude Fable 5 5a6dd6bf88 fix(browse): stop on a dead daemon is success — never boots a daemon to stop it (#2254)
Two changes, one contract:

- Pre-server short-circuit: `browse stop` is handled BEFORE ensureServer().
  No daemon state → "nothing to stop", exit 0. Stale state (dead pid AND
  dead port) → clean the state file, exit 0. The old flow routed stop
  through ensureServer(), which started a fresh daemon + Chromium
  (multi-second boot, resource churn) purely so it could be told to shut
  down — or crashed on the stale state.
- Reconnect branch: a connection error while sending `stop` where the pid
  turns out dead (daemon died mid-flight, between the short-circuit check
  and the send) is treated as SUCCESS — the desired end state (no daemon)
  already holds — instead of the crash-restart path.

Integration tests (stop-dead-daemon.test.ts, real spawned CLI + scratch
BROWSE_STATE_FILE): stop with no state exits 0 and spawns nothing (a
spawned daemon would have written the state file); stop with a stale state
file (dead pid + verified-closed port) exits 0, cleans the state, and
spawns nothing.

Tests: stop-dead-daemon 2 pass; busy-daemon-iron-rule 8 pass;
busy-daemon-recovery 1 pass (11/11 combined).

Fixes #2254.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:06 -07:00
Garry TanandClaude Fable 5 e2f70f1704 fix(browse): fail-fast busy-daemon semantics — never auto-kill an alive pid, add --force-restart (#2219)
The CLI killed live-but-busy daemons: a heavy dev-mode page (cold-compiling
Next.js route, timed-out navigation still churning) kept the daemon from
answering /health longer than the old ~1s probe window (3 × 250ms), so the
connection-error path declared it dead, SIGTERMed a healthy process, and
every kill lost the session's tabs, cookies, and logins (reproduced 4/4 in
the #2219 report).

New contract (decision 9 / F10):

- probeHealthWithBackoff is budget-based: ~8s total
  (HEALTH_PROBE_TOTAL_BUDGET_MS), 500ms intervals, each probe self-bounded
  at 2s — sized to the observed busy windows.
- decideDaemonRestart (pure, exported, unit-tested) encodes the IRON RULE:
  healthy-after-probe → retry the SAME daemon; alive+unhealthy →
  "daemon busy — retry or --force-restart" + NONZERO exit, daemon untouched;
  only a DEAD pid (or an explicit --force-restart) reaches kill+restart.
- --force-restart global flag (extractGlobalFlags): the one consent path
  that replaces a live daemon, always announcing the state it costs.
- Wired at all three kill sites: sendCommand's connection-error branch,
  ensureServer's stale-state path (which previously killServer'd any alive
  pid whose single 2s health probe missed), and connect — which used to
  "Kill ANY existing server" and now refuses to replace a healthy daemon
  without 'browse disconnect' or --force-restart. pair-agent's internal
  headed switch passes --force-restart explicitly (the mode switch is that
  command's stated purpose), preserving its behavior.

E5 IRON RULE regression tests (busy-daemon-iron-rule.test.ts, real spawned
CLI + fake daemons + live sleep-pid stand-ins per the
busy-daemon-recovery.test.ts pattern): healthy daemon SURVIVES connect
(refused with guidance, pid alive, state file untouched); wedged-alive
daemon + plain command → busy report, nonzero exit, pid alive; wedged
daemon + --force-restart IS killed and a real replacement daemon serves the
command. Plus pure-function coverage of all four decision outcomes and the
~8s budget pin.

Tests: busy-daemon-iron-rule 8 pass (16.7s, includes a real daemon
lifecycle); busy-daemon-recovery + proxy-config + daemon-mismatch-refuse +
cli-lock + cli-start-final-healthcheck + cli-setsid-daemonize 39 pass.

Fixes #2219.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:06 -07:00
Garry TanandClaude Fable 5 7686eb212d fix(browse): windowsHide sweep — flag every residual child_process site + full-census tripwire (#2160, #2415)
Add windowsHide:true at every remaining direct child_process call in
browse/src that could flash a console window on Windows:

- project-slug.ts (execSync gstack-slug)
- browser-skills.ts (cp.spawnSync git rev-parse)
- security-sidecar-client.ts (spawn — the LONG-LIVED Node sidecar, whose
  missing flag parked a console window on the taskbar for the daemon's
  whole lifetime)
- find-security-sidecar.ts (execFileSync node --version)
- meta-commands.ts (execSync git rev-parse in inbox + the osascript
  activate call)
- browse-client.ts (cp.spawnSync git rev-parse)
- file-permissions.ts (execFileSync whoami.exe — Windows-only, ran bare)
- cli.ts (nodeSpawn osascript)

windows-spawn-hide.test.ts gains a SWEEP test on top of the existing
needles: it censuses EVERY child_process binding in src/ (static imports
incl. aliases, `await import()` / require destructures, and `import * as
cp` namespaces — 15 call sites across 10 files today) and fails CI on any
call without windowsHide within its options window. Exemptions carry
reasons — the one today is domain-skill-commands' interactive $EDITOR
spawn (stdio:'inherit'; CREATE_NO_WINDOW would detach a console editor
into an invisible console).

Tests: windows-spawn-hide 5 pass; file-permissions 19 pass; browse-client
28 pass; browser-skill-commands 29 pass (81/81 combined).

Fixes the app-side half of #2160; closes out #2415's residuals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:06 -07:00
Garry TanandClaude Fable 5 7171f10364 fix(browse): isProcessAlive uses signal-0 on every platform — no more tasklist probe (#1952)
Replace the Windows tasklist shell-out in isProcessAlive with
process.kill(pid, 0), unifying all platforms on the POSIX idiom. Node maps
signal-0 to an OpenProcess existence check on Windows — and the Windows
daemon runs under Node (dist/server-node.mjs + bun-polyfill, the documented
oven-sh/bun#4253 fallback) — so the probe is portable.

Why the shell-out had to go, beyond the cosmetic conhost flash the watchdog
blinked into the foreground every 60s (#1952): a Bun.spawnSync that hits
its timeout still RETURNS with partial stdout, so the `.includes()` PID
match answered "dead" for LIVE processes under load — the false-negative
half of the #2414/#2295 leak chain. Signal 0 spawns nothing, cannot time
out, and is ~5 orders of magnitude faster (measurements in #2414). EPERM
still reports alive (process exists, we just can't signal it).

Layered on the post-#2414-absorb shape: test 3 in
process-liveness-windows.test.ts now asserts the probe is subprocess-free
on ANY platform (win32 exemption dropped), test 4's static tripwire loses
its error-handling.ts exemption (a `tasklist … PID eq` existence probe
anywhere in src/ now fails CI), and windows-spawn-hide.test.ts drops its
tasklist-in-error-handling needle (nothing spawns, which is stronger than
hiding the window).

Tests: process-liveness-windows + windows-spawn-hide + error-handling —
17 pass, 0 fail.

Fixes #1952.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:05 -07:00
b3a27173fe fix(browse): absorb #2414 residuals — EPERM-alive liveness + Windows-dead test tripwires (re-derived)
Re-derive of PR #2414 (SYKhayyat) onto current main. Most of the PR already
landed in earlier waves: the tick-derived RESPAWN_GUARD_WINDOW_MS, the
spawnTerminalAgent windowsHide flag, the process-liveness regression tests,
and the browse/test import.meta.path sweep are all on main. Two pieces
remained:

1. isProcessAlive EPERM semantics (error-handling.ts): on the signal-0 path,
   EPERM means the process EXISTS but we lack rights to signal it — that is
   ALIVE. Returning false made callers that validate liveness before killing
   (killAgentByRecord, the terminal-agent watchdog) skip the kill and respawn
   around a survivor — the self-reinforcing one-leak-per-tick chain from
   #2414/#2295. Matters for cross-user PID checks.

2. Six test/ files ADDED SINCE the PR reintroduced the exact Windows bug its
   second commit fixed: `new URL(import.meta.url).pathname` yields
   `/C:/Users/...` on Windows, so path.resolve prepends the cwd drive and
   every tripwire ENOENTs instead of asserting anything (egress-receipt,
   egress-lib, egress-receipt-wiring, gstack-egress-cli,
   pty-skill-seeding-wiring, skill-census). All six now use
   import.meta.path — Bun's absolute native path, identical arity.

The remaining #2414 piece — replacing the Windows tasklist probe with
signal-0 — lands as its own commit (#1952) on top of this shape.

Tests: the 6 touched test files 47 pass; process-liveness-windows +
error-handling 13 pass.

Re-derived from PR #2414 by @SYKhayyat. Fixes the residual of #2295.

Co-authored-by: SYKhayyat <shaulyoelkhayyat@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:05 -07:00
Garry TanandClaude Fable 5 25ccda996d fix(browse): daemon owns signal policy — handleSIG*:false at launch sites + SIGHUP shutdown (#2220)
Playwright's default handleSIGINT/handleSIGTERM/handleSIGHUP handlers close
Chromium the moment the DAEMON process receives a signal — which fights the
deliberate headless SIGTERM-ignore in server.ts (Claude Code's Bash sandbox
fires SIGTERM when the parent shell exits between tool invocations; the
daemon survives it by design, but Playwright's handler killed its browser
out from under it). All three flags are now false at all three launch sites
(headless launch, headed launchPersistentContext, handoff relaunch).

ENG-OV4: the daemon had NO process-level SIGHUP handler (only SIGINT and
the mode-aware SIGTERM handler), so flipping handleSIGHUP:false alone would
remove the ONLY Chromium cleanup on hangup. server.ts now routes SIGHUP to
activeShutdown — the same shutdown path SIGINT uses (closes Chromium,
releases ports, removes the state file).

Static tripwire (browse/test/launch-signal-flags.test.ts, house
grep-style): every chromium.launch/launchPersistentContext site must carry
the three flags (site count pinned at 3 so a NEW launch site trips it),
server.ts must keep the SIGHUP→activeShutdown route, and the deliberate
headless SIGTERM-ignore must still exist (the reason handleSIGTERM:false is
safe — pinned in the test's header comment).

Tests: launch-signal-flags 3 pass; browser-manager-unit 28 pass;
bridge-chromium-e2e real-launch smoke 3 pass.

Fixes #2220.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:04 -07:00
Garry TanandClaude Fable 5 822de7d0c3 fix(browse): XProtect launch-kill self-heal — classify, quarantine-clear, bounded reinstall (P0 #2554)
macOS XProtect definition updates can start SIGKILLing the exact Chromium
revision the lockfile pins (xprotectd killed revision 1208's headless shell
at spawn; the failure surfaced as a generic launch timeout). New
browse/src/xprotect-heal.ts heals it, once per process:

- Classifier (F9): positive signatures sourced from the #2554 report +
  Playwright's launch-error format (signal=SIGKILL process-exit lines, and
  launch timeout WITH a <launched> marker), negative-checked FIRST against
  missing executable, spawn EACCES/EPERM, Linux sandbox denials, and plain
  exitCode=1 crashes. darwin-gated.
- Heal (F4 one-shot, in-memory flag): clears com.apple.quarantine via
  `xattr -dr` on chromium* revision dirs in the Playwright cache ONLY —
  never a GSTACK_CHROMIUM_PATH bundle (probePoisonedChromiumBundle's scope
  contract, double-gated at the call sites via usesCustomExecutable).
- Reinstall (E1/ENG-OV3): `bunx playwright install --force chromium` run
  FROM THE GSTACK INSTALL ROOT — the root whose
  node_modules/playwright-core/browsers.json pins the SAME chromium
  revision our embedded playwright-core expects (a cwd-resolved bunx would
  fetch latest and heal to the wrong revision). Bounded at 120s with a
  process-GROUP SIGKILL on timeout; on any heal failure the caller gets the
  ORIGINAL launch error + manual `bunx playwright install chromium`
  guidance — the CLI never hangs.
- Verification (F9): post-install asserts the REGISTRY-derived executable
  path exists (the revision dir playwright-core 1.62.1 expects), not merely
  install exit 0.
- Logging (F11): every action emits one structured stderr line
  ([browse:xprotect-heal] JSON).

All three launch sites in browser-manager.ts (headless launch, headed
launchPersistentContext, handoff relaunch) route through
launchWithXProtectHeal with one post-heal retry. setup's
ensure_playwright_browser failure path gains the same quarantine-clear
(_clear_playwright_quarantine, Darwin-only, Playwright cache scope) before
its Chromium reinstall.

Tests: browse/test/xprotect-heal.test.ts — 33 pass (classifier both
polarities, one-shot guard incl. failed-heal consumption, custom-executable
scope, registry-revision expectation vs playwright-core browsers.json,
install-root revision matching, quarantine-clear scope, wrapper retry +
guidance surfacing). browser-manager unit/custom-chromium: 36 pass.
bridge-chromium-e2e real-launch smoke: 3 pass. setup-windows-fallback
ln-invariant: 9 pass. bash -n setup: clean.

Fixes #2554.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:04 -07:00
Garry TanandClaude Fable 5 2851535f3b deps(browse): bump playwright + playwright-core to 1.62.1 (P0 #2554 vehicle)
Split from dependabot #2582 per plan OV3: this commit bumps ONLY
playwright (^1.58.2 -> ^1.62.1, lock resolves playwright@1.62.1 +
playwright-core@1.62.1 exactly). puppeteer-core, @huggingface/transformers,
marked, and socks are deliberately NOT bumped here — they land separately
(73b) gated on the ONNX sidecar smoke.

Why: bun.lock pinned playwright(-core)@1.58.2, whose Chromium build
macOS XProtect now kills on launch — browse is dead on macOS (#2554).
1.62.1 ships Chromium 151.0.7922.34 (headless shell v1234), which
launches clean.

Verification: bunx playwright install chromium (Chrome Headless Shell
151.0.7922.34 downloaded), then the full browse suite from browse/:
2016 pass / 32 skip / 2 fail across 129 files (133.9s). Both fails are
playwright-independent: data-platform.test.ts "rejects paths in cwd"
expects <cwd>/package.json to exist (browse/ has none; passes from repo
root, the shard runner's cwd — 15/15), and stealth-webdriver.test.ts
passes standalone (15/15) — a 5s-timeout flake under full-suite parallel
load.

Fixes the vehicle half of #2554 (self-heal lands next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:03 -07:00
Garry TanandClaude Fable 5 3c04178590 docs(todos): file the v1.67 fix-wave deferrals + ZeroEntropy sunset deadline
The wave plan's "Cut from this wave" list becomes a durable next-wave queue:
Windows omnibus mining, AskUserQuestion numbering redesign, typecheck infra,
Chromium profile migration, triggers-frontmatter decision, release-tag
upgrade semantics, and the 15-PR feature triage queue. ZeroEntropy's Sept 4
2026 shutdown is filed P1 (calendar-driven — gbrain's default embedding
provider).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:15:56 -07:00
Garry TanandClaude Fable 5 ca8ee4bd7e fix(test): ratchet four skeleton-size caps for the wave's preamble growth
The #2499 project-scoped-MCP jq entry-resolution adds ~340 bytes to every
brain-sync preamble block, and the wave's doc additions push four skills
3-91 bytes past their v1.64/v1.65 parity caps. Re-measured per the ratchet
protocol: plan-ceo-review 92,531 → cap 93,000; document-release 56,571 →
57,000; design-consultation 70,003 → 70,500; cso 75,891 → 76,400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:09:27 -07:00
Garry TanandClaude Fable 5 45fd8e2e3d test: prove the rebased force-push shape is scanned correctly (#2573)
#2573: after `git rebase origin/main`, the feature branch's remote tip
still exists locally (the pre-rebase tip) but is no longer an ancestor of
HEAD, so the old `remoteSha..localSha` range swept in every upstream
commit rebased onto — 1.14 MiB scanned instead of 0.27 MiB on the
reported repo, tripping the engine's 1 MiB cap and blocking the push
with engine.input_too_large (a HIGH that meant "the engine never ran",
not a finding).

The catch-up-merge narrowing (`rev-list localSha --not remoteSha
--remotes`) covers this shape too: the upstream commits are reachable
from origin/main's remote-tracking ref, which exists by construction —
you cannot have rebased onto origin/main without it. No residual gap
found; this lands the proof alone, end-to-end through the actual hook
binary with the real pre-push stdin protocol:

- fixture sanity: the pre-rebase tip exists locally, is NOT an ancestor,
  and the OLD two-dot range would have swept in the upstream credential
- a clean rebased force-push passes — someone else's already-published
  HIGH-shaped fixture no longer blocks it
- coverage is not narrowed: a HIGH in a rebased commit of our own still
  blocks
- the scanned commit set is exactly the rebased own commits, so scan
  size is proportional to OUR work, not to how busy main was

Analyzed non-gap, recorded in the test header: upstream commits in NO
remote-tracking ref cannot arise from the standard flow — rebasing onto
origin/<branch> requires the tracking ref, and rebasing onto a purely
local branch means the "upstream" content was never published, so
scanning it is correct.

Fixes #2573

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:33 -07:00
4cc19e4712 fix(redact): parcel IDs are not phone numbers
A county tax-map parcel ID (APN) reads as a national-format phone number
to `pii.phone.e164` — the same collision class as the digit-only UUID
that `insideUuid` already guards. `12-3456789.000` matches, and so does
its normalized `123456789000`.

This is not a rare edge. Land, title and property-tax repos carry APNs
by the hundred; a single title branch pushed 2 MEDIUM findings, and the
same shape recurs in every fixture, mart and smoke in the domain. A
guardrail that cries wolf on the domain's primary identifier is one
people learn to wave through, which is how a real HIGH finding
eventually gets ignored.

The guard is deliberately narrow, in two tiers:

1. The DOTTED form is exempt on its own shape. No phone convention puts
   a dot before a trailing 3-4 digit group after a 4-8 digit middle.
   Hyphen-only variants (22-0001-000) are NOT shape-exempted — those
   genuinely are phone-shaped.

2. A DIGITS-ONLY span is phone-shaped in isolation, so it earns the
   exemption only by evidence: it must be the exact digit-normalization
   of a punctuated APN within the surrounding window. Fixtures and marts
   carry the pair; a real phone number has no such twin. This reads the
   document's own evidence instead of guessing from digits.

Verified against the unmodified engine over inputs spanning every rule
family (AWS, PEM, GitHub PAT, email, IP, credit card, SSN, timestamp,
UUID, nine phone formats): exactly one behavior changed, the APN pair.

The new test pins both directions and was proven red under mutation —
stubbing the guard to `return true` (the dangerous blanket-exemption
failure) fails 12 of 15; `return false` fails 3.

Absorbs PR #2591 by @Two-Six-Alpha-1115 (applied via git am -3; 96 tests
pass across test/redact-parcel-id-false-positive.test.ts +
test/redact-engine.test.ts, and the pattern-lint / CLI / prepush-hook /
autoredact suites stay green).

Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:33 -07:00
4e055ca202 fix(redact-prepush): don't re-scan commits a catch-up merge brought in
`remoteSha..localSha` is "everything new on this branch", which is not the
same as "everything new to the remote". Merge origin/main into a feature
branch and every commit main gained since that branch's last push becomes
an added line — content that is already published, already scanned, and
not this push's doing.

Two consequences, both observed:

  · FALSE HIGH FINDINGS. A placeholder connection string in a fixture
    someone else had already merged blocked an unrelated push as
    db.url_with_password, telling the operator to rotate a credential
    over a file they never touched. A guard that cries wolf on catch-up
    merges is one people learn to bypass reflexively — which is exactly
    how a real secret gets through.
  · OVERSIZED SCANS. The SCAN_CHUNK_BYTES comment already records a
    1,146,782-byte diff from "a feature branch catching up to a busy
    main" blowing the engine's 1 MiB cap. Same root cause, treated there
    as a size problem. Narrowing the range fixes the size too.

A two-dot range cannot express this: after merging main, neither the
remote tip nor the merge-base with main is an ancestor of the other, so
no single base excludes both.

The narrowed range is `rev-list localSha --not remoteSha --remotes`.
remoteSha STAYS the base — it is what git tells us the remote has, and is
authoritative in a way --remotes is not, since tracking refs can be
absent or stale. Using --remotes alone excludes nothing in a repo without
them, so every commit ever made reads as new. That is the same false
positive from the other direction, and it is what the existing test
"only NEW content is scanned (remote..local), not pre-existing" catches.

When excluding tracking refs changes nothing, this push has no catch-up
commits and the plain range already describes it exactly — so we defer to
it. That keeps every non-catch-up push on the original gitStrict diff
path, which is what #1946's fail-closed regression test exercises. A
narrowing that silently retired that test would be a worse trade than the
false positives it set out to fix.

Each commit is diffed alone. A merge's combined diff shows only content
present in no parent, so a secret introduced while resolving a conflict
is still caught while an ordinary merge contributes nothing.

Tests: 22/22 existing prepush tests still pass (two of them fail without
the remoteSha base and the defer-to-plain-range guard respectively —
verified by mutation). 5 new tests build real repositories on disk and
pin both directions: a catch-up merge no longer re-scans published
content, and secrets in new commits, in merge resolutions, and in
repos with no remote are all still scanned.

Absorbs PR #2592 by @Two-Six-Alpha-1115 (applied via git am -3; 5 new
tests pass in test/redact-prepush-scan-range.test.ts). Also narrows the
range for the rebased-force-push shape reported in #2573 — proven by the
follow-up regression test.

Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:33 -07:00
Garry TanandClaude Fable 5 73cf0ed69a fix: diff-scope glob coverage, honest exit contract, dirty-tree visibility (#2526, #2455, #2299)
Three silent-skip classes in bin/gstack-diff-scope, each of which quietly
disabled scope-gated reviewers in /ship and /review:

1. Pattern gaps (#2526, #2455). `*/api/*` required a path segment BEFORE
   api/, so a root-level api/ layout (Vercel serverless, Next.js pages/api
   at root) never set SCOPE_API — 63 serverless functions in the
   reporter's payments repo, none ever classified, the API-contract
   specialist silently skipped on every payment PR (it found a CRITICAL
   when run by hand). Same for root-level migrations/. And the Rails
   data_migrate gem's db/data/ data migrations — arbitrary Ruby run
   unattended against production data — fell through to plain BACKEND, so
   the [NEVER_GATE] data-migration specialist never got the chance to
   run. Added: api/*, migrations/*, db/data/*, data_migrations/*.

2. All-false was indistinguishable from "could not look" (#2526). New
   contract: empty change set → all false exit 0; >=1 match → flags
   exit 0; changed files with ZERO matches → SCOPE_ERROR=unmatched + the
   unmatched paths as comment lines + exit 2 (a new top-level layout now
   trips loudly instead of invisibly disabling reviewers); unresolvable
   base ref (shallow CI checkout) → SCOPE_ERROR=no_base + exit 2 instead
   of a green that means "we could not look". Every output line stays a
   shell-safe assignment or comment for sourcing consumers, which
   tolerate the nonzero exit today (source ... || true / eval).

3. Uncommitted work was invisible (#2299). /ship detects scope in Step 9,
   BEFORE it commits in Step 15, so the common start-work-then-ship flow
   ran the classifier against an empty diff and skipped every reviewer.
   The change set is now the UNION of committed diff + working tree +
   untracked files. Also from #2299: the single first-match-wins case
   made the nine flags mutually exclusive (Button.test.jsx set FRONTEND
   but not TESTS; util.test.ts the opposite) — each category now gets its
   own case, with BACKEND deliberately still excluding frontend
   component/view files. And file listing is NUL-safe (git diff -z), so
   non-ASCII paths no longer defeat extension globs via octal quoting.

Deliberate behavior change (flagged in #2299): with independent flags, a
backend test file sets BACKEND and TESTS, which can trip the security
specialist's SCOPE_BACKEND gate on test-only PRs — errs toward more
review, not less.

Table-driven tests cover every glob class (root api/, nested api/,
controllers, openapi, root/nested/prisma/db-migrate/db-data migrations,
dual-category test files, auth, prompts, docs, plain classes), the
four-state exit contract, dirty-tree + untracked visibility, and the
non-ASCII path case (39 pass in test/diff-scope.test.ts).

Fixes shaped by the reporters' patches: @grant-ship-it (#2526),
@mkyed (#2455), @ShahriarLak (#2299).

Fixes #2526
Fixes #2455
Fixes #2299

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:33 -07:00
Garry TanandClaude Fable 5 909a9e9577 fix: version-bump honors the .gstack/version-path pin in versionRel (#2462)
cmdClassify's current-version read already resolved the
.gstack/version-path pin, but versionRel — the repo-relative path fed to
`git show origin/<base>:<path>` — was derived from the CLI flag alone
(`argVal(args, "--version-path") ?? "VERSION"`). In a pinned repo with no
explicit flag, base and current therefore read DIFFERENT files: current
from the pinned file, base from the root VERSION. On a repo with no root
VERSION, the base always read 0.0.0.0 — and the pinned-JSON handling
never engaged, so a pinned package.json was read as raw text
(currentVersion 0.0.0.0) and `write` would have overwritten the manifest
with a bare version string.

New resolveVersionRel() resolves the pin's REPO-RELATIVE form once
(flag → .gstack/version-path first line → "VERSION"); classify, write,
and repair all derive both the relative and absolute paths from it, so
base and current reads can no longer diverge. The old resolveVersionPath
(which returned an absolute path `git show` cannot use) is folded in.

Unit tests (the ENG-OV6 spec case plus write/repair coverage): pin set +
no flag → classify reads base AND current from the SAME pinned file
(plain-text sub/VERSION and pinned frontend/package.json, both against a
real git base with NO root VERSION anywhere), write updates the pinned
manifest in place without inventing a root VERSION, repair treats the
pinned JSON as single-source, and the explicit flag still overrides the
pin (38 pass in test/gstack-version-bump.test.ts).

Re-spec'd per ENG-OV6 from the report in #2462 (the originally-filed
classify-read hypothesis was already handled; the live bug was the :138
versionRel derivation). Same fix shape independently identified in
PR #2501 by @YiftahR.

Fixes #2462

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:32 -07:00
da0e28e686 feat: git-based version allocator when the PR queue is unreachable (#2545)
When the host query (gh/glab) failed, gstack-next-version returned
offline:true with an EMPTY claim set, and /ship's documented fallback was
local BUMP_LEVEL arithmetic. Local arithmetic cannot see a sibling's
claim, so the fallback allocated a version another open PR already held —
observed in a downstream repo where two merged PRs both read v0.1.57.0
(and an audit found four such duplicate pairs over three weeks).

New fetchGitClaimed() degrades the QUEUE VIEW without degrading the
ALLOCATION: git already knows what the API was asked for. It reads every
remote-tracking branch's pinned version file (through extractVersion, so
JSON version-paths resolve on remote refs too and each branch's own digit
width is preserved) plus the versions already shipped in the base's last
400 commit subjects (3- or 4-digit; the cap announces itself in warnings
when it truncates). The fallback runs only when the host told us nothing
— the online path is untouched — and the output gains a load-bearing
`fallback: "git" | null` field that /ship can branch on, plus explicit
warnings for both the recovered-from-git and the nothing-found cases.

Tests: end-to-end stub-gh offline contract (fallback:'git' + a valid
version + the warning), sibling-claim discovery from remote-tracking
refs, the pick advancing past the sibling's claim, shipped-subject
scanning, JSON version-path claims on remote refs, and non-repo
degradation to a warning (45 pass in test/gstack-next-version.test.ts).

Re-derived from PR #2545 by @CarringtonCreative under the wave plan's
version-tooling end-state spec; the PR's own VERSION/CHANGELOG stamping
is stripped (release stamping happens at /ship time, not per commit).

Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:32 -07:00
7b5fdab8cb feat: subdirectory manifests + npm-valid version mirror (#2531)
Two gaps in gstack-version-bump's manifest handling, resolved to the wave
plan's version-tooling end-state spec (decision 11):

1. Subdirectory manifests. A repo whose only Node package lives in web/,
   app/, or frontend/ has no ROOT package.json, so join(cwd,
   "package.json") reported pkgExists:false and every bump silently wrote
   VERSION alone — leaving the manifest to be bumped by hand, which is
   exactly the drift this tool exists to prevent, in the one layout where
   it silently did nothing. All three subcommands now resolve the
   manifest as --package-json-path → .gstack/package-json-path →
   ./package.json (mirroring resolveVersionPath).

2. npm-valid mirror. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm's
   semver is 3-component and rejects a fourth, so mirroring the raw form
   breaks `npm ci` in any repo npm actually manages. The manifest and its
   lockfiles now carry the npm-valid 3-digit translation (1.67.0.0 →
   1.67.0) via npmVersion() in lib/version-source.ts. VERSION stays the
   4-digit source of truth. classify judges drift against the TRANSLATED
   form — a correctly-synced `0.1.25` no longer reads as eternal drift
   against `0.1.25.0` — and grandfathers the pre-v1.67 1:1 four-digit
   mirror as in-sync (flagging it DRIFT_UNEXPECTED would hard-stop /ship
   on every existing repo on upgrade day; the next write migrates the
   manifest to the translated form). Lockfiles are synced beside the
   resolved manifest — including beside a pinned JSON version-path — and
   only when they already exist.

classify output gains pkgPath and expectedPkgVersion for observability;
write/repair report packageJsonPath + packageJsonVersion. The /ship Step
12 prose (ship/SKILL.md.tmpl) documents the resolution chain and the
translation; SKILL.md files regenerated and ship golden fixtures
refreshed in this commit.

Tests: subdirectory pin + --package-json-path override, translated-form
classify (FRESH/ALREADY_BUMPED, no false drift), grandfathered 1:1
mirror, genuine divergence still drifts, repair to the npm-valid form
(33 pass in test/gstack-version-bump.test.ts; 526 pass across the five
affected files including goldens and parity).

Re-derived from PR #2531 by @CarringtonCreative on top of the 3-digit/
JSON version-source work, under decision 11 (which resolves the PR's
lockfile-gated translation in favor of an unconditional npm-valid
mirror).

Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:32 -07:00
c33b371f25 fix: write/repair sync npm lockfiles' version fields (#2567)
npm records the package version twice in its lockfiles — top-level
`version` and, in lockfileVersion >= 2, `packages[""].version` (the entry
describing the root package itself) — and `npm install` keeps both in
step. gstack-version-bump write/repair updated VERSION + package.json but
left the lockfile behind, so every /ship bump in an npm repo drifted one
field per release until someone ran npm, dirtying the tree on the next
`npm install` far from the cause.

write and repair now mirror the version into package-lock.json AND
npm-shrinkwrap.json (which shares the format and, when present, is what
npm actually honors) as a pure JSON edit — no npm spawn, no
dependency-tree churn, dependency entries untouched. Per the wave plan's
version-tooling end-state spec (decision 11): synced ONLY when the file
already exists, never created (gstack itself is bun-only). A failed
manifest/lockfile write keeps the existing exit-3 half-write semantics so
classify reports DRIFT_STALE_PKG on re-run instead of hiding the drift.

Tests: 5 new cases in test/gstack-version-bump.test.ts — both lockfile
version fields synced with deps untouched, repair heals a stale lockfile,
lockfileVersion 1 (no packages map) doesn't crash, npm-shrinkwrap.json
synced without inventing a package-lock.json, malformed lockfile exits 3
loudly (26 pass total in the file).

Re-derived from PR #2568 by @ortonom under decision 11.

Fixes #2567

Co-authored-by: ortonom <3261546+ortonom@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:32 -07:00
d7ab20ac06 feat: accept 3-digit semver + package.json version sources (#2501)
Two version-source shapes failed CLOSED in a way that silently disabled
/ship's queue-collision check:

1. A --version-path / .gstack/version-path target that is a package.json
   was read as raw text: the whitespace strip turned the JSON into
   '{"name":"frontend",... which parseVersion rejected, so every read —
   local, `git show`, and rival PRs' claims through the GitHub/GitLab
   Contents APIs — fell back to 0.0.0.0 and competing claims were dropped
   as "malformed".
2. parseVersion required exactly four components, so gstack-next-version
   exited 2 on EVERY invocation in a 3-digit repo. That CLI IS the
   queue-collision check; /ship then took its documented offline path of
   naive local arithmetic, two branches cut from the same base picked the
   same version, and git merged the duplicate without a conflict.

New lib/version-source.ts holds the shared semantics so both CLIs agree
by construction: parseVersion accepts 3- or 4-digit (3 pads the micro
slot for uniform comparison), versionWidth/fmtVersion keep a 3-digit repo
3-digit through bumping and formatting, micro coerces to patch on 3-digit
repos (with a warning in the output), and extractVersion reads a .json
version-path as JSON (.version) from any byte source. gstack-version-bump
treats a package.json version-path as that repo's single source of truth
(written in place, DRIFT_* states can't arise — no second file to drift
from). Detection is by shape, not new configuration.

Scope per the wave plan's version-tooling end-state spec (decision 11,
ENG-OV1): this is the READING capability + 3-digit acceptance ONLY.
gstack's own VERSION file stays the 4-digit source of truth; nothing here
flips authority to package.json. The PR's bundled fix for the
.gstack/version-path pin being ignored by classify's base read lands
separately (#2462) — these tests drive the JSON version-path through the
explicit --version-path flag.

Re-derived from PR #2501 by @YiftahR (73 tests pass across
test/gstack-version-bump.test.ts, test/gstack-next-version.test.ts,
test/ship-version-sync.test.ts).

Fixes #2501

Co-authored-by: YR <work.yiftah.rottem@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:31 -07:00
c7faef885b fix: gstack-gbrain-install --dry-run no longer requires the network (#2540)
The GitHub reachability probe (curl --head, 10s max) was gated only on
--validate-only, so a --dry-run — which prints a plan and exits without
ever cloning — could fail with exit 3 "cannot reach https://github.com"
whenever the curl lost a race for sockets/DNS. Reproducible at ~15% by
running 60 dry-runs concurrently, and the cause of intermittent red in
the D5 detect-first tests, which call this exact path.

The probe now also skips under --dry-run: requiring the network for a
plan-print buys nothing and costs a real failure mode. Real installs
still fail fast when offline rather than hanging git clone.

Absorbs PR #2540 by @CarringtonCreative (applied via git am -3;
26 tests pass across test/gbrain-detect-install.test.ts +
test/egress-receipt-wiring.test.ts).

Fixes the offline/flake half of #2536.

Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:31 -07:00
bfa579d4ea fix: /sync-gbrain respects an existing valid .gbrain-source pin (#2417)
/sync-gbrain always derived a new worktree-scoped source ID, even when
the repository already carried a valid .gbrain-source pin created through
the native GBrain source workflow — silently bypassing the selected
source boundary, registering a duplicate federated source, and routing
later dream/cycle checks to the wrong source.

Now a local pin is reused when it passes the fail-closed identity checks:
the ID is syntactically valid, the source is registered, and the
registered path realpath-resolves to the current checkout (so a stale or
copied dotfile can't redirect a sync into another repo's source). A
confirmed pin is treated as user-managed — synced and attached without
add/remove, legacy migration, or federation changes. Dry-run stays
spawn-free (reads only the local marker for previews). Missing, invalid,
stale, or unreadable pins fall back to the existing generated source ID.

Absorbs PR #2417 by @exGeni (applied via git am -3; 42 tests pass in
test/gstack-gbrain-sync.test.ts including the new pin-respecting
coverage: spawn-free dry-run, symlink-equivalent registered paths,
non-dry-run sync/attach with no add/remove, dream routing, unreadable
markers, config-backed env use).

Co-authored-by: Evgenii Lopatin <e75533@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:31 -07:00
Garry TanandClaude Fable 5 acc354fcfa fix: read project-scoped MCP registrations in gbrain detection (#2499)
Claude Code registers MCP servers at two scopes in ~/.claude.json: user
scope (.mcpServers) and project scope (.projects["/abs/path"].mcpServers
— what `claude mcp add` WITHOUT --scope user writes). Every gbrain
detection site read only user scope, so a correctly configured
project-scoped brain was invisible: brain-aware blocks suppressed,
remote-mode artifacts sync never recognised, and detectEndpointHash fell
through to the 'local' literal — two different project-scoped brains
hashed identically, so switching between them never invalidated the
cache, the exact scenario the function's docstring says it exists to
catch. Nothing errored; the features just quietly were not there.

Two sites fixed:

- scripts/resolvers/preamble/generate-brain-sync-block.ts: the shared
  detection block (rendered into every tier-2+ SKILL.md) now resolves the
  gbrain entry ONCE into _GBRAIN_MCP_ENTRY — user scope first, then the
  nearest-ancestor project entry for $PWD that actually carries a gbrain
  server (longest matching key with a path-boundary check: /a/repo never
  matches /a/repo2; a nested project WITHOUT gbrain doesn't shadow its
  parent's registration). _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from
  the resolved entry, so claude.json is parsed once per skill start. All
  SKILL.md files regenerated in this commit; the ship golden fixtures and
  three carve-guard skeleton caps (plan-eng-review, plan-devex-review,
  office-hours; ~1.5KB rendered growth per skill) are refreshed with
  measured values.
- bin/gstack-brain-cache detectEndpointHash: same resolution order in TS
  (user scope, else nearest-ancestor project entry by cwd, both path
  separators for Windows keys).

Tests: rendered-output tests in test/gen-skill-docs.test.ts pin the
regenerated block (static markers + a FUNCTIONAL run of the exact
rendered lines against a fixture ~/.claude.json with only a
project-scoped registration, plus an outside-cwd discriminator);
detectEndpointHash unit tests in test/brain-cache-roundtrip.test.ts cover
project-scope resolve, path-boundary, nearest-ancestor distinct hashes,
and user-scope precedence.

Root-cause analysis by @samporter-31 in #2499.

Fixes #2499

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:31 -07:00
Garry TanandClaude Fable 5 5854d122d3 fix: resolve GBRAIN_HOME with gbrain's parent-dir semantics (#2521)
gstack treated GBRAIN_HOME as the config directory; gbrain's configDir()
treats it as the PARENT and always appends `.gbrain` itself (the contract
is explicit in gbrain's source: GBRAIN_HOME=/tmp/x → /tmp/x/.gbrain/
config.json). With GBRAIN_HOME set, gstack classified engine status from
a file gbrain never reads — the probe's two halves (file checks vs the
spawned `gbrain sources list`) looked at DIFFERENT installs, so any
resulting status was arbitrary: missing-config/broken-config against
healthy installs, or a thin-client marker gstack saw that gbrain itself
reported as "No brain configured".

New shared resolver `gbrainConfigDir()` in lib/gbrain-exec.ts is the
single source of truth. All seven gstack sites route through the contract:

- lib/gbrain-local-status.ts gbrainConfigPath (the classifier's file half)
- bin/gstack-gbrain-detect GBRAIN_CONFIG + readRemoteMcpUrl
- lib/gbrain-exec.ts buildGbrainEnv (the probe's DATABASE_URL seed —
  fixing only the classifier would have left the split-brain in the
  spawn half, flagged by the reporter)
- lib/gbrain-guards.ts gbrainHome (clones-dir + autopilot-lock paths)
- lib/gstack-memory-helpers.ts gbrainConfigPath (engine-tier fallback)
- bin/gstack-gbrain-install pre-doctor config check (shell)

Unit tests cover GBRAIN_HOME set (config found at $GBRAIN_HOME/.gbrain),
the old flat layout explicitly NOT read (both classifier and
buildGbrainEnv), and unset (~/.gbrain unchanged). Existing fixtures that
encoded the deviant flat layout are updated to gbrain's contract.

Root-cause analysis by @d-danielsun in #2521.

Deviation from the 3-site plan spec: the same deviant resolution existed
in four more sites (buildGbrainEnv, gbrain-guards, memory-helpers,
gbrain-install); fixing only three would have left gstack disagreeing
with itself as well as with gbrain, so the whole class moved to the
shared resolver in one change.

Fixes #2521

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:01:44 -07:00
Garry TanandClaude Fable 5 ce4a7bbb7e fix: detect bearer-token thin clients via host MCP registration (#2520)
The #2051 thin-client fix keys detection on the remote_mcp marker in
~/.gbrain/config.json — but that marker is only written by the OAuth path
(gbrain init --mcp-only). Bearer-token installs (gbrain connect <url>
--token, gbrain's own recommended default for local/personal use) never
touch config.json, so they fell through to the local probe, failed against
the dead-or-absent local engine, and landed on missing-config / broken-db /
broken-config / engine-locked — silently suppressing brain blocks for a
fully-working remote brain.

New evidence source: hasRemoteOnlyGbrainMcp() reads ~/.claude.json MCP
registrations (user scope AND project scope) with the same classification
rules as gstack-gbrain-detect's tier-3 fallback. File-read only — no
subprocess, no network (a classifier network probe is the #1964 pathology).
Wired at two sites in freshClassify:

- missing-config branch: a bearer thin client may never have run a local
  init; if the host's only gbrain registration is remote-HTTP, that
  registration IS the brain → thin-client.
- post-probe-failure demotion: broken-db / broken-config / engine-locked
  reclassify to thin-client when the only gbrain registration is remote.
  A local-stdio sibling registration blocks the demotion (federation
  guard: a user running a local engine plus a remote team brain keeps
  precise local statuses). "timeout" is excluded — already usable, and
  may be a genuinely healthy slow local engine.

7 new unit tests in test/gbrain-local-status.test.ts: user-scope, project-
scope, engine-locked/broken-db demotion, federation guard, no-registration
discriminator, end-to-end --is-ok gate (35 pass total in the file).

Root-cause analysis by @d-danielsun in #2520.

Fixes #2520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:01:44 -07:00
f9f3c9801a fix: whitelist engine-locked at all three gbrain-usable gates (#2456)
#2194 taught the classifier to report a PGLite lock held by a live
\`gbrain serve\` as engine-locked instead of broken-config, but none of the
three "is gbrain usable?" gates accepted the new status — so the symptom
moved from a wrong error to a quieter wrong suppression: gbrain-refresh
stripped GBRAIN_CONTEXT_LOAD / GBRAIN_SAVE_RESULTS blocks out of every
generated SKILL.md after every upgrade, on the RECOMMENDED /setup-gbrain
default (PGLite + local-stdio MCP spawns gbrain serve at session start).

engine-locked is the same class as timeout (#1964): the engine is
installed and healthy, a legitimate holder has the lock. All three gates
now agree:

- bin/gstack-gbrain-detect --is-ok exits 0 on engine-locked
- bin/gstack-config gbrain-refresh case arm renders instead of suppressing
- scripts/gen-skill-docs.ts --respect-detection treats it as detected

Test mirrors the existing timeout case in
test/gbrain-detection-override.test.ts (engine-locked renders brain
blocks; the sibling no-cli case still proves suppression works).

Applies the reporter's patch + test from the issue.

Fixes #2456

Co-authored-by: Mateus Moraes <mmoraes@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:01:44 -07:00
Garry TanandClaude Fable 5 5b65f91a8c fix(test): update four main-side assertions to the T3 installer contracts
Integration drift from the T3 lane: three static assertions pinned the OLD
implementation shapes that T3 legitimately replaced — the gbrain-refresh
branch no longer self-documents a reset --hard cycle (#2569 renders to an
untracked out-dir instead; the test now pins THAT), setup's regen block
renamed to the render form (re-anchored, same exit-code-propagation
invariant), and sections/ linking generalized into _link_skill_runtime_assets
(the _link_or_copy routing assertion moved into the helper). Fourth: the
uninstall neutral-target test asserted against os.tmpdir(), which reads
$TMPDIR at call time — a shard neighbor can leave it gstack-containing,
making the "neutral" symlink target match the provenance substring; the test
now falls back to a fixed neutral root and asserts neutrality explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:01:27 -07:00
d889453686 fix(setup-gbrain): invoke gstack-memory-ingest/gstack-gbrain-sync via bun run + .ts
/setup-gbrain's transcript-ingest steps told the agent to run
bin/gstack-memory-ingest and bin/gstack-gbrain-sync by BARE name. Neither
exists — only the .ts files ship (mode 644, no bin alias) — so the agent
dutifully reported 'script missing at install root' and the ingest/full-
sync steps dead-ended on every host (hit live under Codex; the Claude
render carries the same text).

All four template sites (probe, silent-bulk, post-answer full sync, the
preamble-hook incremental mention) and the four memory.md reference-doc
sites now use the repo's established form: 'bun run <path>/gstack-memory-
ingest.ts …' / 'bun run <path>/gstack-gbrain-sync.ts …' — matching what
sync-gbrain already does. Generated SKILL.md regenerated from the template
in the same commit.

Re-derived from PR #2409 by @SomSamantray per the wave's screening rule
(the PR edited the generated SKILL.md directly; the generated file must
come from gen:skill-docs). The contributor's structural test rides along
as-is: bare-invocation regexes with negative .ts lookahead and backslash-
continuation coverage pin every site, so the drift can't return. The
referenced-paths ratchet in test/setup-claude-skill-assets.test.ts drops
its two #2250 known-broken entries — the class-2 assertion now guards
these paths again.

Verified against #2250's site list (template lines 690/735/784-area, all
covered) plus a fresh grep: zero bare invocations remain in the template
or memory.md; the one prose mention ('gstack-memory-ingest now persists…')
is not an invocation and stays.

Fixes #2250
Fixes #2393

Co-authored-by: SomSamantray <SomSamantray@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:32 -07:00
e20c29f5c2 fix(ios-qa): bridges search front-most presented content first
A presented sheet sits AFTER the screen it covers in window.subviews, so
the elements walk emitted the covered screen first — a client taking the
first match for a label activated a control the user cannot reach, and
the agent saw a success (measured on a real app: the sheet's 'Create'
button ranked 210th behind 35+ covered-screen entries). Menus, alerts and
action sheets were worse: each gets its OWN UIWindow, so keying off
isKeyWindow missed them entirely — absent from /elements, dropped from
/screenshot, untappable via /tap.

Re-derived from PR #2397 by @IDSTUK onto the current bridge templates
(the SwiftUI tap-reliability rework had moved underneath the PR):
ScreenshotBridgeImpl gains orderedWindows(in:) (visible windows front-most
first by windowLevel then insertion order, PassThroughWindow overlays
still filtered), frontmostWindow(), and searchRoots() (per window, the
top-most presented view controller's view before the window itself).
/elements walks those roots in order through the existing shared
visited-set + budget, so overlapping roots emit each view once at its
front-most position; /tap targets frontmostWindow() for both the
accessibility-activation and synthesized-touch paths; /type and /swipe
search the roots in order; /screenshot composites every window
back-to-front at the existing 1x scale. The two now-dead private
activeScene/activeKeyWindow copies in ElementsBridgeImpl and
MutationBridgeImpl are removed.

Fixture mirror synced byte-for-byte; verified with a full
'xcodebuild build -scheme FixtureApp-Package -destination
generic/platform=iOS Simulator' (BUILD SUCCEEDED, DEBUG guard from the
previous commit included).

Co-authored-by: IDST UK <IDSTUK@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:32 -07:00
Sebastian TottéandClaude Opus 5 ecfd9af9d6 ios-qa: guard DebugBridgeTouch.m on DEBUG, not just TARGET_OS_IOS
DebugBridgeTouch.m and its header both promise the code is DEBUG-only and
never shipped:

    "Uses these private UIKit selectors (DEBUG-only; never shipped to App Store)"
    "DEBUG-only — never link in Release."

Nothing enforced it. The only guard was `#if TARGET_OS_IOS`, so a Release build
for iOS compiled the entire implementation in, private API and all.

Measured on a real app (an iOS Release build, `nm -j` on the app binary):

    DebugBridge symbols            15
    IOHIDEventCreateDigitizer       2
    AXSSetAutomationEnabled         1 symbol, 2 strings
    IOKit.framework                 4 strings

including +[DebugBridgeTouch sendTapAtPoint:inWindow:] and
_OBJC_CLASS_$_DebugBridgeTouch. That is a Guideline 2.5.1 private-API exposure
in a shippable binary, and it fails Package.swift's own stated CI invariant:

    nm -j build/Release/<binary> | grep -q DebugBridge && exit 1

WHY THE EXISTING GUARD DOES NOT COVER THIS

Package.swift documents the protection as `.when(configuration: .debug)` on the
consuming target's dependency. That works for SwiftPM consumers. It cannot be
expressed by an app that integrates DebugBridge as a local package inside an
.xcodeproj: Xcode's Filters column under Frameworks, Libraries, and Embedded
Content offers platform conditions only — iOS, macOS, visionOS — never build
configuration. So for xcodeproj consumers the documented guard silently does
nothing, which is precisely the case that was measured.

The Swift targets were already safe: all four .swift files are `#if DEBUG`
guarded and Package.swift defines DEBUG for them via swiftSettings. Only the
Objective-C target, the one that actually links private API, was unguarded.

THE FIX

1. DebugBridgeTouch.m.template now branches `#if !defined(DEBUG)` first and
   emits nothing at all in Release, falling through to the existing iOS and
   non-iOS branches only in Debug.

2. Package.swift.template declares DEBUG explicitly for the ObjC target:

       cSettings: [.define("DEBUG", .when(configuration: .debug))]

   The two Swift targets already did this. Relying on SwiftPM's implicit DEBUG
   for C-family targets is not worth betting a private-API exposure on.

VERIFIED, by compiling the generated file for iOS both ways:

    xcrun -sdk iphoneos clang -c DebugBridgeTouch.m -arch arm64 ...

    Release (no -DDEBUG)   0 DebugBridge symbols, 0 private-API symbols,   448 B
    Debug   (-DDEBUG=1)    7 DebugBridge symbols, 6 private-API symbols, 13104 B

The harness is unchanged in Debug. Release now emits an empty translation unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:49:32 -07:00
Garry TanandClaude Fable 5 4d0e7b7c2a feat(hooks): Stop hook closes dangling timeline entries — fail-open
The preamble writes event:'started' to the project timeline at every skill
start, but the matching 'completed' write lives in prose at the END of the
skill workflow — unenforceable. An interrupted session, a context blowout,
or an agent that simply stops leaked started > completed forever, and the
leak was unrepairable after the fact (observed live in #2553).

New hosts/claude/hooks/timeline-stop-hook (+ .ts, question-log-hook shim
pattern): on Claude Code's Stop event it appends event:'completed' with
outcome 'unknown' and source 'stop-hook' for every 'started' entry in the
project timeline that has no matching completion. setup registers it via
gstack-settings-hook add-event (Stop was already an accepted event) under
its own source tag, idempotently; --no-team and gstack-uninstall remove it.

FAIL-OPEN contract (F5), pinned by tests: ALWAYS exits 0 — corrupt
timeline (bad lines skipped individually, valid ones still repaired),
missing timeline, garbage/empty stdin, bun missing from PATH (the shim
'|| true's), and an over-cap timeline (10MB skip) all repair nothing and
block nothing; errors land in ~/.gstack/hook-errors.log best-effort. The
write path is append-only with a ~2s internal budget, and a second Stop is
a no-op (already-closed entries never re-close). Correlation is
project-scoped by design — the preamble's session id is shell-local, so a
concurrent same-project session's entry may close early as a traceable
source:'stop-hook' row rather than a silent leak; the header documents the
trade-off.

Fixes #2553

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:32 -07:00
Garry TanandClaude Fable 5 9c0de5fed1 fix(redact): close the remaining #1946 fail-opens — detection coverage + one-time consent
Two of #1946's reported gaps were still open after the v1.64 fail-closed
work (the git-error and oversized-diff paths in bin/gstack-redact-prepush
are already strict, chunked, and pinned by tests):

1. Detection fail-open: env.kv required an UPPERCASE name with an '='
   assignment, so 'api_key=…', 'apiKey: "…"', and 'password: …' — the
   most common real config shapes — produced NO finding at all. The pattern
   is now case-insensitive, accepts ':' (YAML/JSON) as well as '='
   assignment, and handles quoted JSON keys. It stays MEDIUM and
   entropy-gated per the calibration rule (a generic net that cries wolf
   gets bypassed), with pinned cases for each closed shape plus the
   placeholder/entropy negatives.

2. Install fail-open: nothing ever offered the guard, so a plain 'git
   push' scanned nothing and users believing themselves protected weren't.
   setup now asks ONCE for consent on a real interactive terminal
   (maintainer decision 6): an explicit answer is recorded to the existing
   redact_prepush_hook key and never re-asked; a timeout or non-interactive
   run changes nothing and keeps the hint-only posture. Default stays
   FALSE, and setup still never installs the hook itself — /ship owns the
   per-repo install (the wrong-repo invariant is pinned by the existing
   'setup carries the hint only' test).

Tests: per-shape pattern cases, prompt gating statics (key-absence + TTY +
timed default-N read), timeout-persists-nothing, non-interactive stays
hint-only with no key write, and recorded-answer-is-silent behavior runs.

Contributes to #1946 (the pre-push guard's fail-closed scan paths landed
in earlier releases; this closes the coverage and consent gaps it names).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:31 -07:00
Garry TanandClaude Fable 5 9af589bb73 fix(setup): render the gbrain :user variant to an out-dir — global installs stay git-clean
On a global-git install with gbrain, ./setup and 'gstack-config
gbrain-refresh' ran gen:skill-docs:user IN PLACE inside the install
checkout, rewriting ~16 TRACKED SKILL.md files. The checkout stayed
permanently dirty, every /gstack-upgrade 'git stash' saved a redundant
snapshot of generated content, and the growing stash list invited a 'git
stash pop' that would lay stale instruction markdown from an older gstack
over the current version — a quiet wrong-rules failure mode.

Fix, wired through machinery that already existed (gen-skill-docs
--out-dir + the symlink install layer): brain-aware SKILL.md now renders
into the untracked ~/.gstack/render/claude, and both Claude installers
serve the render when present — setup's link_claude_skill_dirs prefers
$GSTACK_HOME/render/claude/<skill>/SKILL.md, and bin/gstack-relink does
the same so a later config change can't silently flip skills back to the
blockless canonical source. setup wipes and rebuilds the render each run,
repoints installed skills after a successful render, and removes a stale
render (re-linking canonical) when gbrain is gone. gbrain-refresh renders
to the out-dir and repoints via relink; its 'this dirties the install's
git tree' caveat is retired because it no longer does.

A one-time upgrade migration (gstack-upgrade/migrations/v1.67.0.0.sh, F12)
restores the legacy dirt: unstaged modifications to SKILL.md / sections/
*.md files in the install checkout are git-checkout'd back to canonical;
anything outside that footprint (user edits, untracked files, staged work)
is left alone and reported. Idempotent, non-fatal, symlinked installs
skipped.

Tests: render-preference behavior for both installers, static pins that
every executable :user invocation carries --out-dir and the caveat text is
gone, migration fixture (restore/leave/idempotent/no-op matrix), and the
existing out-dir render test now asserts 'git status --porcelain' gains
zero new entries across a full :user render.

Fixes #2569

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:31 -07:00
Greg JacksonandClaude Opus 4.6 1dbed2c01f fix(settings): include command in add-event dedup key (#2382)
Fixes #2382.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-16 09:49:31 -07:00
2be9bd0660 feat(setup): wire --host cursor through the full install path
'./setup --host cursor' was accepted by the flag parser and then did
nothing: no INSTALL_CURSOR branch existed, so the script built binaries,
printed no 'ready' line, and installed zero skills — Cursor users had no
way to install gstack at all.

Full install slice, re-derived from PR #2547 by @szsunyuan onto the
current installers: generate .cursor/ skill docs (host config already
existed), create a minimal ~/.cursor/skills/gstack runtime root (root
SKILL.md + bin/lib/browse assets + review checklist pair + ETHOS.md +
supabase config — bin and lib travel together because bin scripts import
../lib), link the generated gstack-* skills, and plant the repo-local
.cursor/skills/gstack sidecar WITHOUT ever wiping the generated SKILL.md
files it shares a directory with (link-before-sidecar ordering keeps the
generation fallback alive). Auto mode detects Cursor via the cursor
binary or the ~/.cursor footprint. gstack-uninstall removes
~/.cursor/skills/gstack* and per-project .cursor/skills/gstack* — and
never rmdir's .cursor itself, where Cursor stores user rules.

Re-derivation deltas from the PR: the link guards carry the #2444
IS_WINDOWS bypass (re-runs refresh real-dir copies), lib/ and
supabase/config.sh ride along like every other runtime root, and the
hosts/cursor.ts sidecar field is omitted (HostConfig no longer carries
one — sidecar behavior lives in setup).

Fixes #1358

Co-authored-by: Yuan Sun <forrest.sun527@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:31 -07:00
Garry TanandClaude Fable 5 c84246845e fix(uninstall): remove real-directory skill installs, gated on provenance
On Windows, setup installs skills as REAL directory copies (cp -R via
_link_or_copy). gstack-uninstall's per-skill loop filtered on [ -L ], so
every copy was skipped: --force exited 0 and printed 'gstack uninstalled.'
while leaving ~52 gstack-* directories plus _gstack-command/ behind in
~/.claude/skills. The same filter also missed the standard Unix shape (real
dir + symlinked SKILL.md), which was left as a dangling-symlink husk.

Fix: the loop now handles all three install shapes. Symlink entries keep
the existing readlink check. Real dirs with a SYMLINKED SKILL.md are removed
when the link points into gstack (same semantics as setup's cleanup
helpers). Real dirs with a REAL-FILE SKILL.md — the Windows copy shape — are
removed ONLY when both provenance gates pass (F8): (a) the directory name is
in gstack's skill inventory (source dir names, frontmatter names, gstack-
prefixed variants, and the alias dirs), and (b) the SKILL.md carries the
existing generated banner '<!-- AUTO-GENERATED from' (ENG-OV10: every
pre-v1.67 copy already carries it; a NEW marker would refuse to delete
legitimate old installs, recreating the bug). Anything failing a gate is
listed to stderr and never deleted — a user's own skill that happens to
share a name with a gstack skill survives.

Tests: a fake-tree fixture covers removed/kept/listed for every shape
(including the F8 name-collision row), and a census test asserts every
installable skill's generated SKILL.md carries the banner so the gate can't
strand a bannerless skill. Registered in the Windows-safe curated list —
the copy shape is exactly what windows-latest exercises.

Fixes #2563

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:31 -07:00
Garry TanandClaude Fable 5 52006feac4 fix(setup): Windows re-runs refresh installed skills for codex/factory/opencode hosts
On Windows (Git Bash / MSYS2, no Developer Mode), _link_or_copy installs
REAL directory copies. The install guards in link_codex_skill_dirs,
link_factory_skill_dirs, link_opencode_skill_dirs, and create_agents_sidecar
only ran the copy when the target was a symlink or missing — true on the
first install, never again. Every subsequent ./setup after a git pull
reported 'gstack ready (codex).' and exited 0 while silently refreshing
nothing: users ran stale SKILL.md forever. (link_claude_skill_dirs already
handled this; the other hosts never got the treatment.)

Fix: all five guard sites bypass the symlink-or-missing check when
IS_WINDOWS=1 — _link_or_copy rm -rf's the destination first, so the real-dir
copy refreshes in place. Unix behavior is unchanged (symlinks still pass the
guard via -L and serve updates without re-copying).

The new bash-fixture test drives the REAL extracted functions through the
install → upstream change → re-run cycle under IS_WINDOWS=1 (v1 must become
v2), pins the sidecar-skip behavior, checks the Unix path stayed a symlink,
and statically asserts the bypass at all five sites so factory/opencode
can't regress. Registered in the Windows-safe curated list
(KNOWN_WINDOWS_SAFE) so it actually runs on the windows-latest CI lane —
the 'bin/' pattern hit is a fixture path segment, not a shebang spawn.

Fixes #2444

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:30 -07:00
Garry TanandClaude Fable 5 663aca3b05 fix(setup): alias skills install as rewritten copies, never symlinks
The two back-compat alias dirs — _gstack-command (root router) and
connect-chrome (→ open-gstack-browser) — symlinked the canonical SKILL.md
verbatim, so each alias re-served the canonical frontmatter name:. Claude
Code keys skills on that name and requires global uniqueness: the
connect-chrome duplicate silently shadowed /open-gstack-browser (whichever
readdir returned first won), and the _gstack-command duplicate could drop
the ENTIRE personal-skills set — every /gstack command vanished until the
user hand-deleted the alias dirs, and the next setup re-broke it.

Fix: copy-then-rewrite. A shared _install_alias_skill_md helper reads the
SOURCE SKILL.md and writes a fresh copy with name: rewritten to the alias
dir's own name (_gstack-command / connect-chrome / gstack-connect-chrome).
sed never edits in place: on Unix the old install was a symlink into the
repo, and an in-place rewrite through it would have corrupted the generated
source (eng review E2). bin/gstack-relink gets the same treatment for its
root-alias helper, and its discovery loop now skips symlinked source dirs
so the connect-chrome repo symlink can't re-mint the duplicate.

Tests assert: installed aliases are NOT symlinks, carry their own unique
names, all installed frontmatter names are globally unique, re-runs refresh
cleanly, legacy symlinked aliases are replaced not written through, and the
source files stay byte-intact.

Fixes #2511
Fixes #2201

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:30 -07:00
Garry TanandClaude Fable 5 0f38feee78 fix(setup): install every skill runtime asset for the Claude host
On a fresh Claude install, link_claude_skill_dirs installed only SKILL.md
(+ sections/) per skill. Every skill that reads a sibling runtime file at
.claude/skills/<name>/<file> was broken out of the box: /review stopped at
'Read .claude/skills/review/checklist.md' (file never installed), and qa's
templates/references, plan-devex-review's dx-hall-of-fame.md,
gstack-upgrade's migrations/, and careful/freeze's bin/ hooks were all
silently missing. Codex/Factory/OpenCode/Kiro installers already copied
these; the primary host never did.

Fix: a shared _link_skill_runtime_assets helper installs EVERYTHING a skill
ships next to its SKILL.md, with an explicit exclusion list (F7):
node_modules, dist, test, *.tmpl, hidden files. Exclusion-list polarity
means a newly added asset installs by default instead of being silently
dropped. Assets refresh unconditionally on re-run (rm + relink/copy), so
Windows real-dir copies pick up changes after git pull.

New free test runs the real installer functions against the live repo into
a temp skills dir with a TWO-CLASS referenced-paths assertion (ENG-OV7):
alias-relative refs (.claude/skills/<name>/<path>) must exist under the
install; repo-anchored refs (~/.claude/skills/gstack/<path>) must exist in
the tree modulo an explicit built-artifact allowlist (browse/design/
make-pdf dist + the compiled gstack-global-discover). Known-broken class-2
refs (#2250 bare bin names) are ratcheted: the test fails if they quietly
start existing without the entry being removed.

Fixes #2317
Fixes #2454

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:30 -07:00
Mike LaniakandClaude Opus 5 1a1dab2c5a fix(gitattributes): pin *.txt to LF
.gitattributes pins LF for every other text format in the repo (*.md,
*.tmpl, *.yml, *.yaml, *.json, *.toml, *.sh, *.ts, extensionless scripts,
even the hash-pinned diagram-render dist files). *.txt is the one text
format left unpinned.

On Windows with core.autocrlf=true, that means the two tracked .txt files
are rewritten to CRLF at checkout and then read as permanently modified:

  gstack/llms.txt                                   +174 bytes
  make-pdf/test/fixtures/combined-gate.expected.txt  +20 bytes

git status is never clean, and /gstack-upgrade's 'git stash' step saves a
phantom stash on every upgrade — one that pops back to an empty diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:23:41 -07:00
Garry TanandClaude Fable 5 9cd1e875ea docs(browse): warn that hover scrolls and the daemon tab persists across sessions
Fixes #2445. Both behaviors are by design but produced confidently wrong
verification output: hovering a below-the-fold element scrolls the page
before a "rest state" screenshot (exit 0, wrong section), and the daemon's
tab survives sessions so a bare `reload` can act on whatever earlier work
left open. The screenshot-evidence section now names both traps with the
concrete guards (assert window.scrollY; always goto before verifying).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:23:31 -07:00
Garry TanandClaude Fable 5 0f70ea827c fix(sync-gbrain): remove the capability-check page file left in the user's repo
Fixes #2503. On worktree-pinned brains `gbrain put` materializes the checked
page as _capability_check_<pid>.md in the current directory (the user's
repo), and `gbrain delete` removes the page but not the file — every
/sync-gbrain run left a stray file in the repo root. The check now deletes
the materialized file explicitly after the page delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:22:12 -07:00
Garry TanandClaude Fable 5 bab192391f fix(retro): point the prior-retros context query at files /retro actually writes
Fixes #2552's live half. The gbrain context-query glob targeted
~/.gstack/projects/<slug>/retros/*.md — a directory and extension nothing
writes — so prior-retro recall was dead on every brain-aware run. /retro
saves to .context/retros/*.json (repo-local); the query now reads that. The
issue's second defect (quoted-tilde orphan sweep) is already fixed on main —
the preamble sweeps with "$HOME/..." — verified, no change needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:21:29 -07:00
Garry TanandClaude Fable 5 2c07418ee1 fix(repo-mode): probe GNU stat before BSD so Git Bash stops crashing
Fixes #2195. On GNU coreutils `stat -f` SUCCEEDS (filesystem status, not a
format string), so the BSD-first fallback chain never fell over — it fed
multi-word filesystem output into the cache-age arithmetic and crashed under
set -u on Windows Git Bash. GNU `stat -c` fails cleanly on BSD/macOS, making
GNU-first deterministic on both; the mtime is numeric-validated before
arithmetic as a last line of defense.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:20:14 -07:00
Garry TanandClaude Fable 5 a118fd0c89 fix(make-pdf): boolean flags no longer swallow the next positional argument
Fixes #2514. The parser treated any non-flag token after a flag as its value,
so `$P generate --toc essay.md` ate essay.md as --toc's value and failed with
"missing input" — the skill's own documented usage only worked when two
boolean flags happened to be adjacent. BOOLEAN_FLAGS enumerates the no-value
flags; value flags (--watermark, --to, --title, ...) are unchanged. main()
now runs behind import.meta.main so tests import the parser directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:19:12 -07:00
Garry TanandClaude Fable 5 9df6015130 fix(test): refresh codex/factory ship goldens from post-#2588 regeneration
The #2588 absorb refreshed all three ship goldens, but `bun run
gen:skill-docs` regenerates the CLAUDE host only — the codex/factory goldens
were copied from artifacts rendered before the resolver change and failed
against a fresh external-host regen in the serial test phase. Re-rendered
with --host codex / --host factory and re-copied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:12:32 -07:00