ruvnet-brain 4.3.21 → 4.3.26

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +189 -9
  4. package/console/index.html +70 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/style.css +26 -0
  9. package/console/tips.html +1 -0
  10. package/kb/corpus-release-identity.mjs +239 -0
  11. package/kb/update-storage-transaction.mjs +20 -3
  12. package/package.json +9 -2
  13. package/plugin/.claude-plugin/plugin.json +2 -2
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/commands/checkpoint.md +61 -0
  16. package/plugin/hooks/codex-hooks.json +64 -1
  17. package/plugin/hooks/hook-contracts.json +299 -6
  18. package/plugin/hooks/hooks.json +81 -1
  19. package/plugin/mcp/server.mjs +23 -0
  20. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  21. package/plugin/scripts/advocacy-route.mjs +460 -0
  22. package/plugin/scripts/continuation-gate.mjs +25 -2
  23. package/plugin/scripts/continuation-objective.mjs +7 -1
  24. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  25. package/plugin/scripts/coverage-integrity.mjs +7 -0
  26. package/plugin/scripts/gates.mjs +113 -10
  27. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  28. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  29. package/plugin/scripts/hook-shim.mjs +14 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  53. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  54. package/scripts/adr-072-completion.mjs +1 -1
  55. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  56. package/scripts/approved-runtime.mjs +197 -0
  57. package/scripts/brain-novice-50.mjs +16 -1
  58. package/scripts/brain-score.mjs +23 -5
  59. package/scripts/build-bundle.mjs +971 -530
  60. package/scripts/build-concepts.mjs +36 -116
  61. package/scripts/console-engine.test.mjs +8 -7
  62. package/scripts/console-runtime-identity.mjs +4 -0
  63. package/scripts/corpus-aggregates.mjs +94 -77
  64. package/scripts/corpus-candidate.mjs +475 -222
  65. package/scripts/corpus-next-seed.mjs +225 -0
  66. package/scripts/corpus-promotion.mjs +58 -0
  67. package/scripts/corpus-reconcile.mjs +411 -105
  68. package/scripts/doc-currency.mjs +16 -1
  69. package/scripts/dual-host-deliberation.mjs +25 -2
  70. package/scripts/dual-host-suggest.mjs +17 -1
  71. package/scripts/falsify.mjs +13 -3
  72. package/scripts/gist-receipts.mjs +482 -87
  73. package/scripts/github-health-watch.mjs +12 -2
  74. package/scripts/handoff-asset.mjs +34 -0
  75. package/scripts/hook-retirement-check.mjs +8 -1
  76. package/scripts/host-registry.mjs +1 -1
  77. package/scripts/ingest-gists.mjs +74 -101
  78. package/scripts/job-heartbeat.sh +77 -14
  79. package/scripts/learning-replay-execution.mjs +10 -4
  80. package/scripts/nightly-gists.sh +27 -13
  81. package/scripts/nightly-two-run-proof.mjs +1 -1
  82. package/scripts/nightly-watchdog.mjs +61 -4
  83. package/scripts/onboarding-console.mjs +364 -28
  84. package/scripts/oracle/produce-questions.mjs +293 -0
  85. package/scripts/oracle/producer-hosts.mjs +235 -0
  86. package/scripts/oracle/repo-recall.mjs +448 -0
  87. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  88. package/scripts/oracle/source-tree.mjs +165 -0
  89. package/scripts/oracle/source-units.mjs +391 -0
  90. package/scripts/oracle/spike-run.mjs +98 -0
  91. package/scripts/oracle/unit-inventory.mjs +141 -0
  92. package/scripts/oracle/unit-sampling.mjs +128 -0
  93. package/scripts/oracle/validate-labels.mjs +250 -0
  94. package/scripts/private-overlay.mjs +248 -0
  95. package/scripts/product-integrity-contract.mjs +1 -1
  96. package/scripts/proxy/claude-proxied.sh +6 -0
  97. package/scripts/proxy/proxy-revert.sh +5 -0
  98. package/scripts/proxy/proxy-up.sh +6 -0
  99. package/scripts/proxy/proxy-verify.mjs +4 -0
  100. package/scripts/public-inputs.mjs +409 -0
  101. package/scripts/public-verification-inputs.mjs +112 -26
  102. package/scripts/public-verification-lane.mjs +1 -1
  103. package/scripts/published-surface-probe.mjs +34 -4
  104. package/scripts/qe/card-lane-gate.mjs +16 -1
  105. package/scripts/qe/session-start-gate.mjs +16 -1
  106. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  107. package/scripts/record-lesson.mjs +4 -1
  108. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  109. package/scripts/release-abort-stale.mjs +5 -1
  110. package/scripts/release-authority.mjs +104 -12
  111. package/scripts/release-channel-kind.mjs +86 -0
  112. package/scripts/release-convergence-watchdog.mjs +7 -2
  113. package/scripts/release-projection.mjs +177 -72
  114. package/scripts/release-transaction-provider.mjs +47 -10
  115. package/scripts/release-transaction.mjs +40 -11
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -1,16 +1,171 @@
1
1
  /**
2
2
  * The only automatic lifecycle surface RuvNet Brain permits.
3
3
  *
4
- * The 4.3.16 stabilization retired the old collection of grounding, learning, routing, and
5
- * release interceptors. Continuity is deliberately narrower: SessionStart restores the canonical
6
- * project checkpoint, and Stop may request one continuation only when an explicitly authorized,
7
- * project-scoped objective remains open. Keeping this allowlist in one module prevents a registry
8
- * from quietly growing another pile of independent gates.
4
+ * The 4.3.16 stabilization retired the old collection of grounding, learning, routing, and release
5
+ * interceptors, and they STAY retired — this file is what keeps a registry from quietly regrowing
6
+ * that pile. What it is not is a claim that "two hooks" is the right number forever. The continuity
7
+ * plane had exactly two handlers and could not do its job: SessionStart restored a journal that
8
+ * nothing ever wrote, because no event was allowed to WRITE one. An allowlist that forbids the
9
+ * capture boundary while demanding the restore is not a safety property, it is a contradiction.
10
+ *
11
+ * So the allowlist is now a LIST PER EVENT rather than one entry per event, and each registration
12
+ * declares which hosts may carry it. Adding to it is still a deliberate act that has to pass
13
+ * `npm run hooks:check`; what changed is that the shape can express the plane that actually works.
14
+ *
15
+ * EVENT → OWNER (the same table hook-contracts.json publishes, kept here because this is the file
16
+ * that enforces it). Owner first, event second: wired-check.mjs reads a module header for
17
+ * "<EventName> ... hook|gate" to decide whether a FILE is itself a hook body, and an event-first
18
+ * table made this policy module look like one. The information is identical; the shape is not.
19
+ *
20
+ * session-start continuity recovery at SessionStart claude, codex
21
+ * unprompted-speech advisory delivery at UserPromptSubmit claude, codex
22
+ * continuation-gate continuation nudge at turn end claude, codex
23
+ * session-snapshot continuity capture at turn end claude
24
+ * session-snapshot continuity capture at PreCompact claude
25
+ * session-snapshot continuity capture at SessionEnd claude, codex
26
+ * ground-ruvnet grounding injection at UserPromptSubmit claude, codex
27
+ * decision-gate write authorization at PreToolUse (write) claude, codex
28
+ * grounding-stamp grounding receipt at PostToolUse claude, codex
29
+ * grounding-turn-mark grounding turn marker at UserPromptSubmit claude, codex
30
+ * grounding-turn-gate answered-w/o-search at turn end claude, codex
31
+ *
32
+ * THE GROUNDING ROWS WERE ADDED 2026-09-11 (Stuart), and this header is the record of why. The
33
+ * 4.3.16 retirement took the ONLY enforcement of ADR-0012 — never write rUv-product code the brain
34
+ * has not seen — out of the automatic plane, leaving it in hook-shim's table where nothing ran it.
35
+ * On 2026-09-11 the exact failure that rule exists to prevent recurred in this repo: a console was
36
+ * built without asking the brain whether one existed (it did: console/, RVBC). Stuart: "the fact
37
+ * that you don't have a hook set up to do that means you're a toy versus a solution." So the write
38
+ * gate is automatic again, WITH its key: decision-gate's write route is the one refuser (ADR-067),
39
+ * grounding-stamp on a successful search_ruvnet is the receipt that opens it, and ground-ruvnet is
40
+ * the prompt-level directive that tells the model to search first. ground-ruvnet is a second owner
41
+ * of UserPromptSubmit alongside unprompted-speech — scoped by ADR-040 §Amendment 2026-09-11 to
42
+ * grounding DIRECTIVES, which are not the advisory speech that seam owns.
43
+ *
44
+ * CODEX EXTENSION, 2026-09-12 — the 2026-09-11 "not proven" claim below (see the probe box) was
45
+ * measured with `codex exec "reply OK"`, a prompt that never invokes a tool at all: of course
46
+ * PreToolUse/PostToolUse never fired, because nothing ever ran a tool for them to fire on. That is
47
+ * "never asked the question", not "asked and got no". Re-measured today against codex-cli 0.154.0
48
+ * with a prompt that actually calls a tool: a real `apply_patch` write fired PreToolUse and
49
+ * PostToolUse with `tool_name:"apply_patch"`, and a real MCP call to this repo's own `search_ruvnet`
50
+ * server fired both with `tool_name:"mcp__ruvnet_brain__search_ruvnet"` — which the EXISTING
51
+ * `^(?:.*__)?search_ruvnet$` matcher already recognizes unchanged (`.*__` absorbs the
52
+ * `mcp__ruvnet_brain__` prefix). So decision-gate's write route and grounding-stamp are extended to
53
+ * Codex: `apply_patch` is added to the shared PreToolUse matcher (Claude's Write/Edit/MultiEdit/
54
+ * NotebookEdit are untouched — the addition is a dead branch on Claude, since Claude never names a
55
+ * tool `apply_patch`), and grounding-stamp's PostToolUse matcher is reused byte-identical. Real
56
+ * transcripts of both round trips, both hosts, live in this change's commit and PROGRESS.md.
57
+ *
58
+ * STILL NOT IN THIS PLANE, and deliberately so: decision-gate's BASH route (Codex `exec_command`)
59
+ * and ADR-075's ExecutionPolicy. Today's measurement proved PreToolUse/PostToolUse delivery for a
60
+ * write and an MCP call specifically — it did not exercise `exec_command`, so extending the bash
61
+ * route on that same evidence would be exactly the assumption this rule exists to forbid. Both
62
+ * remain reachable through hook-shim's dispatch table by explicit invocation.
63
+ *
64
+ * "ANSWERED WITHOUT SEARCHING", ADDED 2026-09-12. ground-ruvnet's Gate 1 is a prompt-level
65
+ * DIRECTIVE ("call search_ruvnet before asserting"), and a directive is advisory — nothing checked
66
+ * whether the model actually complied before the turn ended, which is the "stopping is the absence
67
+ * of an action" gap continuation-gate.mjs's own header already names, applied to grounding instead
68
+ * of unfinished work. grounding-turn-mark (UserPromptSubmit) records that Gate 1 fired for this
69
+ * turn; grounding-turn-gate (Stop) forces continuation if grounding-stamp.sh's evidence shows no
70
+ * search_ruvnet call happened since. Full rationale, including why this is a NEW pair rather than
71
+ * an extension of continuation-gate.mjs, lives in grounding-turn-gate.mjs's own header.
9
72
  */
10
73
 
74
+ /**
75
+ * @typedef {{ id: string, matcher: string, hosts: readonly string[] }} ContinuityRegistration
76
+ */
77
+
78
+ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts: Object.freeze(hosts) });
79
+
80
+ /**
81
+ * CODEX REGISTRATIONS ARE MEASURED, NOT ASSUMED (probe run 2026-09-11, codex-cli 0.154.0).
82
+ *
83
+ * A name in an event catalogue is not a delivery. Registering a capture on an event the host never
84
+ * fires produces a plane that LOOKS symmetrical and silently captures nothing on one side of it —
85
+ * which is worse than an asymmetry that is written down. So a temporary CODEX_HOME was given a probe
86
+ * hook registered on all twelve event names the installed binary declares, and a real
87
+ * `codex exec "reply OK"` was run against it. What actually arrived:
88
+ *
89
+ * SessionStart FIRED payload captured
90
+ * UserPromptSubmit FIRED payload captured
91
+ * SessionEnd FIRED payload captured (it fires even when the turn errors out)
92
+ * Stop NOT OBSERVED — the probe turn never completed, so `run_turn_stop_hooks`
93
+ * had no completion to fire on. The binary declares the event and the wire
94
+ * struct, so this is "not proven", not "not supported".
95
+ * PreCompact NOT OBSERVED — a one-line turn never approaches a compaction threshold.
96
+ *
97
+ * Therefore Codex capture is registered at SessionEnd ONLY. Stop keeps the pre-existing
98
+ * continuation-gate registration (unchanged by this lane); no NEW handler is added to an event whose
99
+ * delivery has not been seen. hook-contracts.json carries the same measurement and its date.
100
+ *
101
+ * RE-MEASURED 2026-09-12, codex-cli 0.154.0, PreToolUse/PostToolUse specifically. The 2026-09-11
102
+ * probe above used `codex exec "reply OK"` — a prompt that never invokes a tool — so PreToolUse and
103
+ * PostToolUse were never exercised at all; "NOT OBSERVED" there described an untested path, not a
104
+ * failing one. Today's probe used prompts that DO invoke a tool:
105
+ *
106
+ * PreToolUse / PostToolUse (apply_patch) FIRED tool_name:"apply_patch",
107
+ * tool_input.command = the raw patch,
108
+ * tool_response = "Exit code: 0…
109
+ * Success. Updated the following
110
+ * files: A <path>"
111
+ * PreToolUse / PostToolUse (MCP search_ruvnet, this FIRED tool_name:
112
+ * repo's own plugin/mcp/server.mjs registered as a "mcp__ruvnet_brain__search_ruvnet",
113
+ * real MCP server in the probe's CODEX_HOME) tool_response.content[0].text =
114
+ * "Searched N RuvNet repos …"
115
+ *
116
+ * Both are now registered for Codex: decision-gate's write route (`apply_patch` added to the shared
117
+ * PreToolUse matcher below — Claude's tool names are untouched) and grounding-stamp (the existing
118
+ * `^(?:.*__)?search_ruvnet$` matcher already matches `mcp__ruvnet_brain__search_ruvnet` unchanged,
119
+ * since `.*__` absorbs any qualifying prefix). Full transcripts in this change's commit message.
120
+ */
11
121
  export const CONTINUITY_EVENTS = Object.freeze({
12
- SessionStart: Object.freeze({ id: 'session-start', matcher: 'startup|resume|clear|compact|fork' }),
13
- Stop: Object.freeze({ id: 'continuation-gate', matcher: '*' }),
122
+ SessionStart: Object.freeze([
123
+ registration('session-start', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
124
+ ]),
125
+ UserPromptSubmit: Object.freeze([
126
+ registration('unprompted-speech', '*', ['claude', 'codex']),
127
+ registration('ground-ruvnet', '*', ['claude', 'codex']),
128
+ // The "answered without searching" gate, half 1 of 2 (2026-09-12) — see grounding-turn-gate.mjs's
129
+ // header for the full rationale. Records that ground-ruvnet's Gate 1 fired for this turn, since
130
+ // Stop's own payload carries no prompt text for grounding-turn-gate to test.
131
+ registration('grounding-turn-mark', '*', ['claude', 'codex']),
132
+ ]),
133
+ // The write gate and its key (ADR-0012 / ADR-067), re-registered 2026-09-11 — see the header.
134
+ // Extended to Codex 2026-09-12: a real `apply_patch` write was measured to fire PreToolUse on
135
+ // codex-cli 0.154.0 (see the probe box above). `apply_patch` is Codex's own raw tool name for a
136
+ // write — the matcher tests the RAW host event, before codex-hook-adapter.mjs normalizes it to
137
+ // Claude's `Edit` shape for decision-gate.mjs's own policies (protect-state, hijack-ruvnet,
138
+ // ground-before-write, adr-currency all then see tool_name:"Edit" exactly as on Claude).
139
+ PreToolUse: Object.freeze([
140
+ registration('decision-gate', '^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$', ['claude', 'codex']),
141
+ ]),
142
+ // Extended to Codex 2026-09-12: a real MCP `search_ruvnet` call was measured to fire PostToolUse
143
+ // on codex-cli 0.154.0 with tool_name "mcp__ruvnet_brain__search_ruvnet" — the matcher below
144
+ // already matched that shape unchanged (see the probe box above), and grounding-stamp.sh's own
145
+ // gating logic is a raw-payload substring scan with no host-specific field access, so it needed
146
+ // no change either (proven live: the real MCP tool_response's `content[0].text` carries the exact
147
+ // "Searched N RuvNet repos" banner and query text the script already looks for).
148
+ PostToolUse: Object.freeze([
149
+ registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
150
+ ]),
151
+ Stop: Object.freeze([
152
+ registration('continuation-gate', '*', ['claude', 'codex']),
153
+ registration('session-snapshot', '*', ['claude']),
154
+ // The "answered without searching" gate, half 2 of 2 (2026-09-12). Forces continuation
155
+ // (hookSpecificOutput.additionalContext — the same contract continuation-gate.mjs already uses
156
+ // and codex-hook-adapter.mjs already translates to Codex's decision:block on both hosts) when
157
+ // grounding-turn-mark's marker for this session shows Gate 1 fired and no search_ruvnet stamp
158
+ // (grounding-stamp.sh) postdates it. A separate registration from continuation-gate on purpose —
159
+ // see grounding-turn-gate.mjs's header for why folding it in would corrupt that file's ledger
160
+ // semantics rather than extend them.
161
+ registration('grounding-turn-gate', '*', ['claude', 'codex']),
162
+ ]),
163
+ PreCompact: Object.freeze([
164
+ registration('session-snapshot', '*', ['claude']),
165
+ ]),
166
+ SessionEnd: Object.freeze([
167
+ registration('session-snapshot', '*', ['claude', 'codex']),
168
+ ]),
14
169
  });
15
170
 
16
171
  const commandHas = (command, id) => {
@@ -19,19 +174,39 @@ const commandHas = (command, id) => {
19
174
  return new RegExp(`(?:^|[\\s"'])${id}(?:$|[\\s"'])`).test(text);
20
175
  };
21
176
 
22
- export function continuityHookId(command) {
23
- for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
24
- if (commandHas(command, spec.id)) return { event, id: spec.id };
177
+ /** Which continuity handler, if any, a command invokes — scoped to `event` when one is supplied. */
178
+ export function continuityHookId(command, event) {
179
+ const events = event ? { [event]: CONTINUITY_EVENTS[event] ?? [] } : CONTINUITY_EVENTS;
180
+ for (const [name, specs] of Object.entries(events)) {
181
+ for (const spec of specs) if (commandHas(command, spec.id)) return { event: name, id: spec.id };
25
182
  }
26
183
  return null;
27
184
  }
28
185
 
29
- export function isAllowedContinuityRegistration({ event, matcher, command } = {}) {
30
- const spec = CONTINUITY_EVENTS[event];
31
- if (!spec || String(matcher ?? '') !== spec.matcher) return false;
32
- return commandHas(command, spec.id);
186
+ /**
187
+ * Is this exact (event, matcher, command) registration permitted, for this host?
188
+ * `host` defaults to undefined, meaning "any host that may carry it" — the check every caller used
189
+ * before hosts existed, kept so a host-agnostic caller keeps its old answer.
190
+ */
191
+ export function isAllowedContinuityRegistration({ event, matcher, command, host } = {}) {
192
+ const specs = CONTINUITY_EVENTS[event];
193
+ if (!specs) return false;
194
+ return specs.some((spec) => String(matcher ?? '') === spec.matcher
195
+ && commandHas(command, spec.id)
196
+ && (host === undefined || spec.hosts.includes(host)));
197
+ }
198
+
199
+ /** Every registration this policy expects on `host`, as flat {event, id, matcher} rows. */
200
+ export function continuityRegistrations(host) {
201
+ return Object.entries(CONTINUITY_EVENTS).flatMap(([event, specs]) => specs
202
+ .filter((spec) => host === undefined || spec.hosts.includes(host))
203
+ .map((spec) => ({ event, id: spec.id, matcher: spec.matcher, hosts: [...spec.hosts] })));
33
204
  }
34
205
 
206
+ /**
207
+ * The (event, id) pairs a contracts manifest must list — PAIRS, not ids, because `session-snapshot`
208
+ * is legitimately registered at three different boundaries and a bare id set cannot say that.
209
+ */
35
210
  export function continuityContractIds() {
36
- return Object.values(CONTINUITY_EVENTS).map(({ id }) => id);
211
+ return continuityRegistrations().map(({ event, id }) => `${event}:${id}`);
37
212
  }
@@ -121,6 +121,13 @@ export function sha256File(file) {
121
121
  return hash.digest('hex');
122
122
  }
123
123
 
124
+ // One shared file-identity primitive: always hashes the actual written bytes on disk (never a
125
+ // claimed/serialized value). Every corpus/release receipt reader and writer binds file identity
126
+ // through this single function so they can never independently drift from each other.
127
+ export function fileIdentity(file) {
128
+ return { file: path.basename(file), sha256: sha256File(file), bytes: fs.statSync(file).size };
129
+ }
130
+
124
131
  const readJson = (file, label) => {
125
132
  let stat;
126
133
  try { stat = fs.lstatSync(file); } catch { throw new Error(`${label} is missing`); }
@@ -20,6 +20,24 @@
20
20
  // stops anything. Counting all hooks as "protection" would be the same inflation as counting
21
21
  // cloned upstream repos as your own wiring. Advisory and blocking are different claims.
22
22
  //
23
+ // THREE MORE (console audit, 2026-09-11), each a way the card lied on the owner's own machine:
24
+ //
25
+ // 3. It read plugin/hooks/hooks.json from the console's REPO. On an installed host REPO is the
26
+ // console runtime under ~/.cache, which ships no plugin/hooks/ at all — so the live page showed
27
+ // the 3 machine hooks and NOTHING from the plugin, while Claude Code was loading the plugin's
28
+ // hooks.json from ~/.claude/plugins/cache/…. The survey now reads the hooks the HOST loads
29
+ // (the installed plugin cache), falling back to the repo only in a dev checkout, and says which.
30
+ //
31
+ // 4. Every plugin row was named "hook-shim" — the launcher — so five different gates collapsed
32
+ // into one name and the duplicate detector could not tell them apart. Rows are now named by
33
+ // the gate hook-shim runs (`hook-shim.mjs memory-ensure` → memory-ensure).
34
+ //
35
+ // 5. "0 can block" was true and useless. hook-shim's own table marks ground-before-write,
36
+ // decision-gate, design-wall and protect-brain-state `blocking` — and none of them is
37
+ // registered in hooks.json. "Nothing is enforcing" and "these enforcers are unplugged" are
38
+ // different sentences; only the second is actionable. Unregistered blocking gates are listed,
39
+ // with whether the file even exists on disk.
40
+ //
23
41
  // Reads only. Never asserts a count it cannot source from a file on this machine.
24
42
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
25
43
 
@@ -28,9 +46,10 @@ import os from 'node:os';
28
46
  import path from 'node:path';
29
47
 
30
48
  const HOME = os.homedir();
31
- const BLOCKS = path.join(HOME, '.cache/ruvnet-brain/gate-blocks.jsonl');
49
+ const PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
32
50
 
33
51
  function readJSON(f) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } }
52
+ function isFile(f) { try { return fs.statSync(f).isFile(); } catch { return false; } }
34
53
 
35
54
  // Two independent things must BOTH be true for a hook to stop anything, and conflating them is how
36
55
  // a census gets sold as protection:
@@ -42,12 +61,28 @@ const BLOCKING_EVENTS = new Set(['PreToolUse', 'UserPromptSubmit']);
42
61
  const swallowsExit = (cmd) => /\|\|\s*true\s*$/.test(String(cmd || '').trim());
43
62
  const canBlock = (event, cmd) => BLOCKING_EVENTS.has(event) && !swallowsExit(cmd);
44
63
 
64
+ // The plugin wires every hook through one launcher: `node ".../hook-shim.mjs" <gate> …`. The GATE is
65
+ // the name worth reading; the launcher is not.
66
+ const SHIM_ID = /hook-shim\.mjs["']?\s+([\w-]+)/;
45
67
  const NAME = (cmd) => {
68
+ const shim = String(cmd || '').match(SHIM_ID);
69
+ if (shim) return shim[1];
46
70
  const m = String(cmd || '').match(/([\w-]+)\.(sh|mjs|js)/);
47
71
  return m ? m[1] : String(cmd || '').split(/\s+/).filter((t) => !t.startsWith('-')).pop()?.slice(0, 28) || 'hook';
48
72
  };
49
73
 
50
- function collect(hooksObj, source) {
74
+ // hook-shim.mjs is executable on import (it reads argv[2] at module level), so its TABLE is read as
75
+ // TEXT, never imported. Each entry is one line: `'id': { file: 'x.sh', …, mode: 'blocking'|'advisory', … }`.
76
+ const TABLE_ENTRY = /'([\w-]+)':\s*\{[^}]*?\bfile:\s*'([^']+)'[^}]*?\bmode:\s*'(blocking|advisory)'/g;
77
+ export function readShimTable(shimFile) {
78
+ let src = '';
79
+ try { src = fs.readFileSync(shimFile, 'utf8'); } catch { return null; }
80
+ const table = {};
81
+ for (const m of src.matchAll(TABLE_ENTRY)) table[m[1]] = { file: m[2], mode: m[3] };
82
+ return Object.keys(table).length ? table : null;
83
+ }
84
+
85
+ function collect(hooksObj, source, shimTable = null) {
51
86
  const out = [];
52
87
  for (const [event, groups] of Object.entries(hooksObj || {})) {
53
88
  const list = Array.isArray(groups) ? groups : [groups];
@@ -55,27 +90,88 @@ function collect(hooksObj, source) {
55
90
  const hooks = Array.isArray(g?.hooks) ? g.hooks : (g?.command ? [g] : []);
56
91
  for (const h of hooks) {
57
92
  if (!h?.command) continue;
58
- out.push({ event, matcher: g?.matcher ?? '*', name: NAME(h.command), blocking: canBlock(event, h.command), source });
93
+ const name = NAME(h.command);
94
+ // An advisory shim entry exits 0 by contract, so even a PreToolUse wiring without `|| true`
95
+ // cannot refuse through it. Both conditions are required; the shim's own word is final.
96
+ const shimMode = shimTable?.[name]?.mode ?? null;
97
+ const blocking = canBlock(event, h.command) && shimMode !== 'advisory';
98
+ out.push({ event, matcher: g?.matcher ?? '*', name, blocking, source, ...(shimMode ? { shimMode } : {}) });
59
99
  }
60
100
  }
61
101
  }
62
102
  return out;
63
103
  }
64
104
 
105
+ // Which plugin hooks.json does the HOST load? Explicit root → the installed plugin cache (what Claude
106
+ // Code actually reads) → the repo's plugin/ in a dev checkout → none. The answer is reported, not
107
+ // assumed, because on an installed host the repo path does not exist and used to read as "no gates".
108
+ export function resolvePluginRoot({ home = HOME, repo = null, pluginRoot = null } = {}) {
109
+ if (pluginRoot) return { root: pluginRoot, source: 'explicit' };
110
+ const reg = readJSON(path.join(home, '.claude/plugins/installed_plugins.json'));
111
+ const entries = reg?.plugins?.[PLUGIN_ID];
112
+ const installPath = Array.isArray(entries) ? entries[0]?.installPath : null;
113
+ if (installPath && isFile(path.join(installPath, 'hooks', 'hooks.json'))) return { root: installPath, source: 'installed' };
114
+ if (repo && isFile(path.join(repo, 'plugin', 'hooks', 'hooks.json'))) return { root: path.join(repo, 'plugin'), source: 'repo' };
115
+ return { root: null, source: null };
116
+ }
117
+
118
+ // Git hooks stop COMMITS, not tool calls — reported in their own list so they never inflate the
119
+ // tool-call count. A worktree's `.git` is a file pointing at <common>/worktrees/<name>; hooks live
120
+ // at <common>/hooks. `.sample` files are not hooks.
121
+ export function gitHooks(repo) {
122
+ if (!repo) return [];
123
+ const dotGit = path.join(repo, '.git');
124
+ let hooksDir = null;
125
+ try {
126
+ const st = fs.statSync(dotGit);
127
+ if (st.isDirectory()) hooksDir = path.join(dotGit, 'hooks');
128
+ else {
129
+ const m = fs.readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+)$/m);
130
+ if (m) hooksDir = path.join(path.dirname(path.dirname(m[1].trim())), 'hooks');
131
+ }
132
+ } catch { return []; }
133
+ if (!hooksDir) return [];
134
+ let names = [];
135
+ try { names = fs.readdirSync(hooksDir); } catch { return []; }
136
+ return names
137
+ .filter((n) => !n.endsWith('.sample') && isFile(path.join(hooksDir, n)))
138
+ .sort()
139
+ .map((n) => {
140
+ const file = path.join(hooksDir, n);
141
+ let executable = false;
142
+ try { fs.accessSync(file, fs.constants.X_OK); executable = true; } catch { /* present but will not run */ }
143
+ return { name: n, path: file, blocking: true, executable, source: 'git' };
144
+ });
145
+ }
146
+
65
147
  // Every catch the gates have recorded. This file only exists once a gate has actually refused
66
148
  // something — an empty ledger is an honest "nothing caught yet", never a failure.
67
- export function gateBlocks() {
149
+ export function gateBlocks(home = HOME) {
68
150
  try {
69
- return fs.readFileSync(BLOCKS, 'utf8').trim().split('\n')
151
+ return fs.readFileSync(path.join(home, '.cache/ruvnet-brain/gate-blocks.jsonl'), 'utf8').trim().split('\n')
70
152
  .map((l) => { try { return JSON.parse(l); } catch { return null; } })
71
153
  .filter(Boolean);
72
154
  } catch { return []; }
73
155
  }
74
156
 
75
- export function gatesSurvey({ repo } = {}) {
76
- const machine = collect(readJSON(path.join(HOME, '.claude/settings.json'))?.hooks, 'machine');
77
- const pluginCfg = repo ? readJSON(path.join(repo, 'plugin/hooks/hooks.json')) : null;
78
- const plugin = collect(pluginCfg?.hooks || pluginCfg, 'plugin');
157
+ export function gatesSurvey({ repo, home = HOME, pluginRoot } = {}) {
158
+ const machine = collect(readJSON(path.join(home, '.claude/settings.json'))?.hooks, 'machine');
159
+
160
+ const resolved = resolvePluginRoot({ home, repo, pluginRoot });
161
+ const pluginPath = resolved.root ? path.join(resolved.root, 'hooks', 'hooks.json') : null;
162
+ const pluginCfg = pluginPath ? readJSON(pluginPath) : null;
163
+ const shimTable = resolved.root ? readShimTable(path.join(resolved.root, 'scripts', 'hook-shim.mjs')) : null;
164
+ const plugin = collect(pluginCfg?.hooks || pluginCfg, 'plugin', shimTable);
165
+
166
+ // Blocking gates the launcher KNOWS but hooks.json never wires — they cannot stop anything until
167
+ // registered. `onDisk` separates "unplugged" from "missing": both are findings, of different kinds.
168
+ const registeredNames = new Set(plugin.map((g) => g.name));
169
+ const unregistered = Object.entries(shimTable || {})
170
+ .filter(([id, e]) => e.mode === 'blocking' && !registeredNames.has(id))
171
+ .map(([id, e]) => ({ name: id, mode: e.mode, file: e.file, onDisk: isFile(path.join(resolved.root, 'scripts', e.file)) }))
172
+ .sort((a, b) => a.name.localeCompare(b.name));
173
+
174
+ const git = gitHooks(repo);
79
175
 
80
176
  const all = [...machine, ...plugin];
81
177
  const blocking = all.filter((g) => g.blocking);
@@ -89,7 +185,7 @@ export function gatesSurvey({ repo } = {}) {
89
185
  // name share a count. That is a real ambiguity and it is narrow; attributing the whole machine's
90
186
  // history to whichever directory you happen to be standing in was neither.
91
187
  const here = repo ? path.basename(path.resolve(repo)) : null;
92
- const allBlocks = gateBlocks();
188
+ const allBlocks = gateBlocks(home);
93
189
  const blocks = here ? allBlocks.filter((b) => b.cwd === here) : allBlocks;
94
190
 
95
191
  // Same gate, same event, wired both machine-wide AND by the plugin — it runs twice on every
@@ -134,11 +230,18 @@ export function gatesSurvey({ repo } = {}) {
134
230
  advisory: all.length - blockingWired,
135
231
  blockingDistinct: uniqueBlocking, // distinct gates that can refuse; ≤ blocking when wired twice
136
232
  duplicated, // wired twice; runs twice
233
+ unregisteredBlocking: unregistered.length, // blocking gates the launcher knows but nothing wires
234
+ gitHooks: git.length, // stop commits, not tool calls — never added to `blocking`
235
+ pluginSource: resolved.source, // 'installed' | 'repo' | 'explicit' | null — which hooks.json was read
137
236
  caughtTotal: blocks.length,
138
237
  caughtThisWeek: recent.length,
139
238
  everRecorded: blocks.length > 0,
140
239
  },
141
240
  gates: all.sort((a, b) => Number(b.blocking) - Number(a.blocking)),
241
+ unregistered,
242
+ git,
243
+ pluginSource: resolved.source,
244
+ pluginPath,
142
245
  // Newest first — the most recent catch is the one worth reading.
143
246
  catches: blocks.slice(-12).reverse(),
144
247
  byGate: Object.fromEntries(Object.entries(byGate).map(([k, v]) => [k, v.length])),
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * grounding-turn-gate.mjs — Stop-time enforcement of "you were told to ground, did you?"
4
+ *
5
+ * THE GAP THIS CLOSES, measured rather than assumed. ground-ruvnet.sh's Gate 1 fires a PROMPT-LEVEL
6
+ * directive — "you MUST call the search_ruvnet MCP tool ... BEFORE stating what any RuvNet tool
7
+ * can/cannot do" — whenever the user's prompt touches the rUv stack. That directive is advisory: a
8
+ * prompt is text in context, and rUv's own ADR-G007 names the failure mode by name — "prompts are
9
+ * advisory. Agents can and do ignore them, especially in long sessions." Every OTHER wall in this
10
+ * project fires on an ACTION (a Write, a push, a claim); a plain-text ANSWER that never calls a
11
+ * tool is invisible to all of them, which is the exact shape of continuation-gate.mjs's own
12
+ * "stopping is the absence of an action" problem, applied to grounding instead of to unfinished
13
+ * work.
14
+ *
15
+ * WHY A NEW HOOK, not an extension of continuation-gate.mjs. continuation-gate.mjs's whole
16
+ * architecture is a work LEDGER: explicit `--commit-to` items and derived backlog items, each with
17
+ * an age, a cooldown-guarded force, and a "committed vs observed" vocabulary baked into its header
18
+ * composition. What this file checks is neither — it is a stateless, single-turn compliance
19
+ * question ("did Gate 1 fire this turn, and did search_ruvnet answer it?") with no ledger, no age,
20
+ * and no meaningful "committed vs observed" framing. Folding it into continuation-gate.mjs's
21
+ * cooldown lock would mean a real ledger force and a same-turn grounding nudge fight over one
22
+ * shared 20s window, and folding it into that file's header composition would invent a third
23
+ * category (`header` is currently a strict if/else over exactly two shapes). A second, independent
24
+ * Stop registration is the surgical change; forcing this into continuation-gate.mjs's shape is not.
25
+ *
26
+ * THE MECHANISM, REUSED, NOT INVENTED:
27
+ * - Gate 1's regex: imported from ruvnet-gate1-pattern.mjs, the same copy
28
+ * grounding-turn-mark.mjs uses, proven byte-identical to ground-ruvnet.sh by
29
+ * tests/unit/ruvnet-gate1-pattern.test.mjs. This file does not re-test the prompt itself —
30
+ * Stop's payload carries no prompt text — it reads grounding-turn-mark.mjs's marker instead
31
+ * (see that file's header for why the split exists).
32
+ * - "was search_ruvnet called": the EXISTING evidence grounding-stamp.sh already produces —
33
+ * ~/.cache/ruvnet-brain/grounded/<term>, one file per product term, minted ONLY on a genuinely
34
+ * successful search (grounding-stamp.sh's own header: stamps mint ONLY on a successful grounded
35
+ * result). ground-before-write.sh already trusts this exact directory's file mtimes for its own
36
+ * 24h freshness check; this file trusts the SAME directory the SAME way, just against a
37
+ * narrower window (since the marker's own mtime, not "20 hours ago", is the turn boundary).
38
+ * No second "was it searched" signal is invented — a search_ruvnet call this turn mints a stamp
39
+ * here exactly as it always has, for exactly the same reason (decision-gate's write gate).
40
+ * - The Stop block/continue contract: `{"hookSpecificOutput":{"hookEventName":"Stop",
41
+ * "additionalContext":"..."}}` on stdout, exit 0. This is not a new discovery — it is the exact
42
+ * contract continuation-gate.mjs already uses and this repo's own tests already prove works on
43
+ * BOTH hosts (tests/unit/codex-lifecycle-hooks.test.mjs, "translates the Claude Stop
44
+ * continuation envelope into Codex block plus reason" — codex-hook-adapter.mjs's Stop branch
45
+ * converts this exact envelope into Codex's `{decision:"block",reason}` wire shape). Blocking a
46
+ * Stop is genuinely supported here; this file exercises the already-proven path rather than
47
+ * asking a new question of the host.
48
+ *
49
+ * LOOP SAFETY: identical checks to continuation-gate.mjs (same reasons, same file) — only an
50
+ * affirmatively-parsed `stdin` payload with a real `session_id` may force, `stop_hook_active` means
51
+ * this stop episode has already been continued once and this gate stays silent, and an
52
+ * interrupted/cancelled turn is never forced. The marker is consumed (deleted) whether or not it
53
+ * fires, so a genuinely abandoned marker cannot pressure some unrelated later turn.
54
+ *
55
+ * FAILS OPEN ALWAYS. Exit 0 unconditionally — a gate that breaks a turn's completion because a
56
+ * cache directory was unreadable would be disabled within a day.
57
+ */
58
+ import fs from 'node:fs';
59
+ import os from 'node:os';
60
+ import path from 'node:path';
61
+ import { fileURLToPath } from 'node:url';
62
+ import { readStdinBounded } from './hook-input.mjs';
63
+ import { markerPathFor } from './grounding-turn-mark.mjs';
64
+
65
+ const HOME = os.homedir();
66
+ const EXIT_ALLOW = 0;
67
+
68
+ // Same directory grounding-stamp.sh writes and ground-before-write.sh reads — no env override in
69
+ // either of those (they are pure-bash, deliberately dependency-free per ADR-0021), so this reader
70
+ // must resolve the identical default for the two to ever agree. Tests isolate via HOME, exactly as
71
+ // tests/unit/codex-lifecycle-hooks.test.mjs already does for the rest of this hook family.
72
+ const GROUNDED_DIR = path.join(HOME, '.cache', 'ruvnet-brain', 'grounded');
73
+
74
+ /** Newest mtime (ms since epoch) among grounding-stamp.sh's product-term stamp files, or null if
75
+ * the directory is absent/empty/unreadable — never throws, this is a fail-open evidence read. */
76
+ export function newestGroundingStampMs(dir = GROUNDED_DIR) {
77
+ let entries;
78
+ try { entries = fs.readdirSync(dir); } catch { return null; }
79
+ let newest = null;
80
+ for (const name of entries) {
81
+ try {
82
+ const st = fs.statSync(path.join(dir, name));
83
+ if (!st.isFile()) continue;
84
+ const ms = st.mtimeMs;
85
+ if (newest === null || ms > newest) newest = ms;
86
+ } catch { /* a stamp that vanished mid-scan is not evidence either way */ }
87
+ }
88
+ return newest;
89
+ }
90
+
91
+ /** A little slack for filesystem mtime granularity (some filesystems round to whole seconds), so a
92
+ * stamp written the same wall-clock second as the marker is never wrongly judged "before" it. */
93
+ const SKEW_MS = 1500;
94
+
95
+ /** Pure decision: given the marker's mtime and the newest grounding stamp's mtime, was this turn's
96
+ * Gate-1 directive satisfied? Exported so the unit test can drive it without touching the
97
+ * filesystem or spawning a process. */
98
+ export function wasGroundedSince(markerMs, newestStampMs) {
99
+ if (newestStampMs === null) return false;
100
+ return newestStampMs >= markerMs - SKEW_MS;
101
+ }
102
+
103
+ async function readHookInput() {
104
+ // Mirrors continuation-gate.mjs's own three-way source classification exactly (ADR-043 /
105
+ // Fable #1): only a payload we actually parsed off stdin may ever force a continuation.
106
+ if (process.stdin.isTTY) return { __source: 'tty' };
107
+ try {
108
+ const raw = (await readStdinBounded()).toString('utf8');
109
+ return { ...JSON.parse(raw || '{}'), __source: 'stdin' };
110
+ } catch { return { __source: 'unreadable' }; }
111
+ }
112
+
113
+ async function main() {
114
+ const hookInput = await readHookInput();
115
+ if (hookInput.__source !== 'stdin') process.exit(EXIT_ALLOW);
116
+ if (hookInput.stop_hook_active) process.exit(EXIT_ALLOW);
117
+ if (hookInput.hook_event_name !== 'Stop' || hookInput.interrupted || hookInput.cancelled) {
118
+ process.exit(EXIT_ALLOW);
119
+ }
120
+ if (!hookInput.session_id) process.exit(EXIT_ALLOW);
121
+
122
+ const marker = markerPathFor(hookInput.session_id);
123
+ if (!marker) process.exit(EXIT_ALLOW);
124
+
125
+ let markerStat;
126
+ try { markerStat = fs.statSync(marker); } catch { process.exit(EXIT_ALLOW); }
127
+
128
+ // Consume the marker unconditionally: whether this fires or not, it must never pressure a LATER,
129
+ // unrelated turn (same reasoning as continuation-gate.mjs's cooldown lock, applied here as a
130
+ // single-use marker instead of a timed window, because "did this turn ground itself" has no
131
+ // meaningful reading beyond the one turn it was written for).
132
+ try { fs.unlinkSync(marker); } catch { /* a marker that vanished between stat and unlink already told us what we needed */ }
133
+
134
+ const grounded = wasGroundedSince(markerStat.mtimeMs, newestGroundingStampMs());
135
+ if (grounded) process.exit(EXIT_ALLOW);
136
+
137
+ const lines = [
138
+ 'This turn touched the RuvNet / rUv stack and ground-ruvnet\'s directive required calling the',
139
+ 'search_ruvnet MCP tool before asserting what any RuvNet tool can/cannot do — but no successful',
140
+ 'search_ruvnet call was recorded this turn (checked against the same grounding-stamp evidence',
141
+ 'ground-before-write.sh already trusts).',
142
+ '',
143
+ 'Do NOT end the turn on an ungrounded rUv-domain answer. Call `search_ruvnet` now with the',
144
+ 'relevant product term(s) in the query, ground your answer in the cited source paths it returns,',
145
+ 'and correct anything you already asserted from memory. Training priors on the rUv stack are',
146
+ 'stale by construction (ADR-0012) — this is not a formality.',
147
+ ];
148
+
149
+ process.stdout.write(JSON.stringify({
150
+ hookSpecificOutput: {
151
+ hookEventName: 'Stop',
152
+ additionalContext: lines.join('\n'),
153
+ },
154
+ }));
155
+ process.exit(EXIT_ALLOW);
156
+ }
157
+
158
+ /** Never runs main() merely because a test (or anything else) imported this file for its pure
159
+ * helpers — same guard decision-gate.mjs uses, for the same reason (entrypoint-guard-safety). */
160
+ function isMain() {
161
+ try {
162
+ if (!process.argv[1]) return false;
163
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
164
+ } catch { return false; }
165
+ }
166
+
167
+ if (isMain()) main();