ruvnet-brain 4.0.36 → 4.2.2-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +28 -5
  2. package/bin/install.mjs +405 -23
  3. package/data/model-catalog.json +104 -15
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/brain-console.md +72 -9
  8. package/plugin/commands/configure.md +67 -21
  9. package/plugin/commands/rvcb.md +72 -9
  10. package/plugin/hooks/codex-hooks.json +40 -33
  11. package/plugin/hooks/hook-contracts.json +14 -24
  12. package/plugin/hooks/hooks.json +7 -42
  13. package/plugin/mcp/server.mjs +23 -6
  14. package/plugin/scripts/adr-currency-gate.mjs +150 -0
  15. package/plugin/scripts/capability-registry.mjs +10 -1
  16. package/plugin/scripts/codex-hook-adapter.mjs +121 -19
  17. package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
  18. package/plugin/scripts/continuation-gate.mjs +148 -8
  19. package/plugin/scripts/decision-gate.mjs +428 -0
  20. package/plugin/scripts/decision-outcomes.mjs +231 -0
  21. package/plugin/scripts/degradation-watch.mjs +271 -0
  22. package/plugin/scripts/ground-ruvnet.sh +51 -11
  23. package/plugin/scripts/hijack-ruvnet.sh +11 -3
  24. package/plugin/scripts/hook-registry.mjs +48 -3
  25. package/plugin/scripts/hook-shim.mjs +58 -3
  26. package/plugin/scripts/identifier-preflight.mjs +134 -0
  27. package/plugin/scripts/learn-capture.sh +50 -1
  28. package/plugin/scripts/learn-flush.mjs +5 -5
  29. package/plugin/scripts/lesson-bridge.mjs +343 -0
  30. package/plugin/scripts/lesson-hooks.sh +26 -0
  31. package/plugin/scripts/lesson-promote.mjs +50 -0
  32. package/plugin/scripts/lesson-store.mjs +6 -1
  33. package/plugin/scripts/mcp-readiness.mjs +107 -0
  34. package/plugin/scripts/protect-brain-state.sh +9 -0
  35. package/plugin/scripts/runtime-preferences.mjs +40 -0
  36. package/plugin/scripts/session-snapshot-hook.mjs +15 -6
  37. package/plugin/scripts/spend-guard.mjs +125 -0
  38. package/plugin/scripts/unprompted-runtime.mjs +12 -2
  39. package/plugin/scripts/update-apply.mjs +7 -2
  40. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
  41. package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
  42. package/scripts/brain-score.mjs +261 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +38 -4
  45. package/scripts/card-from-source.mjs +114 -0
  46. package/scripts/console-engine.mjs +1 -1
  47. package/scripts/corpus-candidate.mjs +294 -0
  48. package/scripts/corpus-reconcile.mjs +273 -0
  49. package/scripts/corpus-seed-publish.mjs +110 -0
  50. package/scripts/health-repair.mjs +11 -2
  51. package/scripts/ingest-new-repos.mjs +122 -0
  52. package/scripts/ingest-repo.mjs +66 -6
  53. package/scripts/learning-replay-cli.mjs +8 -3
  54. package/scripts/learning-replay-fixture.mjs +25 -6
  55. package/scripts/learning-replay-proof.mjs +38 -0
  56. package/scripts/nightly-wrapper.sh +55 -7
  57. package/scripts/onboarding-console.mjs +21 -1
  58. package/scripts/org-repo-count.mjs +119 -0
  59. package/scripts/rebuild-gists-from-receipts.mjs +246 -0
  60. package/scripts/release-transaction.mjs +30 -2
  61. package/scripts/release.mjs +147 -0
  62. package/scripts/repo-count-detector.mjs +62 -0
  63. package/scripts/restore-local-ingests.mjs +116 -0
  64. package/scripts/rvf-generation.mjs +44 -0
  65. package/scripts/selfcheck.mjs +9 -1
  66. package/scripts/stabilization-receipt.mjs +11 -1
  67. package/scripts/sync-census.mjs +0 -0
  68. package/scripts/sync-commands.mjs +117 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * degradation-watch.mjs — a printed warning is not a control. This turns one into a REFUSAL.
3
+ *
4
+ * THE FAILURE, 2026-08-13. `ruflo memory store` printed, on every write, for three days:
5
+ *
6
+ * [WARN] Data stored, but persistence is not guaranteed: sql.js fallback driver in use —
7
+ * this write may not be durably persisted to disk.
8
+ *
9
+ * Nothing persisted after 2026-08-10 17:38. Every lesson, checkpoint and session-end handoff went
10
+ * into a WASM buffer and evaporated, while the CLI printed `[OK] Data stored successfully`. Root
11
+ * cause, proven: `better_sqlite3.node` was built against NODE_MODULE_VERSION 141 and the running
12
+ * node needs 137, so the native bridge threw ERR_DLOPEN_FAILED and the driver fell back.
13
+ *
14
+ * I READ THAT WARNING AND CONTINUED. That is the defect, and it is not an information problem —
15
+ * the answer was ALSO in capability-cards.md, which I had printed to screen twenty minutes earlier:
16
+ * "a native SQLite ABI mismatch is degraded, not healthy: verify and rebuild the active
17
+ * better-sqlite3 bridge rather than treating a sql.js fallback as equivalent." Knowing was never
18
+ * missing. STOPPING was.
19
+ *
20
+ * So the cure cannot be printing it again, louder. This project already disproved that approach:
21
+ * the flywheel hook printed `NOT auto-surfaced: default=38` every session for EIGHT DAYS while six
22
+ * curated lessons never fired. A diagnostic nobody reads is the same as no diagnostic, and I am the
23
+ * "nobody" in both sentences.
24
+ *
25
+ * WHAT GROUNDING CHANGED HERE (search_ruvnet, receipt ede43691e2d5, against
26
+ * v3/@claude-flow/cli/src/commands/memory.ts). My first draft detected the DRIVER — "is it sql.js?"
27
+ * The real source says `// Use direct sql.js storage with automatic embedding generation`: sql.js is
28
+ * a legitimate path in ruflo, not a fault signal. A driver-identity check would have cried wolf on
29
+ * healthy installs, and a channel that cries wolf gets skimmed, which is the disease this file
30
+ * exists to cure. So the PROVER is the authority and it tests the only thing that actually matters:
31
+ * did the row survive to disk. Signatures are hints that trigger a probe, never the verdict.
32
+ *
33
+ * THREE PROPERTIES, all required or this is theatre:
34
+ * 1. DETECTION MUST NOT DEPEND ON ME NOTICING — the prover exercises the real path, because the
35
+ * success line lied: `[OK]` printed over rowcount 0.
36
+ * 2. THE VERDICT MUST REFUSE, NOT INFORM — an operation whose truth depends on durable storage
37
+ * cannot proceed while storage is not durable. "We learned X" is FALSE if X did not persist.
38
+ * 3. IT MUST FAIL ON THE BROKEN SHAPE — proven by mutation in the test, because "a guard that
39
+ * cannot fail" is the other bug I shipped today.
40
+ *
41
+ * WHY NOT "ALWAYS CONSULT MEMORY FIRST": measured the same day — when I graded the architecture from
42
+ * file counts, the facts I needed sat in FOUR reachable places and I read none. A fifth advisory
43
+ * surface cannot fix a failure caused by skipping four. Retrieval cures ignorance; only
44
+ * interception cures confidence.
45
+ */
46
+ import { execFileSync } from 'node:child_process';
47
+ import fs from 'node:fs';
48
+ import os from 'node:os';
49
+ import path from 'node:path';
50
+ // Required by the isMain guard below. Its try/catch would otherwise swallow the ReferenceError and
51
+ // return false, so the policy entrypoint would silently never run — a guard that cannot fire, which
52
+ // is the exact defect class this file exists to close. Caught by counting occurrences, not by
53
+ // reading the code and believing it.
54
+ import { fileURLToPath } from 'node:url';
55
+
56
+ /**
57
+ * THE PROBE TARGET IS A SCRATCH DB, NOT THE PROJECT'S.
58
+ *
59
+ * This was `process.cwd()/.swarm/memory.db`. The hook runs MACHINE-WIDE, so in any repo that is not
60
+ * ruvnet-brain it ran `ruflo memory store --path <that repo>/.swarm/memory.db` — CREATING a .swarm
61
+ * directory and writing a probe row into somebody else's project. An independent audit named it
62
+ * "unrelated-project mutation", and it is the plainest possible violation of this project's own rule
63
+ * (ADR-058 D5: never touch what we do not own). Shipped by me, today, in the hook whose entire
64
+ * purpose is to stop silent damage.
65
+ *
66
+ * The fix is not tighter scoping — it is noticing the question was mis-framed. "Does `ruflo memory
67
+ * store` durably persist?" is a property of the SQLite DRIVER and its ABI against the running node.
68
+ * That is machine-wide. It has nothing to do with which repo you happen to be standing in, so a
69
+ * scratch database answers it exactly as well, mutates nothing, and additionally covers the case an
70
+ * audit flagged separately: with a real project path, an ABSENT store had to be skipped, which made
71
+ * the store-CREATING first write the one write the guard could never falsify. A scratch path is
72
+ * always absent and always created — the first-write case is now the ONLY case.
73
+ */
74
+ const defaultDb = () => path.join(os.tmpdir(), `ruvnet-durability-probe-${process.pid}.db`);
75
+
76
+ function defaultRun(cmd, args) {
77
+ return execFileSync(cmd, args, { encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
78
+ }
79
+
80
+ /**
81
+ * THE ONLY ACCEPTABLE PROOF OF A WRITE: store, then RETRIEVE that exact key through the managed
82
+ * interface and read the value back. Explicitly NOT evidence, each having been believed once: the
83
+ * `[OK]` success line, a semantic-search hit, the .db mtime, the daemon being up, or which driver is
84
+ * loaded. And explicitly NOT permitted, per issue #140: opening the managed store with `sqlite3`.
85
+ */
86
+ export function proveMemoryDurable(dbPath = defaultDb(), { run = defaultRun } = {}) {
87
+ // AN ABSENT STORE IS NOT AN EXEMPTION. This returned `{ok:true, skipped:true}` when the file did
88
+ // not exist, which — as an independent audit put it — means "the first `ruflo memory store`, the
89
+ // operation that CREATES the store, is precisely the write the guard cannot falsify." That is a
90
+ // hole shaped exactly like the moment durability matters most: a fresh machine, first write, and
91
+ // the sql.js fallback silently swallowing it. `ruflo memory store` creates the file, so probing an
92
+ // absent path is a valid question with a real answer; only a path we cannot even attempt is a skip.
93
+ const key = `durability-probe-${process.pid}-${Date.now()}`;
94
+ try {
95
+ // THE PROOF IS THE RETRIEVED VALUE, NOT A SQL ROWCOUNT (issue #140, @sparkling).
96
+ //
97
+ // This read the row back with `sqlite3`, which opens a Ruflo-MANAGED store directly — the exact
98
+ // boundary violation ADR-063 and `hijack-ruvnet` exist to stop, committed by the hook that
99
+ // guards durability. The justification was real at the time: a write could report success and
100
+ // persist nothing. But rUv closed that upstream in v3.32.34 ("No manual SQL is required"; the
101
+ // bridge now FAILS CLOSED and reports the native error instead of a misleading fallback), and
102
+ // the 2026-08-13 incident confirms retrieve was always sufficient — it answered `Key not found`
103
+ // on precisely the writes that had evaporated. Verified on ruflo 3.38.12 before this change:
104
+ // the round-trip returns the stored VALUE, and a damaged store answers `[ERROR] no such table`
105
+ // rather than a false success.
106
+ const probeValue = `probe-${key}`;
107
+ run('ruflo', ['memory', 'store', '--path', dbPath, '-n', 'default', '-k', key, '--value', probeValue]);
108
+ const back = run('ruflo', ['memory', 'retrieve', '--path', dbPath, '-n', 'default', '-k', key]);
109
+ if (!String(back).includes(probeValue)) {
110
+ return { ok: false, why: 'stored a key and retrieving it did not return the value — the store reports a success it cannot honour' };
111
+ }
112
+ return { ok: true, why: 'store → retrieve round-trip returned the exact value through the managed interface' };
113
+ } catch (e) {
114
+ // "CANNOT PROBE" IS NOT "PROBED AND FAILED", and collapsing them shipped the worst
115
+ // stranger-facing bug in this repo. Measured with PATH=/usr/bin:/bin — i.e. most machines that
116
+ // install this plugin but not ruflo — EVERY `git push`, `npm publish` and `gh release create`
117
+ // was refused, with instructions to `npm rebuild better-sqlite3` in a package the user never
118
+ // installed. Same for a missing `sqlite3`, which is normal on Linux and Windows.
119
+ //
120
+ // The rule was already written, one file away, in identifier-preflight.mjs's header: "FAIL OPEN.
121
+ // An identifier this cannot resolve is ALLOWED, silently... a fabricated diagnosis is worse than
122
+ // no check, because it burns the credibility the channel runs on." That header even cites THIS
123
+ // file as the sibling whose bug taught the rule. The rule was recorded and not applied to the
124
+ // file it was learned from — the same shape as freshness machinery pointed at coverage but not
125
+ // the eval, and resolveBash existing but unused at a new call site.
126
+ //
127
+ // A missing binary means this machine cannot answer the question. It does not mean the answer
128
+ // is "broken".
129
+ const msg = String(e?.message ?? e);
130
+ if (/ENOENT|not found|spawnSync .* ENOENT/i.test(msg)) {
131
+ return { ok: true, why: `cannot probe on this machine (${msg.split('\n')[0]}) — declining to guess`, skipped: true };
132
+ }
133
+ return { ok: false, why: `probe could not complete: ${msg.split('\n')[0]}` };
134
+ } finally {
135
+ // The scratch db is ours alone; remove it and its WAL siblings rather than leaving litter in tmp.
136
+ for (const suffix of ['', '-wal', '-shm']) {
137
+ try { fs.unlinkSync(dbPath + suffix); } catch { /* never existed, or already gone */ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * A degradation is only worth a channel if something PROVES it. `detect` spots a smell in output a
144
+ * tool already printed and is a HINT ONLY — it decides when to probe, never what is true.
145
+ */
146
+ export const SIGNATURES = [
147
+ {
148
+ id: 'memory-not-durable',
149
+ detect: /persistence is not guaranteed|NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|not durably persisted/i,
150
+ what: 'the memory store is not durably persisting writes',
151
+ costs: 'every lesson, checkpoint and handoff is silently lost — the learning loop is severed',
152
+ blocks: ['claim-done', 'ship', 'record-lesson'],
153
+ fix: 'npm rebuild better-sqlite3 in $(npm root -g)/ruflo, then re-run the round-trip probe',
154
+ prove: proveMemoryDurable,
155
+ },
156
+ ];
157
+
158
+ /** Spot a known degradation in text a tool printed. A hint that a probe is warranted — not a verdict. */
159
+ export function detectIn(text, signatures = SIGNATURES) {
160
+ if (!text) return [];
161
+ return signatures.filter((s) => s.detect.test(text));
162
+ }
163
+
164
+ /**
165
+ * Run every prover and return only the degradations that are REAL RIGHT NOW. A signature its prover
166
+ * clears is not reported, because a warning channel that cries wolf gets skimmed — which is exactly
167
+ * how three days of memory were lost.
168
+ */
169
+ export function activeDegradations(signatures = SIGNATURES, opts = {}) {
170
+ const out = [];
171
+ for (const s of signatures) {
172
+ const verdict = s.prove ? s.prove(opts.dbPath, opts) : { ok: false, why: 'no prover — unprovable claims are treated as failures' };
173
+ if (!verdict.ok) out.push({ ...s, verdict });
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * THE REFUSAL — the property that makes a warning stop being skimmable, because it is no longer
180
+ * text, it is a closed door. Only operations whose truth DEPENDS on durable storage are blocked;
181
+ * blocking everything would make this the next thing routed around.
182
+ */
183
+ export function refusalFor(event, degradations) {
184
+ const hit = degradations.find((d) => d.blocks.includes(event));
185
+ if (!hit) return null;
186
+ return {
187
+ allow: false,
188
+ policy: 'degradation-watch',
189
+ reason:
190
+ `BLOCKED — ${hit.what}.\n`
191
+ + ` proof: ${hit.verdict.why}\n`
192
+ + ` cost: ${hit.costs}\n`
193
+ + ` fix: ${hit.fix}\n`
194
+ + 'This refuses rather than warns on purpose: the warning WAS printed, on every write, for '
195
+ + 'three days, and read. Repair the store, then repeat the action.',
196
+ };
197
+ }
198
+
199
+ /**
200
+ * WHICH COMMANDS DEPEND ON DURABLE MEMORY. The probe shells out to ruflo + sqlite3, so running it on
201
+ * every Bash call would add seconds to every command and become the next thing someone disables.
202
+ * It fires only where a false success actually costs something: recording a lesson into a store that
203
+ * drops it, or shipping while claiming the project learned something it did not.
204
+ */
205
+ export const DEPENDENT_COMMANDS = [
206
+ // `cat lesson-bridge.mjs` used to match `record-lesson`, so READING the file needed to fix a
207
+ // degradation was refused while degraded. The verb has to be in the pattern, not just the noun.
208
+ { event: 'record-lesson', match: /\bruflo\s+memory\s+store\b|lesson-bridge\.mjs\s+--apply/ },
209
+ // `git -C <path> push` and `git --git-dir=… push` are the forms an agent with absolute paths
210
+ // actually writes — this environment's own instructions mandate them — and the first version
211
+ // matched neither. Two independent audits caught that the same day, and both also caught the
212
+ // inverse: `grep -n "npm publish" docs/…` matched, so reading ABOUT shipping counted as shipping.
213
+ { event: 'ship', match: /\bgit\b[^|;&]*\bpush\b|\b(?:npm|yarn|pnpm)\s+publish\b|\bgh\s+release\s+create\b|release\.mjs/ },
214
+ ];
215
+
216
+ /**
217
+ * QUOTED TEXT IS AN ARGUMENT, NOT A COMMAND. The same correction identifier-preflight.mjs needed
218
+ * hours earlier: the truth-maker is what will EXECUTE, and a prompt, a grep pattern or a commit
219
+ * message is not that. Without it, `git commit -m "ready to git push"` reads as shipping.
220
+ */
221
+ const executablePart = (cmd) => String(cmd || '').replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ');
222
+
223
+ export function dependentEvent(command, table = DEPENDENT_COMMANDS) {
224
+ const cmd = executablePart(command);
225
+ return table.find((c) => c.match.test(cmd))?.event ?? null;
226
+ }
227
+
228
+ /**
229
+ * THE CACHE IS GONE, AND ITS REMOVAL IS THE FIX.
230
+ *
231
+ * It was keyed `/tmp/ruvnet-degradation-$USER.json` while the probe targets a PER-PROJECT path. Two
232
+ * independent adversarial audits, blind to each other, each found a different bug in it on the same
233
+ * day — which is the strongest signal available that the cache, not its key, was the defect:
234
+ *
235
+ * · a DEGRADED project cached ok:false, so `git push` in a HEALTHY project was refused for five
236
+ * minutes citing another repo's evidence;
237
+ * · a HEALTHY project cached ok:true, so a BROKEN project shipped inside the TTL without ever
238
+ * being probed — the direction that actually costs data.
239
+ *
240
+ * The probe runs only on `git push`, `npm publish` and `ruflo memory store`: rare, deliberate acts
241
+ * where one or two seconds is invisible and a wrong answer is expensive. Caching bought latency
242
+ * nobody was asking for and sold correctness to pay for it. A cache whose key is not the thing that
243
+ * determines the answer is a restated fact, which is the defect this whole file exists to close.
244
+ */
245
+ export function cachedDegradations(opts = {}) {
246
+ return activeDegradations(SIGNATURES, opts);
247
+ }
248
+
249
+ /**
250
+ * POLICY ENTRYPOINT — spawned by decision-gate.mjs exactly like the other refusers: payload on
251
+ * stdin, exit 0 to allow, exit 2 to refuse with the reason on stderr. Deliberately NOT a new hook
252
+ * mechanism; the invariant this repo already paid for is that ONE process refuses a given tool call.
253
+ */
254
+ const isMain = (() => {
255
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
256
+ catch { return false; }
257
+ })();
258
+
259
+ if (isMain) {
260
+ let payload = '';
261
+ try { payload = fs.readFileSync(0, 'utf8'); } catch { /* no stdin is not a refusal */ }
262
+ let input = {};
263
+ try { input = JSON.parse(payload)?.tool_input ?? {}; } catch { /* malformed payload degrades to allow */ }
264
+ const event = dependentEvent(input.command);
265
+ // The overwhelmingly common case: this command does not depend on durable memory. Cost ~0ms.
266
+ if (!event) process.exit(0);
267
+ const refusal = refusalFor(event, cachedDegradations());
268
+ if (!refusal) process.exit(0);
269
+ process.stderr.write(`⛔ ${refusal.reason}\n`);
270
+ process.exit(2);
271
+ }
@@ -177,14 +177,42 @@ fi
177
177
  # not prove that the store command wrote the canonical database.
178
178
 
179
179
  # ── Self-learning flywheel (ruflo ≥3.24, ADR-176) — OFFER it, never switch it on for them ────────
180
- # Opt-in is a single env var; `harnessLoopOptedIn()` in @claude-flow/cli reads process.env directly,
181
- # so a project enables it via .claude/settings.json `env`. Unset is a true no-op. We detect BOTH so
182
- # an already-enabled project is never nagged.
180
+ #
181
+ # ISSUE #138 — CONFIGURED IS NOT OPERATIONAL, AND THIS CONFLATED THEM.
182
+ #
183
+ # The previous detector treated ANY occurrence of the string RUFLO_HARNESS_LOOP in
184
+ # .claude/settings.json as proof the flywheel was on, and then went silent. That is not ruflo's
185
+ # contract. Verified against rUv's source at f35c545, not recalled:
186
+ #
187
+ # harness-worker.ts:41 /^(1|true|yes|on)$/i.test(process.env.RUFLO_HARNESS_LOOP ?? '')
188
+ # harness-worker.ts:54 if (!optedIn) return { ran: false, reason: 'opt-in required (…)' }
189
+ #
190
+ # It reads the DAEMON PROCESS ENVIRONMENT. A Claude settings file does not put anything into the
191
+ # environment of a daemon launched by Codex, launchd, systemd, a monitor, or another shell — so the
192
+ # grep proved a FILE MENTIONS A NAME, never that the running daemon inherited it. The Brain then
193
+ # stayed quiet while ruflo recorded "opt-in required" every cycle: a false ENABLED state, which is
194
+ # strictly worse than a false disabled one because nobody goes looking.
195
+ #
196
+ # Same shape as the console learner card (#136): a surface reporting a proxy as if it were the
197
+ # answer. The rule is `lesson-audit-the-answer-not-the-wiring` — a mention is not a measurement.
198
+ #
199
+ # So there are now THREE states, and only the process environment can prove the third:
200
+ # off nothing set anywhere → offer it
201
+ # declared a settings file names it, but → say so, and say what is still missing
202
+ # THIS process did not inherit it
203
+ # on this process HAS it truthy → stay silent, it is genuinely opted in
183
204
  FLYWHEEL=off
184
205
  case "${RUFLO_HARNESS_LOOP:-}" in 1|true|yes|on|TRUE|Yes|On) FLYWHEEL=on ;; esac
185
206
  if [ "$FLYWHEEL" = "off" ] && grep -qs 'RUFLO_HARNESS_LOOP' .claude/settings.json .claude/settings.local.json 2>/dev/null; then
186
- FLYWHEEL=on
207
+ FLYWHEEL=declared
187
208
  fi
209
+ # THE SECOND GATE, and it is not optional (ruflo #2840 / PR #2848). A downstream project must ALSO
210
+ # carry a hash-pinned project-local anchor or the runtime fails closed with
211
+ # "project-local flywheel anchor required" — harness-project-anchor.ts:186. Its canonical path is
212
+ # DEFAULT_PROJECT_ANCHOR_MANIFEST = .claude/eval/flywheel-anchor.manifest.json (same file, :32).
213
+ # Guidance that omits it cannot produce a working setup, which is why "TURN IT ON" below now names it.
214
+ ANCHOR=missing
215
+ [ -f .claude/eval/flywheel-anchor.manifest.json ] && ANCHOR=present
188
216
 
189
217
  # The cadence belongs to the hook, not to an instruction asking the model to remember whether it
190
218
  # already spoke (issue #53). A directory create is the portable atomic primitive here: for one
@@ -208,18 +236,30 @@ claim_flywheel_day() {
208
236
  )
209
237
  }
210
238
 
211
- if [ "$RUFLO_STATE" = "yes" ] && [ "$FLYWHEEL" = "off" ] && claim_flywheel_day; then
239
+ if [ "$RUFLO_STATE" = "yes" ] && [ "$FLYWHEEL" != "on" ] && claim_flywheel_day; then
212
240
  cat <<'EOF'
213
- [RuvNet Brain — the self-learning flywheel is available here and switched OFF]
214
- This project runs Ruflo, and ruflo ≥3.24 ships a self-optimizing flywheel that is OFF by default. Offer it plainly and warmly — then drop it. NEVER enable it without an explicit yes. The hook enforces the delivery cadence; do not repeat this advisory yourself.
241
+ [RuvNet Brain — the self-learning flywheel is available here and is NOT running]
242
+ This project runs Ruflo, and ruflo ≥3.24 ships a self-optimizing flywheel that is OFF by default.
243
+ IF a settings file already names RUFLO_HARNESS_LOOP, say so plainly and do not call it enabled: a
244
+ settings entry is not the daemon's environment, and ruflo reports "opt-in required" until the
245
+ process that runs the loop actually carries it. Configured is not operational. Offer it plainly and warmly — then drop it. NEVER enable it without an explicit yes. The hook enforces the delivery cadence; do not repeat this advisory yourself.
215
246
 
216
247
  Explain it in the user's words, not the plumbing's:
217
248
  - WHAT IT IS: ruflo can improve one of its own operating policies over time — specifically how it finds the right stored memory for a query (the BM25/cosine blend, diversity, etc.). Each candidate is scored against a frozen, human-labelled relevance set; a change is promoted ONLY if it measurably beats the current champion on held-out data. Every promotion leaves a signed receipt you can replay yourself, and a drift canary auto-rolls-back on regression. Think "git for decision-policies": each generation has a parent, a diff, evidence, a signature, and an immutable root you can walk back to.
218
249
  - WHY IT'S DIFFERENT: most "self-improving agent" claims are unfalsifiable. This one only turns the wheel when an improvement is independently proven, and it hands you the receipt.
219
250
  - WHAT IT COSTS: $0. No LLM calls and no network on the decision path.
220
- - HONEST CAVEAT (say this, don't hide it): it stays idle until the project has accumulated at least 12 stored neural patterns to harvest a corpus from. Before that it reports "store too small" and does nothing. On a brand-new project that means it earns its keep later, not today.
221
- - TURN IT ON: add {"env":{"RUFLO_HARNESS_LOOP":"1"}} to .claude/settings.json, then `ruflo daemon start`
222
- (the GLOBAL binary — never `npx ruflo@latest`, which runs its own private copy and hides drift).
251
+ - HONEST CAVEAT (say this, don't hide it): it stays idle until the project has accumulated at least 12 stored neural patterns to harvest a corpus from — and GENERATION needs more still: at least 20 harvested held-out tasks. Before that it reports "store too small" and does nothing. On a brand-new project it earns its keep later, not today.
252
+ - TURN IT ON — BOTH steps, or ruflo fails closed (verified in rUv's source at f35c545, not recalled):
253
+ 1. the DAEMON'S OWN ENVIRONMENT must carry it. `harness-worker.ts:41` tests
254
+ `process.env.RUFLO_HARNESS_LOOP` inside the daemon process, so a Claude settings file only
255
+ helps if the daemon is launched from a shell that inherited it. Export it where the daemon
256
+ actually starts, then `ruflo daemon start` (the GLOBAL binary — never `npx ruflo@latest`,
257
+ which runs its own private copy and hides drift).
258
+ 2. a PROJECT-LOCAL ANCHOR is required for a downstream repo (ruflo #2840 / PR #2848):
259
+ `.claude/eval/flywheel-anchor.manifest.json`, hash-pinned. Without it the runtime refuses with
260
+ "project-local flywheel anchor required" — `harness-project-anchor.ts:186`.
261
+ Verify it is genuinely running rather than merely configured: `ruflo hooks intelligence --status`
262
+ in THIS project, and read the reason it prints. "opt-in required" or "anchor required" means off.
223
263
  - TURN IT OFF: remove that env var (and `RUFLO_DAEMON_AUTOSTART=0` stops the daemon auto-starting).
224
264
 
225
265
  Offer like this: "Ruflo can quietly tune how it recalls memory — testing changes against a frozen benchmark and only keeping what provably wins, with a receipt you can replay. It's free, it's off by default, and it does nothing until this project has enough history. Want me to turn it on?" If they decline, respect it and never raise it again unless they ask.
@@ -383,7 +423,7 @@ This task touches the RuvNet / rUv stack. Your training priors here are STALE an
383
423
  - You MUST call the `search_ruvnet` MCP tool and ground your answer in the cited source paths it returns BEFORE stating what any RuvNet tool can/cannot do or which one to use. Asserting a RuvNet capability from memory, without a cited source path, is an error — do not do it.
384
424
  - If a needed RuvNet repo isn't covered yet, ingest it first (`node scripts/ingest-repo.mjs --name <repo>`) rather than guessing.
385
425
  - SIGNAL that the brain is working so the user has confidence it's in charge: when you ground an answer, say so briefly (e.g. "Grounding in rUv's source…" → cite the path). Silent grounding leaves the user unsure it's even on.
386
- - MEMORY DIAGNOSIS: never infer a broken write from DB/WAL mtime, semantic-search misses, daemon startup, or the CLI success line. Prove it against ONE canonical absolute path: unique-key `ruflo memory store --path <project>/.swarm/memory.db` → exact-key `ruflo memory retrieve --path ...` → exact SQL row. Only then call it miswired.
426
+ - MEMORY DIAGNOSIS: never infer a broken write from DB/WAL mtime, semantic-search misses, daemon startup, or the CLI success line. Prove it against ONE canonical absolute path: unique-key `ruflo memory store --path <project>/.swarm/memory.db` → exact-key `ruflo memory retrieve --path ...` and read the returned VALUE. NEVER raw sqlite3 on a managed store (#140; rUv v3.32.34: "No manual SQL is required"). Only then call it miswired.
387
427
  EOF
388
428
  fi
389
429
 
@@ -128,11 +128,19 @@ if [ "$_direct_managed_access" = "1" ]; then
128
128
  printf '%s' "$PAYLOAD" | grep -qiE '(insert|update|delete|drop|alter|create|replace|vacuum|pragma[[:space:]]+[a-z_]+[[:space:]]*=|\.import|\.restore)' && _is_write=1
129
129
 
130
130
  if [ "$_boundary" = "block" ] || { [ "$_boundary" = "read-only" ] && [ "$_is_write" = "1" ]; }; then
131
+ # NAME THE STORE THEY ACTUALLY REACHED FOR (#140). The reporter was at a USER-level store,
132
+ # `~/.claude-flow/user-memory.db`; a refusal that hardcodes <project>/.swarm/memory.db points
133
+ # them at a DIFFERENT file, so the "sanctioned path" reads as not understanding the situation
134
+ # — and an agent that cannot see its own store in the substitute goes back to guessing schema,
135
+ # which is the exact failure #103 measured nine times.
136
+ _target_store=$(printf '%s' "$PAYLOAD" | grep -oiE '[^ "]*(\.swarm/[a-zA-Z-]*memory\.db|user-memory\.db|agentdb[a-zA-Z-]*\.db|memory\.db)' | head -n 1)
137
+ [ -z "$_target_store" ] && _target_store="<project>/.swarm/memory.db"
131
138
  printf '%s\n' "[RuvNet Brain] REFUSED: direct access to a Ruflo-managed memory store." >&2
132
139
  printf '%s\n' "Your setting managedMemoryBoundary=$_boundary refuses this. Ruflo owns these stores; two writers on one file is how they corrupt." >&2
133
- printf '%s\n' "Use the sanctioned path instead — it needs no schema knowledge:" >&2
134
- printf '%s\n' " ruflo memory search -q \"<query>\" --path <project>/.swarm/memory.db" >&2
135
- printf '%s\n' " ruflo memory store -k \"<key>\" --value \"<text>\" --path <project>/.swarm/memory.db" >&2
140
+ printf '%s\n' "Use the sanctioned path instead — it needs no schema knowledge, and --path reaches ANY store, user-level ones included:" >&2
141
+ printf '%s\n' " ruflo memory search -q \"<query>\" --path $_target_store" >&2
142
+ printf '%s\n' " ruflo memory retrieve -k \"<key>\" --path $_target_store # exact key; the returned VALUE is the proof" >&2
143
+ printf '%s\n' " ruflo memory store -k \"<key>\" --value \"<text>\" --path $_target_store" >&2
136
144
  printf '%s\n' "To allow this, set managedMemoryBoundary back to 'advise' (or 'read-only' for reads) in the Console." >&2
137
145
  exit 2
138
146
  fi
@@ -18,17 +18,28 @@
18
18
  * plus the derived fields the lint needs (`handler`, `shimId`, `codeRoot`, `hasFailsafe`,
19
19
  * `declaredMode`, `tools`, `anchored`, `asyncRewake`, `contractSource`).
20
20
  *
21
- * THE SIX REGISTRIES (ADR-055 appendix A), and one deliberate split:
21
+ * THE SEVEN REGISTRIES (ADR-055 appendix A plus the Codex host, added 2026-08-20 — Dream Cycle
22
+ * cross-host-conformance finding: this file enumerated six registries and Codex's was not one of
23
+ * them, so none of M1/M3/M5/M6 had ever run over `codex-hooks.json`), and one deliberate split:
22
24
  *
23
25
  * layer | file | inMesh
24
26
  * ---------------------|---------------------------------------------------------|-------
25
27
  * plugin | <repo>/plugin/hooks/hooks.json | yes
28
+ * codex | <repo>/plugin/hooks/codex-hooks.json | yes
26
29
  * user | ~/.claude/settings.json | yes
27
30
  * project | <repo>/.claude/settings.json | yes
28
31
  * third-party:<name> | <plugin install>/hooks/hooks.json (enabled plugins only)| yes
29
32
  * plugin-installed | ~/.claude/plugins/cache/ruvnet-brain/<v>/hooks/hooks.json | NO (mirror)
30
33
  * marketplace-clone | ~/.claude/plugins/marketplaces/ruvnet-brain/…/hooks.json | NO (mirror)
31
34
  *
35
+ * CODEX RECORDS NEVER ROUTE THROUGH `hook-shim.mjs` — Codex's own wrapper chain
36
+ * (`codex-hook-wrapper.mjs` → `codex-hook-adapter.mjs`) resolves the active spine generation and
37
+ * dispatches by a trailing CLI token instead (`codexDispatchIdIn()`, below). That token is drawn
38
+ * from the SAME id space as `hook-shim.mjs`'s dispatch TABLE — verified 2026-08-20: every id
39
+ * `codex-hooks.json` uses (`decision-gate`, `route-dispatch`, `learn-capture`, …) already exists as
40
+ * a table key — so a codex registration's `mode`/`offBehavior` resolve from that one table too,
41
+ * exactly like a plugin registration. No new contract file, no invented declaration.
42
+ *
32
43
  * The last two are the SAME registrations as `plugin`, delivered as different code copies — the
33
44
  * repo copy is the preimage, the cache copy is what Claude Code booted, the marketplace clone is
34
45
  * what the user layer's own commands execute from. Counting all three in the mesh would invent 30
@@ -105,6 +116,10 @@ export const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
105
116
  export const TOOLS = Object.freeze([
106
117
  'Task', 'TaskStop', 'Agent', 'Bash', 'BashOutput', 'Read', 'Write', 'Edit', 'MultiEdit',
107
118
  'NotebookEdit', 'NotebookRead', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'TodoWrite', 'Skill',
119
+ // Codex's own tool names (ADR-051) — `exec_command` and `apply_patch` are what `codex-hooks.json`
120
+ // matchers actually target; without them here M5 could not see whether a Codex matcher anchors
121
+ // the tool it claims to guard.
122
+ 'exec_command', 'apply_patch',
108
123
  ]);
109
124
 
110
125
  /** `*` and `.*` and `` are Claude Code's "everything" spellings; `*` is not a legal regex. */
@@ -149,6 +164,19 @@ export function shimIdIn(command) {
149
164
  return m ? m[1] : null;
150
165
  }
151
166
 
167
+ /**
168
+ * The Codex wrapper's dispatch id. `codex-hooks.json` never names `hook-shim.mjs` — its inline
169
+ * bootstrap (`node -e "<script>" <budgetMs> <hookId>[ <extra>]`) spawns `codex-hook.mjs` and hands
170
+ * it `<hookId>` as `process.argv[2]`, exactly as `codex-hook-wrapper.mjs` itself reads it
171
+ * (`const hookId = process.argv[2] || '';`). The closing quote of the `-e` argument followed by a
172
+ * bare number is what marks where the CLI args begin; verified against all 16 live entries
173
+ * 2026-08-20, zero false matches.
174
+ */
175
+ export function codexDispatchIdIn(command) {
176
+ const m = (command ?? '').match(/"\s+\d+\s+([a-zA-Z][\w-]*)/);
177
+ return m ? m[1] : null;
178
+ }
179
+
152
180
  /**
153
181
  * hook-shim.mjs's dispatch TABLE, parsed rather than re-implemented — it is the authority for
154
182
  * `mode` and `offBehavior` on every shim-routed registration (ADR-054 §3: the OFF contract lives in
@@ -313,8 +341,13 @@ export function discoverSources({ repo = REPO, home = os.homedir(), includeMachi
313
341
  // so the mesh census silently described a machine with no shipped hooks at all.
314
342
  const pluginHooks = ['plugin/hooks/hooks.json', 'hooks/hooks.json']
315
343
  .map((rel) => path.join(repo, rel));
344
+ const codexHooks = ['plugin/hooks/codex-hooks.json', 'hooks/codex-hooks.json']
345
+ .map((rel) => path.join(repo, rel));
316
346
  const sources = [
317
347
  { layer: 'plugin', file: pluginHooks.find((f) => fs.existsSync(f)) ?? pluginHooks[0], role: 'shipped', inMesh: true, reachesStrangers: true, machineLocal: false },
348
+ // Repo-owned and shipped exactly like `plugin` (package.json ships the whole `plugin/` tree) —
349
+ // just a different host manifest, so it belongs in the same CI-gated block, not the machine one.
350
+ { layer: 'codex', file: codexHooks.find((f) => fs.existsSync(f)) ?? codexHooks[0], role: 'shipped', inMesh: true, reachesStrangers: true, machineLocal: false },
318
351
  { layer: 'project', file: path.join(repo, '.claude/settings.json'), role: 'active', inMesh: true, reachesStrangers: false, machineLocal: false },
319
352
  ];
320
353
  if (includeMachine) {
@@ -345,7 +378,12 @@ export function buildRegistry({ repo = REPO, home = os.homedir(), includeMachine
345
378
  try { regs = readRegistrations(src.file); } catch (e) { errors.push({ file: src.file, layer: src.layer, error: String(e.message ?? e) }); continue; }
346
379
  for (const r of regs) {
347
380
  const shimId = shimIdIn(r.command);
348
- const shim = shimId ? table[shimId] : null;
381
+ // Codex commands never name hook-shim.mjs, so shimId is always null there — fall back to the
382
+ // Codex wrapper's own dispatch token, which is drawn from the same table (see the header note
383
+ // above codex-hooks.json's row in this file's doc comment).
384
+ const codexHookId = !shimId && src.layer === 'codex' ? codexDispatchIdIn(r.command) : null;
385
+ const dispatchId = shimId ?? codexHookId;
386
+ const shim = dispatchId ? table[dispatchId] : null;
349
387
  const base = {
350
388
  layer: src.layer,
351
389
  file: src.file,
@@ -363,7 +401,8 @@ export function buildRegistry({ repo = REPO, home = os.homedir(), includeMachine
363
401
  inMesh: src.inMesh,
364
402
  machineLocal: src.machineLocal,
365
403
  shimId,
366
- handler: shim?.file ?? basenamesIn(r.command).filter((b) => b !== 'hook-shim.mjs').pop() ?? null,
404
+ codexHookId,
405
+ handler: shim?.file ?? basenamesIn(r.command).filter((b) => b !== 'hook-shim.mjs' && b !== 'codex-hook.mjs').pop() ?? null,
367
406
  hasFailsafe: hasFailsafe(r.command),
368
407
  anchored: isAnchored(r.matcher),
369
408
  tools: matchedTools(r.matcher, r.event),
@@ -409,6 +448,12 @@ export function codeRootOf(rec, repo = REPO, home = os.homedir()) {
409
448
  // plugin root — that indirection is the whole point of ADR-023.
410
449
  return rec.shimId ? 'spine' : 'plugin-root';
411
450
  }
451
+ if (rec.layer === 'codex') {
452
+ // codex-hook-wrapper.mjs's activeRoot() resolves the SAME active spine generation
453
+ // (`$RUVNET_BRAIN_HOME/versions/<gen>`) that hook-shim.mjs resolves for Claude Code — the two
454
+ // wrappers are two doors onto one axis, so a dispatch-table hit is 'spine' here too.
455
+ return rec.codexHookId ? 'spine' : 'unknown';
456
+ }
412
457
  // (`<user>` below is a DECLARED placeholder from codex-wiring.test.mjs's allowlist, not a real
413
458
  // account. That scan covers everything under plugin/, which this file joined in ADR-065; the
414
459
  // example previously wrote an ellipsis where the account name goes, and an ellipsis is
@@ -97,6 +97,13 @@ const TABLE = {
97
97
  // The consent guard (ADR-054 §3): it protects the OFF state itself, so it is the one hook that
98
98
  // matters MORE while the brain is off. 'run', permanently.
99
99
  'protect-state': { file: 'protect-brain-state.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
100
+ // THE REFUSAL CHOKEPOINT (ADR-067). ONE PreToolUse decision, composed from every policy that could
101
+ // refuse, with declared precedence and ONE reason. It replaces four independent hooks that could
102
+ // each exit 2 on the same Write with no precedence and no shared context — the concrete form of
103
+ // "constraints that collapse on each other". Same shape as 'unprompted-speech' one layer up: that
104
+ // one is the sole writer of unprompted BYTES, this one is the sole author of a REFUSAL. The four
105
+ // policies it consults are unchanged and still individually tested; the gate only composes them.
106
+ 'decision-gate': { file: 'decision-gate.mjs', interpreter: 'node', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
100
107
  'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
101
108
  'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
102
109
  'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
@@ -218,6 +225,50 @@ function readHookInput(limit) {
218
225
  });
219
226
  }
220
227
 
228
+ /**
229
+ * THE LOUD FALLBACK, ONCE — not once per hook fire (2026-08-14).
230
+ *
231
+ * Finding 25 is right that a broken spine must never masquerade as health, and the message below is
232
+ * KEPT verbatim for that reason. What was wrong was the CADENCE. Both fallback lines were written on
233
+ * EVERY invocation, and this shim is registered ~19 times across the two manifests, on
234
+ * UserPromptSubmit / PreToolUse / PostToolUse / Stop. Measured on a seeded-but-broken spine: five
235
+ * consecutive fires of ONE hook id produced five identical stderr lines; a single prompt that runs a
236
+ * few tools fires far more than five. stderr is precisely what a host renders as "hook error", so a
237
+ * broken spine did not report itself once — it reported itself as the owner's literal complaint,
238
+ * "a ton of hook errors".
239
+ *
240
+ * Loudness is a fair design choice. Loudness PER INVOCATION is not information; it is noise that
241
+ * gets the whole surface muted, which is how a real warning stops being read.
242
+ *
243
+ * KEYED BY GENERATION + KIND, in a marker file — the same shape as skipNoBash's notice, and for the
244
+ * same reason. The key changes when the spine changes, so a NEW breakage always announces itself
245
+ * even if an older one was already reported.
246
+ *
247
+ * WHY A TTL RATHER THAN A TRUE SESSION ID, stated plainly rather than implied: no session identifier
248
+ * reaches this dispatcher. Claude Code does not set CLAUDE_SESSION_ID (learn-flush.mjs documents the
249
+ * same finding), and the only place `session_id` exists is the stdin payload — which the majority of
250
+ * these table entries deliberately never read. So the window is an approximation of a session, not a
251
+ * claim to be one: it bounds a persistent breakage to roughly one line per working session instead
252
+ * of one per tool call, and re-announces it in tomorrow's session rather than going silent forever.
253
+ * Never let it block or fail: a notice we could not record is still worth saying.
254
+ */
255
+ const NOTICE_FILE = path.join(BRAIN_HOME, '.spine-fallback-notice');
256
+ const NOTICE_TTL_MS = Number(process.env.RUVNET_SPINE_NOTICE_TTL_MS ?? 4 * 3600_000);
257
+ function warnOnce(key, message) {
258
+ try {
259
+ const prev = JSON.parse(fs.readFileSync(NOTICE_FILE, 'utf8'));
260
+ const at = Date.parse(prev?.at || '');
261
+ // Same breakage, still inside the window → already said. Anything else (different key, no
262
+ // marker, unparseable marker, expired window) falls through and speaks.
263
+ if (prev?.key === key && Number.isFinite(at) && (Date.now() - at) <= NOTICE_TTL_MS) return;
264
+ } catch { /* no marker yet, or unreadable → say it */ }
265
+ try {
266
+ fs.mkdirSync(BRAIN_HOME, { recursive: true });
267
+ fs.writeFileSync(NOTICE_FILE, JSON.stringify({ key, at: new Date().toISOString() }) + '\n');
268
+ } catch { /* best effort — an unrecordable claim must not cost us the warning */ }
269
+ process.stderr.write(message);
270
+ }
271
+
221
272
  /** Resolve the active code root. Returns { root, source } or null (→ fallback). */
222
273
  function resolveCodeRoot() {
223
274
  // Dev mode wins when explicitly declared and the target still looks like the checkout it names.
@@ -293,14 +344,18 @@ function dispatchHook() {
293
344
  if (fs.existsSync(spineFile)) {
294
345
  return runHook(spineFile);
295
346
  }
296
- // Spine resolved but the body file is missing — fall back LOUDLY (finding 25).
297
- process.stderr.write(`[hook-shim] spine (${spine.source}) missing ${entry.file} — falling back to frozen plugin\n`);
347
+ // Spine resolved but the body file is missing — fall back LOUDLY (finding 25), once per
348
+ // generation. The key omits entry.file on purpose: one broken generation is ONE piece of news,
349
+ // and keying per hook would put ~19 identical diagnoses of the same fault on the user's screen.
350
+ warnOnce(`missing-body:${spine.source}`,
351
+ `[hook-shim] spine (${spine.source}) missing ${entry.file} — falling back to frozen plugin\n`);
298
352
  return runHook(fallbackFile);
299
353
  }
300
354
  // No spine at all. First-install is the normal quiet case; a previously-seeded-but-broken spine
301
355
  // still lands here — the seed marker distinguishes them so breakage is loud, first-run silent.
302
356
  if (fs.existsSync(path.join(BRAIN_HOME, '.spine-seeded'))) {
303
- process.stderr.write(`[hook-shim] spine unreadable — running frozen plugin fallback (run: node scripts/update-apply.mjs --doctor)\n`);
357
+ warnOnce('no-spine',
358
+ `[hook-shim] spine unreadable — running frozen plugin fallback (run: node scripts/update-apply.mjs --doctor)\n`);
304
359
  }
305
360
  return runHook(fallbackFile);
306
361
  }