mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
87c2583ff140c60d11e3b37f4e616fed8f6694b6
449
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
87c2583ff1 |
fix(sync-gbrain): dream pack-capability WARN anchors to the graph phase
Fixes #2341. classifyDreamOutcome matched the bare phrase "does not declare this phase", but gbrain's only emitters are the CONTENT phases (extract_atoms, synthesize_concepts) — which the default base packs legitimately skip while resolve_symbol_edges still runs. Every base-pack brain therefore got the pack-capability WARN with its wrong, costly remediation ("switch schema packs"), masking real graph problems. The match now anchors to the graph phase (resolve_symbol_edges/extract_code_symbols); a base-pack run with a built graph is clean, and a resolved-0 run gets the honest 0-edge diagnosis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0f6e471ae8 |
fix(test): update four main-side gen-skill-docs assertions to the T6 contracts
Three contracts moved under this theme and the assertions pinned the old shapes: - The routing-probe assertion expected the single-file 'grep ... CLAUDE.md' shape; #2500 made the probe iterate CLAUDE.md AND AGENTS.md, so it now asserts the for-loop + quoted $_RF shape. - The three Claude-output Codex-path bans tripped on ~/.codex/config.toml, which the shared codexPreflight's model_unusable branch (#2477) now documents in rendered output. That path is the Codex CLI's own config file — the same user-facing class as the already-exempt ~/.codex/sessions/ — so it is scrubbed before the host-path ban, with the reasoning recorded next to the existing exemptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
504409d75b |
fix(build): convert MSYS paths for Bun in the Windows server-bundle build (#2452)
browse/scripts/build-node-server.sh resolves GSTACK_DIR with pwd, which
under MSYS/Git Bash yields a /c/... style absolute path that Bun cannot
open ('FileNotFound opening root directory') — the Windows Node-server
bundle build died at the first bun build. Convert via cygpath -m on
MINGW/MSYS/CYGWIN before deriving SRC_DIR/DIST_DIR.
Re-derived from PR #2452, taking only the cygpath build half — the PR's
icacls principal-ambiguity half already landed on main
(browse/src/file-permissions.ts's SID-form principal). Verified the build
bug still exists on current code before absorbing (build-node-server.sh:10
had no conversion). Contributed by @chiragborse1.
Co-authored-by: chiragborse1 <chiragborse1@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
ae47c29b82 |
fix(review): skip nested codex spawns when already running under a Codex host (#2519)
/review executed inside a Codex host spawned the codex specialist passes anyway — the same model reviewing itself, at multiplied cost (observed: 15M tokens for a single /review). Detection per maintainer decision 7: a presence probe of the Codex session env. A live Codex session exports CODEX_THREAD_ID and CODEX_SANDBOX into every shell it spawns — verified during implementation against a live `codex exec 'env | grep -i codex'` capture on codex 0.147.0 (CODEX_THREAD_ID, CODEX_SANDBOX=seatbelt, CODEX_SANDBOX_NETWORK_DISABLED=1, CODEX_CI=1). The shared codexPreflight in scripts/resolvers/constants.ts (consumed by all three review.ts army blocks: adversarial, codex plan review, codex doc review) now yields CODEX_MODE=under_codex and instructs exactly one printed notice — '[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]'. The override env var forces the nested passes for users who really want them. codex/SKILL.md.tmpl Step 0.5 gains the same probe: /codex under a Codex host stops with a one-line notice, since its whole value is a SECOND model's opinion. test/codex-under-codex-detection.test.ts runs the rendered preflight bash under all four env combinations (thread-id only, sandbox only, forced, clean) and asserts the probe + notice render in the three preflight consumers and the codex skill. Fixes #2519 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
73c96f9c12 |
feat(codex): model round-trip probe — an unusable configured model fails fast with guidance (#2477)
The auth probe accepts 'auth exists' as readiness, but a ChatGPT account
with a stale model pin in ~/.codex/config.toml passes it and then EVERY
mode dies with an HTTP 400 ('The <model> model is not supported when using
Codex with a ChatGPT account') and no pointer to where the model came from
— one report burned ~40 minutes and four invocations plus a strings dump
of the binary before finding the one-line config fix.
bin/gstack-codex-probe gains _gstack_codex_model_probe: a short
codex exec 'reply OK' round trip with the configured model, gated behind
the cheap auth probe at all three preflight sites (codex Step 0.5, the
shared codexPreflight in scripts/resolvers/constants.ts — which grows a
model_unusable CODEX_MODE branch — and autoplan's availability chain).
Verdicts: MODEL_OK (cached 1h, keyed on config.toml + auth.json mtimes so
a pin edit or re-login re-probes immediately), MODEL_UNUSABLE (exit 1,
prints the rejection plus HINTs at the model= pin and the
[notice.model_migrations] table), MODEL_PROBE_INCONCLUSIVE (timeout or
transient: FAIL-OPEN so network luck never wedges codex mode).
The 'Model not supported (HTTP 400)' Error Handling entry already shipped
in v1.64.0.0; Step 0.5's prose now routes MODEL_UNUSABLE to it.
test/codex-model-probe.test.ts drives all four behaviors against a stubbed
codex binary (invocation-counted cache hit, hint content, fail-open
polarity, mtime invalidation).
Fixes #2477
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
63e2b7ac2c |
fix(ship): review fix loop stays in one invocation, bounded at 3 cycles (#2391)
The pre-landing review committed its fixes, then STOPPED and told the user to run /ship again — 5-10 manual invocations on a branch with a few auto-fixable findings, violating /ship's fully-automated contract. There is no user decision between those invocations; each rerun just repeats the workflow until a review pass produces no fixes. ship/sections/review-army.md.tmpl item 7 now makes the loop explicit: after committing fixes, re-run the test suite (Step 5) and this review (Step 9 items 2-6) in the SAME invocation, repeating until one full pass applies zero fixes, then continue to Step 12. Bounded at 3 fix cycles — a review that will not converge STOPs with a report of which findings keep reappearing (a genuine blocker), never with a rerun request. test/ship-review-loop.test.ts asserts no rendered ship surface (section + all three host goldens) carries the STOP-and-rerun shape and that the bounded loop language renders. Fixes #2391 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a8d9cf9ef4 |
fix(resolvers): slug-canonical branch form in file-path positions (#2550, #1851)
Branch-name-to-filename had incompatible rules across writer and readers: gstack-review-log WRITES <branch>-reviews.jsonl with the gstack-slug canonical form (tr '/' '-' then tr -cd 'a-zA-Z0-9._-', bin/gstack-slug:178), but Context Recovery PROBED it with raw $_BRANCH from git branch --show-current — so for any branch containing a '/' the REVIEWS line never fired (#1851's reader half of #1127). The probe now uses ${BRANCH:-unknown}, the canonical value the gstack-slug eval on the block's first line already sets. review.ts's plan content-search BRANCH gains the missing tr -cd half so it matches the same canonical pipeline. Full audit of the 5 raw $_BRANCH interpolation sites in scripts/resolvers/ (E3): generate-context-recovery.ts:16 (reviews.jsonl path) -> canonical BRANCH; :19/:21 (timeline.jsonl content greps) KEEP raw $_BRANCH because the timeline writer (preamble's gstack-timeline-log call) stores the raw branch in the "branch" field — slugging the reader would break that pairing; generate-preamble-bash.ts:29 (display echo) and :97 (timeline data write) keep raw by design. The *-$BRANCH-design-*.md family (review.ts:313 + 3 plan-review templates) is a consistent tr '/' '-' writer/reader pair and is deliberately untouched. test/branch-slug-hygiene.test.ts pins the discipline: a rendered-output sweep forbids raw $_BRANCH adjacent to a path separator or as a filename prefix in ANY generated SKILL.md/section, and a live round-trip on a feat/slash branch proves gstack-review-log's write is found by the rendered probe (with the raw-form shape as a negative control). Reader-side fix folded from PR #1851. Contributed by @harjothkhara. Fixes #2550 Fixes #1127 Co-authored-by: harjothkhara <harjothkhara@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
96c22cbd21 |
fix(question-tuning): interpolate the absolute question-registry path (#2489)
The Question Tuning preamble pointed agents at a RELATIVE
scripts/question-registry.ts in the same sentence whose ${bin} path renders
absolute. Agents run with cwd in the USER'S project — the relative lookup
never resolves, silently fails, and the documented {skill}-{slug} fallback
fabricates a singleton question_id every time (one observed
/plan-eng-review session: 21/21 unregistered ids, so no per-question
preference can ever attach).
The resolver now interpolates ctx.paths.skillRoot the way sibling resolvers
interpolate bin paths: ~/.claude/skills/gstack/scripts/question-registry.ts
on Claude, $GSTACK_ROOT/scripts/question-registry.ts on env-var hosts.
test/question-tuning-registry-path.test.ts asserts the rendered path per
host, forbids the bare relative shape, and checks the target file exists in
the install tree.
Fixes #2489
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
a9ca914f43 |
fix(codex): retire deprecated web-search flag behind one CODEX_WEB_SEARCH_FLAG constant (#2525)
codex >=0.144 deprecates the legacy --enable-based web_search_cached
spelling (web search is on by default; --enable <FEATURE> now means
-c features.<name>=true, verified against codex 0.147.0's exec --help).
Every gstack codex invocation now passes -c 'web_search="cached"' instead.
The flag previously lived inline at 19 raw sites. Per ENG-OV11a the 10
template-inline sites (autoplan/SKILL.md.tmpl x4, codex/SKILL.md.tmpl x6)
convert to a shared {{CODEX_WEB_SEARCH_FLAG}} token first, so ONE resolver
constant (CODEX_WEB_SEARCH_FLAG in scripts/resolvers/constants.ts) now
covers all sites: review.ts x5, design.ts x3, the token resolver in
utility.ts, and the tool-map helper comment.
codex/SKILL.md.tmpl's web-search prose guarantee is corrected: the -c form
explicitly overrides a top-level web_search config (the legacy flag yielded
to it), and native codex review disables web search regardless of
configuration, so the flag is a no-op on the default Review path.
test/codex-web-search-flag.test.ts is the safety net: repo-wide grep
tripwires assert NO rendered SKILL.md/section/golden and NO source file
carries the deprecated spelling, and that the token resolves in rendered
output.
Fixes #2525
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
08bff7ef25 |
fix(resolvers): empty find must not fall through to cwd (#2483)
find ... | xargs ls -t runs ls with NO operands when find matches nothing — GNU xargs still invokes the command once, and ls -t with no operands lists the current directory. Three sites misfired on fresh installs (no ceo-plans / checkpoints / plans yet), exactly where a wrong answer is least likely to be recognized: review.ts's plan fallback silently adopted a random cwd .md as "the plan", and Context Recovery listed unrelated cwd files as RECENT ARTIFACTS / LATEST_CHECKPOINT. All three now use xargs -r ls -t, mirroring the shape the sibling bin/gstack-codex-session-import fix (#2482) landed with: -r pins the BSD skip-on-empty behavior on GNU too, and BSD xargs accepts -r as a no-op. test/empty-find-fallthrough.test.ts pins it four ways: no bare xargs ls -t in scripts/ or bin/, both rendered Context Recovery sites guarded, a live execution proving an empty checkpoints dir yields no checkpoint (not a decoy cwd file), and a rendered-SKILL.md sweep. Re-derived from PR #2483 onto current code. Contributed by @tranthanhnhatkhoa. Fixes #2483 Co-authored-by: tranthanhnhatkhoa <tranthanhnhatkhoa@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
95b66b56ca |
fix(preamble): probe AGENTS.md for skill routing; team-init resolves GSTACK_ROOT (#2500)
The HAS_ROUTING preamble probe only checked CLAUDE.md, so repos that route skills via AGENTS.md (the cross-harness convention for Codex, Cursor, and generic agent hosts) reported HAS_ROUTING: no and got nagged to create CLAUDE.md. The probe now iterates CLAUDE.md and AGENTS.md. gstack-team-init's required-mode enforcement (the CLAUDE.md verification snippet and the generated .claude/hooks/check-gstack.sh) hardcoded ~/.claude/skills/gstack, false-blocking installs living at any other host's global root or the migrated ~/.gstack/repos/gstack location. Both sites now resolve the install root: GSTACK_ROOT env first, then every registered host's globalRoot, then the migrated repo path. Install instructions keep pointing at the canonical Claude location. test/routing-probe.test.ts pins both: rendered-preamble assertions plus a live execution of the extracted probe block (AGENTS.md-only repo => yes), and a drift test that requires every hosts-registry globalRoot to appear in team-init's probe list. Re-derived from PR #2500 onto current code (the PR's 52-file regen was discarded and regenerated here). Contributed by @gamerey43. Fixes #2500 Co-authored-by: gamerey43 <gamerey43@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
436adb72d3 |
fix(deps): bun-patch playwright-core 1.62.1 — windowsHide at launch + taskkill (#2160, #1989)
The repo's first patchedDependencies entry. playwright-core's bundled process launcher (lib/coreBundle.js in the 1.62.x layout) spawns browser children without windowsHide — Node defaults it to FALSE for child_process.spawn — so Chromium children could flash a console window on Windows, and its force-kill path shells `taskkill /pid <pid> /T /F` through cmd.exe with the same omission. Both sites now pass windowsHide: true via patches/playwright-core@1.62.1.patch (generated with `bun patch` / `bun patch --commit`). Coherence verified end-to-end: rm -rf node_modules && bun install applies the patch cleanly (both sites present in the reinstalled tree), and a real chromium.launch() through the patched bundle works. browse/test/playwright-core-patch.test.ts pins the three-legged invariant statically — package.json's patchedDependencies key is VERSION-KEYED against the installed playwright-core, the patch file exists and carries both sites, bun.lock records the patch, and the installed bundle actually has it applied — so a future playwright bump that forgets to re-target the patch fails CI with the exact key to regenerate (revert pairing: dropping the c25 bump requires dropping this patch too). Tests: playwright-core-patch 4 pass, 0 fail. Fixes #2160, #1989. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
994b5f5502 | fix(browse): create node bundle output directory | ||
|
|
0cbaeadc66 |
feat(browse): allow Emulation.setEmulatedMedia on the CDP allowlist (re-derived from #2419)
Adds Emulation.setEmulatedMedia to the deny-default CDP allowlist: tab-scoped, trusted output (returns an empty result — no page content). Unlocks media type/feature overrides (prefers-color-scheme, prefers-reduced-motion, prefers-contrast, forced-colors) via `$B cdp`, so dark-mode and a11y CSS branches are testable without a headed toggle. Like setUserAgentOverride, the override persists on the tab until cleared with an empty features array — noted in the entry's justification. Registry test pins the entry (allowed + tab scope + trusted output); the PR's VERSION/CHANGELOG stamping is stripped per wave convention (versioning happens at /ship). Tests: cdp-allowlist 7 pass, 0 fail. Re-derived from PR #2419 by @meshailabs. Co-authored-by: meshailabs <devsupport@meshai.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
be66d4f06f |
fix(browse): capture browser-skill subprocess output via temp files, not pipes (core of #2559)
Under a loaded parent, the FIRST piped Bun.spawn in a process intermittently yields an empty stderr even though the child wrote it and exited 0 — measured identically with readers-attached-before-exit and with a manual getReader() drain, so it's loss inside the async pipe plumbing, not read ordering. It flaked `$B skill test` (bun test writes its banner to stdout and the pass/fail summary to stderr, so a dropped stderr silently degraded the result to just the banner) and would blank a skill's JSON result on `$B skill run` while still reporting success. New runToFiles() points the child's stdout/stderr at temp files via Bun.file() (never raw fds — closing self-opened fds around a spawn tripped Bun's fd bookkeeping into a stray epoll_ctl EBADF), awaits exit, then reads the files: the kernel has flushed everything by child exit, so the post-exit read is complete, and chatty children can't stall on a full pipe buffer. Both handleTest and spawnSkill route through it (timeout + capped read preserved via timeoutMs/maxStdoutBytes). Bun.spawnSync would also capture reliably but would deadlock: a spawned skill calls back into this same daemon on GSTACK_PORT. The `tests passed for "<name>"` fallback is gone — a passing bun test always prints a summary, so exit 0 with no output means the run was NOT captured, and handleTest now throws instead of fabricating success. The E2E assertion checks both stream halves (banner + summary + "Ran N tests") instead of the loose alternation whose `tests passed` branch matched the synthetic fallback vacuously. A static tripwire pins the structure: runToFiles owns the module's ONLY Bun.spawn, and no site reads child output via stdout:'pipe' / new Response(proc.stdout) / getReader(). Scope: the PR's repo-wide test-file sweep is deliberately not absorbed — this is the core only, per the wave plan. Tests: browser-skill-commands + browser-skills-e2e + browser-skill-write 74 pass, 0 fail. Re-derived from PR #2559 by @frederik-kaster-noygear. Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |