Repository navigation
fix: five silently-unwired capabilities — CI pointed at a deleted dir, deps that never re-installed, an engine nobody asked its version, a memory bridge that couldn't find airc, and a plugin frozen 2 weeks behind - #2213
Conversation
…hat should have was pointed at a deleted directory Three defects that compose into one: the TypeScript client tests were unguarded, the guard that should have covered them was dead, and on Windows the suite could not be run at all. 1. THE SUITE WAS RUN BY NO WORKFLOW. `npm run test:clients` was already in package.json and already passing. Nothing invoked it. So renderBench.spec.ts and six sibling spec files across apps/web and packages/ existed, were green on their authors' machines, and gated nothing — a correct check nothing calls, indistinguishable from having no check until someone greps for the caller. 2. ci.yml HAD BEEN RED FOR TWO MONTHS. Last success 2026-06-07; failing continuously from 2026-06-09 with `Cannot find module 'dotenv'`. Root cause was not dotenv: the job ran in `working-directory: src` against `src/package-lock.json`, calling `npm run build:ts` and `npm run test:crud`. `src/` was the Node monolith, retired when the substrate became a headless Rust core (#1840). Every path and script it named is gone; the error was describing the absence of its whole world. A check that ALWAYS fails is worse than no check — it cannot distinguish a broken PR from a healthy one, so the only lesson available is to stop reading CI. That is what happened: work routed around it through the Rust and drift-guard workflows for two months. Replaced rather than patched, and it now watches `canary` as well as `main`, because a gate that only sees the stable line learns about breakage after the merge. 3. THE SUITE COULD NOT RUN ON WINDOWS AT ALL. `@continuum/web` and `@continuum/chat-view` declared `"test": "TZ=UTC vitest run"`. npm runs scripts through cmd.exe on Windows, which has no POSIX env prefix, so both died with `'TZ' is not recognized as an internal or external command`. The other three workspaces use a plain `vitest run` and were fine — the split is exactly the prefix. Fixed by REMOVING the portability assumption rather than packaging a tool to satisfy it: apps/web already pinned `test: { env: { TZ: 'UTC' } }` in its vite config, making the prefix redundant; chat-view had no config at all, so it gets one whose only job is that pin. No cross-env dependency added. Worth stating: this one is only findable by RUNNING the suite on Windows. Linux CI and macOS both pass. Adding the CI gate alone would have gone green and left every Windows contributor unable to run the tests locally — the gate would have hidden this rather than caught it. Verified on Windows after the fix: 25 test files, 153 tests, exit 0. Before it, the two affected workspaces produced no test run whatsoever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
… client scripts only
`install.sh` runs `npm install` ONCE. Nothing re-ran it when the manifest
changed. So a contributor who installed in June and pulled in August had a
node_modules that silently did not match its own package.json, and the first
symptom was every client spec failing at COLLECTION with:
Failed to load url @continuum/chat-view. Does the file exist?
which points a newcomer at missing SOURCE rather than at their missing deps.
Measured on a real checkout: tree from Jun 17, manifest from Aug 5, `lit` and
every @continuum/* workspace package absent, seven spec files dead.
WHERE IT HANGS, AND WHY NOT THE START PATH. `start-server.sh` is headless Rust
by doctrine — "No Node, no TS, no widgets. The Node orchestrator stays out of
the loop." A dependency guard wired there would drag npm into the one runtime
path that exists to avoid it. So this hangs off `pre*` hooks on the CLIENT
scripts only: dev:web, dev:desktop, build/lint/typecheck/test:clients. Anyone
who only ever runs the core pays nothing and never sees it execute.
mtime, not a checksum: npm writes node_modules/.package-lock.json when it
materialises the tree, so "tree older than manifest" is exactly the question
worth asking and needs no parsing. A checksum would be more precise about
CONTENT and no more precise about what actually breaks people.
LOUD, never silent. A guard that repairs things without saying so teaches the
operator that installs are magic and hides a real signal — a lockfile moving
under them — that is sometimes worth knowing. Skipped under CI, where `npm ci`
is authoritative and already ran; CONTINUUM_SKIP_DEP_CHECK opts out for anyone
hand-managing a tree.
Verified both directions: silent on a fresh tree, fires with the reason named on
a stale one, no-ops under CI=1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…its own name Four names were in play and two were installed: uu ON PATH core/continuum-core/src/bin/continuum.rs continuum ON PATH same build, same size, same mtime — one binary, two names ctm absent declared as [[bin]] in apps/cli/Cargo.toml, never installed jtag absent used 40 times in CLAUDE.md, installed by nothing (Node era) So the declared name, the documented name, and the installed names were three different answers and nothing reconciled them. Joel reached for `uu` and was right; I nearly "corrected" him from the stale Cargo.toml, which would have been ratifying a declaration over the live system. WHAT CHANGED `program_name()` derives from argv[0]. The usage text was the literal string "usage: continuum ...", so `uu --help` printed examples for a command the reader had not typed — the front door did not know its own name. One binary can now ship under any number of names and each tells the truth; a future alias is correct the moment it exists, with nothing to remember to update. `version` is handled LOCALLY and never dispatched. `uu version` used to fall through to the substrate and return `Unknown command: 'version'` — the CLI could ask the core what IT was and could not say what ITSELF was, and with no core running it answered nothing at all. That is the gap that lets someone debug a fixed bug with an unfixed binary in their hand, which is the whole point of the version ruling. CLAUDE.md: 38 `./jtag` invocations rewritten to `uu`. Deliberately NOT rewritten: `.continuum/jtag/logs/...` paths (3, real directories), the [[jtag-probes-are-rtos-debugger]] memory slug, and the legacy JTAGClient snippets — none of those are commands, and a blind replace would have broken log paths. The mistake entry itself was inverted and is now the correction: it told every new session "Always work from `src`" and "Commands: `./jtag` NOT `./continuum`". `src/` is the retired Node monolith, `jtag` is installed by nothing, and `continuum` — the one it warned against — works. That is a documentation lie with a live cost: it is the first thing a fresh agent reads and the first ten minutes it wastes. Env vars corrected mid-edit: I wrote CONTINUUM_BUILD_SHA/_BRANCH from memory; build.rs emits CONTINUUM_BUILD_GIT_SHA and no branch at all. `option_env!` would have silently printed "unknown" rather than failing, so that would have shipped as a quiet lie about the build. cargo check -p continuum-core --bin continuum: 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…nfo on the snapshot A per-slot wedge was attributed to "the fork bump in build 4577". 4577 is the Rust CORE's build number; the engine has its own, unrelated one, and the fork engine had not been rebuilt at all. Settling it took comparing binary mtimes on two machines. I told M5 the engine had no version surface. That was wrong: llama_build_info() has existed all along — `--version`, the startup log, `/props.build_info`, and `system_fingerprint` on every completion. The gap was entirely on OUR side. The daemon already GETs `/props` for the served window and the modalities verdict, and threw `build_info` away. So this is silently-unwired-capability with the polarity reversed: not something we built and failed to wire, but something upstream hands us free that we drop on the floor. Same cost — a fact available for the asking gets re-derived by archaeology, and the derivation was wrong. - LlamaServerControl::engine_build() reads /props.build_info, default Ok(None) so fakes and remote controls stay honest by construction - rides on ServingSnapshot, stamped at reconcile, probed on the reconcile line - surfaces as engineBuild on ai/inference/status The commit sha is the load-bearing half: build numbers are ancestor counts, so our fork and upstream can both say b6789 and mean different code. Three assertions pin it: the identity must reach the snapshot; an engine that cannot say what it is reads as unknown rather than a guess; and a not-live snapshot never names an engine even when one answered the probe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
… CUDA's business Two defects, both found by a plain `cargo check` dying with "is `cmake` not installed?" on a box where cmake was installed a directory away. 1. The cmake + ninja pins lived INSIDE windows-build-env.sh's CUDA block, gated on `nvcc present AND cl.exe absent`. Neither is a CUDA concern. A CPU-only Windows box (no nvcc) skipped the block and got no cmake pin at all; a shell where cl.exe already resolved skipped it for the same reason. Hoisted into two self-guarded blocks that run on their own applicability. The PATH half is now the manifest's job — [module.runtime_path] on cmake and ninja, consumed by the generic loop that was already there. That is where the windows-vs-unix split belongs, because it IS a packaging fact: brew/apt put these on PATH, the Windows archives do not. Linux/macOS projections carry nothing, as they should. 2. ninja was fetched by a HARDCODED url inside the llama-server PowerShell module — no manifest entry, no pinned sha256, invisible to the manifest-gen drift gate. One tool provisioned by different rules than every other tool is exactly the drift the manifest exists to prevent, and an unverified download is a supply-chain hole however convenient the url is. Now a real Mod-Ninja with the same guard shape, sha256 verification and source-of-truth as Mod-CMake, and it runs in install.ps1 beside Mod-CMake instead of only on boxes that had already built llama-server with CUDA. ninja is declared platforms = ["windows"] deliberately. The defect it fixes is Windows-only (cmake auto-picks the newest VS; "Visual Studio 18 2026" is a generator cmake 3.30.x cannot name). On unix the default generator is never broken, so listing macos/linux would make those contributors install a package to buy nothing. Adapting to the platform means stating the asymmetry, not smearing one platform's workaround across all three. Verified: manifest-gen --check OK (4 files in sync), ninja absent from the linux and macos projections, CMAKE and CMAKE_GENERATOR=Ninja both resolve from a fresh shell with no CUDA involved, Mod-Ninja/Mod-CMake idempotent-skip, PowerShell parse + bash -n clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…own" for "program not found" Engram recall has been silently off on BigMama. Every session start printed "MEMORY BRIDGE DOWN — could not resolve this agent's persona id (airc status gave nothing)" while airc had 21h uptime and 1305/1305 acked. The probe never observed the daemon. Hooks do not inherit the operator's interactive shell — airc installs to ~/.local/bin, routinely absent from a hook's PATH — so bare `airc` was not a program it could find, and 2>/dev/null swallowed the "command not found" that would have said so. The same file already had resolve_continuum() doing this correctly. Two answers to "find a binary" in one file, one of them robust, is the drift; now there is one shape (resolve_airc, honoring $AIRC_BIN, with .exe variants for Windows). share.sh's two bare `airc` calls go through it too. And the receipts stop guessing. "No airc binary" and "airc ran and reported nothing" are different types, not two values of one type — conflating them is what wrote "airc status down" about a healthy daemon, twice, and sent an afternoon of diagnosis at the wrong layer. persona_failure_reason() re-derives the measured cause for both the receipt and the notice the AGENT reads. It re-derives rather than setting a variable inside resolve_agent_persona because callers invoke that as $(...) — a subshell, where any assignment dies. The first version of this fix did exactly that and the receipt came out blank. The negative test caught it, which is why it exists. Verified both directions: with ~/.local/bin stripped from PATH (the environment that failed for two sessions) the id now resolves; with no airc reachable at all the failure names the real cause instead of blaming the daemon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…indistinguishable from "stale"
The bridge's founding rule is that "installed" must never be indistinguishable
from "working". This is that rule one level up, and it is why yesterday's fixes
would have changed nothing on the machine that needed them.
There are two install paths with very different freshness semantics:
* `claude --plugin-dir tools/plugins/memory-bridge` runs LIVE from the repo —
`git pull` IS the update.
* a marketplace install COPIES the plugin to
~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ and pins it to a
git sha. Nothing re-syncs it. `git pull` changes nothing.
Nothing told you which one you were on. Measured on BigMama: the running copy
was pinned to 60fa0db (2026-07-25), two weeks stale. Its lib.sh had no
persona-id cache, and it contained NO session-capture.sh at all — so automatic
per-turn capture, the entire "volitional memory isn't memory" point, had never
run once on that machine. The README said the bridge was live the whole time.
So session-recall now compares its own running location against
tools/plugins/memory-bridge/scripts in the current checkout, and on drift emits
⚠️ MEMORY BRIDGE STALE plus a `stale` receipt. A missing file counts as drift —
that is exactly how session-capture.sh went absent unnoticed.
It runs on the SUCCESS path too, because staleness is orthogonal to whether
recall worked: a frozen copy can recall perfectly and still be missing every fix
since it was installed.
Only a cached copy can be stale, so running from the repo stays silent; and if
the cwd is not the continuum checkout there is nothing to compare against, so it
says nothing rather than guessing.
README: the "Status: Live" line was a claim a README cannot make — it describes
the repo, while liveness depends on your install. Replaced with the two paths
and how to tell which you are on.
Verified against the real frozen copy on this box (4 of 4 scripts differ →
notice fires) and against a live repo run (silent).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…l reason fixes never shipped
Plugin updates are VERSION-based, not content-based. `claude plugin update`
compares the DECLARED version against the installed one and never looks at the
files. memory-bridge had sat at 0.1.0 since 2026-07-25, so every installed copy
answered:
✔ memory-bridge is already at the latest version (0.1.0).
...no matter what changed. That single static string is why two weeks of work
never reached the machine that needed it: the copy running on BigMama had no
persona-id cache and no session-capture.sh AT ALL, so automatic per-turn memory
capture — the whole "volitional memory isn't memory" point — had never run once,
while the repo held working code and the README said the bridge was live.
Bumping 0.1.0 -> 0.2.0 propagated all of it in one command, verified here:
marketplace update -> `Plugin "memory-bridge" updated from 0.1.0 to 0.2.0`, the
new copy carries session-capture.sh + resolve_airc + the staleness detector, and
now differs from this tree in 0 of 4 scripts.
So the guard. check-plugin-version.sh fails when files under a plugin dir change
without that plugin's plugin.json version changing, and it compares the version
BEFORE and AFTER rather than trusting that the manifest was touched — editing a
description is not a release. A brand-new plugin has nothing to bump from and
passes. Verified both directions: passes on this commit's real bump, fails on a
simulated missed one.
Wired into CI, deliberately NOT into .githooks/pre-commit: that hook invokes
tests/adversarial-protocol.test.cjs and tests/command-processing.test.cjs, both
deleted with the Node monolith, and core.hooksPath does not point at it — so no
pre-commit hook runs at all right now. Adding a gate there would have looked
enforced while running never, which is the same defect one level up. Flagged
rather than silently repaired; that hook needs its own decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
|
Rebase attempt 2026-08-14: first two commits rebased clean (CLAUDE.md resolved to current continuum doctrine — the branch's edits there were Node-era |
# Conflicts: # CLAUDE.md # core/continuum-core/src/bin/continuum.rs # core/continuum-core/src/inference/llama_server.rs # core/continuum-core/src/modules/serving_daemon.rs # tools/scripts/generated/manifest.windows.ps1 # tools/scripts/generated/manifest.windows.sh # tools/scripts/install-manifest.toml # tools/scripts/lib/windows-build-env.sh
joelteply
left a comment
There was a problem hiding this comment.
[IntelMac non-author review] Reviewed because this PR's theme — capability that exists, is correct, and never reaches the thing that executes — is the same failure I have hit seven times on the grid tonight, so I read it looking for that shape in the fixes themselves rather than only in what they fix. Two findings, one of them the PR's own lesson stopping one line short. Nothing blocking.
WHAT I CHECKED RATHER THAN ASSUMED, since a gate that cannot run is the exact defect being fixed here: typecheck:clients, test:clients, build:clients, lint:clients, dev:web, dev:desktop and deps:ensure all exist in package.json on fix/ci-clients-gate. The two the workflow invokes are real. And ensure-node-deps.sh hard-exits on CI before doing anything, so the pre* hooks cannot make npm ci's lockfile authority negotiable in the workflow — I went looking for that specifically and it is already closed, with the reasoning written down.
FINDING 1 — persona_failure_reason STILL SWALLOWS THE ONE THING THAT WOULD ANSWER IT. This is the finding I would act on.
The diagnosis on this hunk is that 2>/dev/null ate the command not found that would have named the cause, so a resolver failure got written into two sessions' receipts as airc status down about a daemon at 21h uptime. Correct, and the resolver fix is right. But the status call keeps the redirect, in both places:
live="$("$airc_bin" status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')"
if [ -z "$("$airc_bin" status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')" ]; then
printf 'airc found at %s but it reported no peer_id — daemon down or not joined' "$airc_bin"
So the receipt now asserts a DISJUNCTION — "daemon down or not joined" — and the stream that distinguishes those two is discarded at the only point where it exists. A daemon that is genuinely down, a scope that never joined, a socket whose permissions changed, and an airc that panics on startup all produce that identical sentence. You have gone from one confidently wrong cause to two confidently listed candidates, which is a real improvement and is still the same shape: the process that KNOWS is silenced, and the explanation is reconstructed by inference afterwards.
The fix is small and lives on the path where the file already argues cost does not matter ("only ever runs on the failure path, where a fraction of a millisecond buys a receipt that names the actual cause"): capture stderr instead of dropping it, and put its first line in the receipt.
err="$("$airc_bin" status 2>&1 >/dev/null | head -1)"
printf 'airc found at %s but reported no peer_id (%s)' "$airc_bin" "${err:-no stderr}"
I am flagging this rather than shrugging at it because your own commit message is the argument for it: "'No airc binary' and 'airc ran and reported nothing' are different types, not two values of one type." Agreed — and "the daemon is down" and "this scope never joined" are also different types, currently collapsed into one string. The type distinction you drew is right and stops one level too early.
FINDING 2 — THE CLIENTS GATE NOW RUNS ON EVERY RUST-ONLY PUSH, AND THE SKIP IT REPLACED IS GONE.
on:
push: branches: [ main, canary ]
pull_request: branches: [ main, canary ]
with no paths: filter. The old workflow had a documentation-only skip; it was broken (it diffed origin/main..HEAD, which is the wrong base for a canary PR) and removing it is right. But nothing took its place, so on a repo whose overwhelming majority of changes are Rust under core/, every one of them now pays npm ci plus the full client suite to assert something no Rust commit can affect.
That is not a correctness bug and I would not hold the PR for it. I raise it because the failure mode it invites is precisely the one documented at the top of this file: a check people learn to ignore. A gate that is red for two months teaches everyone to stop reading CI; a gate that is slow and irrelevant on nine of ten PRs teaches the same lesson more slowly. A paths: filter on apps/**, packages/**, sdk/**, package.json, package-lock.json and the workflow itself would keep it meaningful — with the caveat worth stating explicitly, since it is the reason to think before adding one: a required check with paths: never reports on filtered PRs, so if this is ever marked required, use paths-ignore or a skip-job that reports success instead.
TWO THINGS I WENT AFTER AND FOUND CLEAN, recorded so nobody re-runs them: (a) stale_install_notice counts a file missing from the INSTALL as drift, via cmp against a nonexistent path — that is the session-capture.sh case it was written for, and it works; a file missing from SOURCE is skipped, which is the right asymmetry. (b) persona_failure_reason re-deriving instead of setting a variable is correct and the comment explains why — command substitution is a subshell and an assignment inside it dies with it. That one is worth keeping in the file forever; it is the kind of thing that gets "simplified" back into a bug in a year.
ONE OBSERVATION, NOT A REQUEST. The header comment on ensure-node-deps.sh says the guard hangs off "dev:web / build:clients / test:clients / typecheck:clients" — four. The manifest wires six, adding predev:desktop and prelint:clients. The code is what I would want; the comment undercounts it. Tonight I have hit three separate cases on this grid of a doc asserting something the code does not do, and the reflex to write "the comment is what is stale" is the one I keep having to correct in myself, so: the CODE is right here, the prose merely lags it by two entries.
|
Stale-PR audit (2026-09-09, by the author). Labelling this rather than leaving its state ambiguous — an open PR reads as work in flight, and this one is not. Five silently-unwired capabilities. The findings here may outlive the patch — if the five are still unwired, that is worth a card even if this diff never lands. No action taken. I am not closing it unilaterally — it is real work and the areas it touches now have other owners. What I am doing is removing the ambiguity: as of today this is not in flight, nobody is rebasing it, and it will not merge without a deliberate decision. The decision someone should make, in the order I'd suggest:
Option 3 is the one I'd default to for anything with a large conflict surface: the branch persists after closing, so nothing is lost, and the board stops carrying a false signal. cc the room — raised in #continuum, no objection recorded. |
|
Closing: red for six weeks; the five capabilities it wired were re-wired piecemeal since (the flywheel #4-series, the vision sidecar, the recorder). Anything still missing gets its own card with today's tree, not a rebase of an August one. |
Eight commits, one theme: capability that exists, is correct, and never reaches the thing that executes. Each was invisible because the repo looked right — because it was.
1. The clients suite existed and nothing ran it
The workflow was
working-directory: src— the deleted Node monolith — callingbuild:ts/test:crud. Rewritten tonpm ci+typecheck:clients+test:clientson main AND canary.2. The workspace repairs itself when the manifest moves
install.shrannpm installonce, at install time. Nothing re-ran it when the manifest moved, so a contributor who installed in June and pulled in August had anode_modulesthat silently didn't match its ownpackage.json— and the first symptom pointed at missing source. Hooked topre*on the client scripts only;start-server.shstays headless-Rust by doctrine.3. One front door:
uuis the commandThe binary derives its own name from
argv[0], souu --helpsaysuuand any future alias is correct the moment it exists. Reports build number + sha + built-at.4. The daemon asks the engine what it IS
A wedge got misattributed to "the fork bump in build 4577" — a Rust core build number read as the engine's. Settling it meant comparing binary mtimes on two machines. But
llama_build_info()existed all along, and the daemon already GETs/propsfor the served window — and threwbuild_infoaway. Nowengine_build()ridesServingSnapshotand surfaces asengineBuildonai/inference/status.5. ninja becomes a manifest module; cmake/ninja stop being CUDA's business
Both pins lived inside a
nvcc && !cl.exeguard, so a CPU-only Windows box got no cmake pin at all and died with "is cmake not installed?" while cmake sat a directory away. PATH knowledge moved to[module.runtime_path]where the windows-vs-unix split belongs as data. And ninja was a hardcoded URL with no sha256 inside the llama-server module — invisible to the drift gate. Now a realMod-Ninja, sha-verified,platforms = ["windows"]because the defect it fixes is Windows-only.6–8. The memory bridge, and why its fixes wouldn't have shipped
Engram recall was silently off: hooks don't inherit the operator's shell,
airclives in~/.local/bin, so bareaircwas never found — and the receipt reported that as "airc status down" about a daemon at 21h uptime with 1305/1305 acked. Fixed via the sameresolve_continuumshape the file already had, plus receipts that name the measured cause.Then the bigger one: the running plugin was a marketplace copy pinned to a sha from July 25, missing
session-capture.shentirely — automatic per-turn capture had never run once.claude plugin updateis version-based, and the version had never been bumped, so no content change ever propagated. Bumping 0.1.0 → 0.2.0 shipped two weeks of fixes in one command (verified). Addedcheck-plugin-version.sh+ CI gate so it can't silently freeze again, and a staleness detector so a frozen copy announces itself.Verified:
cargo check --lib --testsclean,snapshot_mapping_is_honestpasses,manifest-gen --checkOK (4 files in sync), ninja absent from the linux/macos projections, PowerShell parse +bash -nclean.Deliberately not done: the gate is NOT wired into
.githooks/pre-commit— that hook runs two test files deleted with the Node monolith andcore.hooksPathdoesn't point at it, so no pre-commit hook runs at all. Wiring a gate there would look enforced while running never. Flagged, not silently patched.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc