ruvnet-brain 4.3.21 → 4.3.25
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.
- package/README.md +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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(
|
|
13
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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`); }
|
package/plugin/scripts/gates.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
77
|
-
|
|
78
|
-
const
|
|
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();
|