ruvnet-brain 4.3.40 → 4.4.1

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 (49) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +179 -36
  3. package/kb/brain-profile.mjs +17 -2
  4. package/kb/forge-update.mjs +6 -2
  5. package/kb/lifecycle-evidence-retention.mjs +12 -9
  6. package/kb/refresh-run.mjs +17 -1
  7. package/kb/update-storage-transaction.mjs +33 -5
  8. package/package.json +1 -1
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/hooks/codex-hooks.json +2 -2
  12. package/plugin/hooks/hooks.json +1 -1
  13. package/plugin/scripts/capability-registry.mjs +3 -3
  14. package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
  15. package/plugin/scripts/design-wall.sh +1 -0
  16. package/plugin/scripts/ground-before-write.sh +1 -0
  17. package/plugin/scripts/ground-ruvnet.sh +3 -3
  18. package/plugin/scripts/grounding-answer.mjs +129 -0
  19. package/plugin/scripts/grounding-stamp.sh +32 -31
  20. package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
  21. package/plugin/scripts/grounding-turn-gate.mjs +25 -6
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/kling-preflight.sh +1 -0
  24. package/plugin/scripts/learn-capture.sh +1 -0
  25. package/plugin/scripts/project-progression-reader.mjs +10 -0
  26. package/plugin/scripts/project-progression-sources.mjs +16 -4
  27. package/plugin/scripts/project-progression-store.mjs +201 -6
  28. package/plugin/scripts/protect-brain-state.sh +1 -0
  29. package/plugin/scripts/route-dispatch.sh +1 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +383 -37
  31. package/plugin/scripts/session-start-health.mjs +24 -3
  32. package/plugin/scripts/session-start-update-plane.mjs +1 -1
  33. package/plugin/scripts/update-apply.mjs +22 -2
  34. package/scripts/console-instances.mjs +203 -0
  35. package/scripts/console-runtime-identity.mjs +2 -0
  36. package/scripts/corpus-canary.mjs +130 -18
  37. package/scripts/customer-seams.mjs +84 -0
  38. package/scripts/customer-state-matrix.mjs +363 -0
  39. package/scripts/full-suite-gate.mjs +162 -0
  40. package/scripts/grounding-turn-replay.mjs +11 -3
  41. package/scripts/hook-qualify-core.mjs +346 -0
  42. package/scripts/hook-qualify-hosts.mjs +115 -0
  43. package/scripts/hook-qualify.mjs +101 -0
  44. package/scripts/host-cli.mjs +115 -0
  45. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  46. package/scripts/route-gold-rank.mjs +156 -0
  47. package/scripts/route-index-memory.mjs +51 -0
  48. package/scripts/route-latency-warm.mjs +123 -0
  49. package/scripts/wired-check.mjs +17 -3
@@ -258,7 +258,23 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
258
258
  const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
259
259
  if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
260
260
  const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
261
- if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) continue;
261
+ if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
262
+ // A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
263
+ // run — but only if its bytes are exactly what was sealed when it was quarantined.
264
+ const quarantine = latest.quarantinedUnsealedCandidate;
265
+ if (latest.state === 'ROLLED_BACK' && quarantine && latest.quarantineReclaimed !== true
266
+ && path.resolve(quarantine) === transactionPaths(live, transactionId).failed) {
267
+ const unchanged = !fs.existsSync(quarantine) || (() => {
268
+ try { requireDigest(quarantine, latest.quarantineIdentity, 'quarantined candidate'); return true; } catch { return false; }
269
+ })();
270
+ if (unchanged) {
271
+ removeIfPresent(quarantine);
272
+ appendRecoveryReceipt(receipts, 'ROLLED_BACK', { quarantineReclaimed: true,
273
+ reason: 'released the quarantined unsealed candidate one update cycle later (bytes unchanged)' });
274
+ }
275
+ }
276
+ continue;
277
+ }
262
278
  if (latest.state === 'RECOVERY_REQUIRED') throw new Error(`storage transaction requires manual recovery: ${transactionId}`);
263
279
  const paths = latest.paths;
264
280
  const expectedPaths = transactionPaths(live, transactionId);
@@ -273,12 +289,22 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
273
289
  try {
274
290
  // Validate every retained tree before any rename or deletion. A receipt owns
275
291
  // paths, but cannot authorize discarding bytes added after the process died.
276
- if (fs.existsSync(paths.candidate)) requireDigest(paths.candidate, latest.candidate, 'interrupted candidate');
292
+ // A kill DURING candidate building (the long phase: copy, private restore, guard) leaves a candidate
293
+ // that was never sealed, so no receipt can vouch for its bytes. Refusing made every later update
294
+ // fail forever; deleting would discard bytes nothing proved disposable. It is QUARANTINED instead:
295
+ // renamed intact to this transaction's `failed` path, named in the receipt, and live (proved equal
296
+ // to the prior identity) stays in service.
297
+ const unsealedCandidate = fs.existsSync(paths.candidate) && !latest.candidate?.sha256
298
+ && ['LOCKED', 'CANDIDATE_BUILDING'].includes(latest.state);
299
+ if (fs.existsSync(paths.candidate) && !unsealedCandidate) requireDigest(paths.candidate, latest.candidate, 'interrupted candidate');
277
300
  if (fs.existsSync(paths.rollback)) requireDigest(paths.rollback, prior, 'interrupted rollback');
278
301
  if (fs.existsSync(paths.failed)) throw new Error('interrupted failed tree has no safe recovery disposition');
279
302
  if (['LOCKED', 'CANDIDATE_BUILDING', 'CANDIDATE_VERIFIED'].includes(latest.state)) {
280
303
  requireDigest(live, prior, 'interrupted live');
281
- removeIfPresent(paths.candidate);
304
+ if (unsealedCandidate) {
305
+ assertDirectory(paths.candidate, 'unsealed candidate');
306
+ fs.renameSync(paths.candidate, paths.failed);
307
+ } else removeIfPresent(paths.candidate);
282
308
  } else if (latest.state === 'OLD_RENAME_STARTED') {
283
309
  const hasLive = fs.existsSync(live);
284
310
  const hasRollback = fs.existsSync(paths.rollback);
@@ -324,10 +350,12 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
324
350
  continue;
325
351
  } else throw new Error(`unsupported interrupted state ${latest.state}`);
326
352
  const delta = storageDelta(paths, { prior, candidate: latest.candidate || null });
353
+ const quarantined = unsealedCandidate
354
+ ? { quarantinedUnsealedCandidate: paths.failed, quarantineIdentity: identitySummary(treeIdentity(paths.failed)) } : {};
327
355
  appendRecoveryReceipt(receipts, 'ROLLED_BACK', { terminalVerdict: 'interrupted-run-restored', prior,
328
- storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work` });
356
+ storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work`, ...quarantined });
329
357
  recovered.push({ transactionId, from: latest.state, terminalVerdict: 'interrupted-run-restored',
330
- storageDelta: delta });
358
+ storageDelta: delta, ...quarantined });
331
359
  } catch (error) {
332
360
  appendRecoveryReceipt(receipts, 'RECOVERY_REQUIRED', { terminalVerdict: 'recovery-required', prior,
333
361
  reason: error.message });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.40",
3
+ "version": "4.4.1",
4
4
  "description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.3.40",
4
+ "version": "4.4.1",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.40",
3
+ "version": "4.4.1",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -92,8 +92,8 @@
92
92
  "hooks": [
93
93
  {
94
94
  "type": "command",
95
- "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot SessionEnd",
96
- "timeout": 10
95
+ "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 2500 session-snapshot SessionEnd",
96
+ "timeout": 3
97
97
  }
98
98
  ]
99
99
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech — ADR-040 §Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors — the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
2
+ "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech — ADR-040 §Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if the final answer asserts a rUv capability and no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors — the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -451,11 +451,11 @@ export const CAPABILITIES = [
451
451
  if (d.unreadable) {
452
452
  const locked = /lock|busy|writer/i.test(String(d.unreadable));
453
453
  return row(STATE.UNKNOWN, locked
454
- ? `the memory store is currently held by another process (${d.unreadable}) — that is a passing lock, not a fault; re-checking in a moment should clear it`
454
+ ? `could not read the memory store: another process is holding it (${d.unreadable}) — that is a passing lock, not a fault; re-checking in a moment should clear it`
455
455
  : `the memory store could not be read (${d.unreadable}) — this is not a transient lock, so re-checking will not clear it; the store or its journal files need attention before distillation state can be established`);
456
456
  }
457
- if (d.schemaless) return row(STATE.UNKNOWN, 'the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) — nothing to distill yet, and nothing is broken');
458
- if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows — distillation state not established');
457
+ if (d.schemaless) return row(STATE.UNKNOWN, 'cannot measure distillation: the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) — nothing to distill yet, and nothing is broken');
458
+ if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows — distillation state could not be established');
459
459
 
460
460
  if (d.total === 0) return row(STATE.ABSENT, 'the memory store is empty, so there is nothing to distill yet');
461
461
  if (d.learns) return row(STATE.ON, `${d.patterns} reusable patterns distilled from ${d.real} memories (${(d.cover * 100).toFixed(1)}% embedded)`);
@@ -60,7 +60,7 @@ const blockingHooks = new Set([
60
60
  const DETACHED_HOOKS = new Set(['learn-flush']);
61
61
 
62
62
  /** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
63
- function timeoutFor(hookId) {
63
+ function timeoutFor(hookId, event = '') {
64
64
  const override = Number(process.env.RUVNET_CODEX_HOOK_TIMEOUT_MS);
65
65
  if (Number.isFinite(override) && override > 0) return override;
66
66
  // decision-gate's own internal budget is 4000ms (RUVNET_DECISION_BUDGET_MS) and it is allowed to
@@ -75,6 +75,11 @@ function timeoutFor(hookId) {
75
75
  if (hookId === 'ground-ruvnet' || hookId === 'unprompted-speech' || hookId === 'continuation-gate') {
76
76
  return 8_500;
77
77
  }
78
+ // SessionEnd is hard-capped at 3s by the host (see DETACHED_HOOKS above) and the codex-hooks.json
79
+ // launcher kills this wrapper at 2500ms. A 4000ms budget here was a number nobody would ever reach:
80
+ // the body planned for 8s and was SIGKILLed mid-write. 2200ms leaves the launcher its margin, and the
81
+ // body receives it as RUVNET_CODEX_BUDGET_MS (below) so it can save the new snapshot first.
82
+ if (event === 'SessionEnd') return 2_200;
78
83
  return 4_000;
79
84
  }
80
85
 
@@ -193,7 +198,7 @@ if (DETACHED_HOOKS.has(hookId)) {
193
198
  process.exit(0);
194
199
  }
195
200
 
196
- const budgetMs = timeoutFor(hookId);
201
+ const budgetMs = timeoutFor(hookId, process.argv[3] || '');
197
202
  const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
198
203
  input,
199
204
  encoding: 'utf8',
@@ -29,6 +29,7 @@ INPUT=""
29
29
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
30
30
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
31
31
  # the whole thing back at once and a per-iteration cap never fires.
32
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
32
33
  while IFS= read -r -t 2 _l; do
33
34
  INPUT+="$_l"
34
35
  [ ${#INPUT} -ge 65536 ] && break
@@ -43,6 +43,7 @@ INPUT=""
43
43
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
44
44
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
45
45
  # the whole thing back at once and a per-iteration cap never fires.
46
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
46
47
  while IFS= read -r -t 2 _l; do
47
48
  INPUT+="$_l"
48
49
  [ ${#INPUT} -ge 65536 ] && break
@@ -347,7 +347,7 @@ LASTV=$(cat "$VSTAMP" 2>/dev/null || echo 0)
347
347
  VJITTER=$(( ( $(hostname 2>/dev/null | cksum 2>/dev/null | cut -d' ' -f1 || echo 0) % 8641 ) - 4320 ))
348
348
  VINTERVAL=$(( 21600 + VJITTER ))
349
349
  if [ "$NOWV" -gt 0 ] && [ $((NOWV - LASTV)) -gt "$VINTERVAL" ]; then
350
- echo "$NOWV" > "$VSTAMP" 2>/dev/null
350
+ echo "$NOWV" 2>/dev/null > "$VSTAMP" # 2>/dev/null FIRST: a failed redirect prints the shell's own error otherwise
351
351
  # BACKGROUNDED (QE-0011 code#1): these are 3 sequential `curl --max-time 3` = up to ~9s. Running
352
352
  # them synchronously HERE — before the grounding gates below — risks the whole hook being killed by
353
353
  # Claude Code's ~5s hook timeout on the once/20h refresh tick, which would DROP the actual grounding
@@ -357,7 +357,7 @@ if [ "$NOWV" -gt 0 ] && [ $((NOWV - LASTV)) -gt "$VINTERVAL" ]; then
357
357
  ( for PKG in ruflo @claude-flow/cli @ruvector/rvf; do
358
358
  L=$(curl -fsS --max-time 3 "https://registry.npmjs.org/$PKG/latest" 2>/dev/null | sed -E 's/.*"version":"([^"]+)".*/\1/' | head -c 40)
359
359
  [ -n "$L" ] && echo "$PKG $L"
360
- done > "$VCACHE".tmp 2>/dev/null && mv -f "$VCACHE".tmp "$VCACHE" 2>/dev/null ) &
360
+ done 2>/dev/null > "$VCACHE".tmp && mv -f "$VCACHE".tmp "$VCACHE" 2>/dev/null ) 2>/dev/null &
361
361
  fi
362
362
  if [ -s "$VCACHE" ]; then
363
363
  OUTDATED=""
@@ -625,7 +625,7 @@ if [ -n "$METER_TMP" ]; then
625
625
  mkdir -p "$METER_LEDGER_DIR" 2>/dev/null && \
626
626
  printf '{"ts":"%s","source":"hook","class":"%s","bytes":%d,"cwd":"%s"}\n' \
627
627
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$METER_CLASS" "$METER_BYTES" "$( { pwd -W 2>/dev/null || pwd 2>/dev/null; } | sed 's/"/\\"/g')" \
628
- >> "$METER_LEDGER_DIR/token-ledger.jsonl" 2>/dev/null
628
+ 2>/dev/null >> "$METER_LEDGER_DIR/token-ledger.jsonl"
629
629
  fi
630
630
 
631
631
  exit 0
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * grounding-answer.mjs — the ONE predicate for "did search_ruvnet actually answer?", shared by the
4
+ * PostToolUse stamp (grounding-stamp.sh calls this file as a CLI) and the Stop gate
5
+ * (grounding-turn-evidence.mjs imports brainAnsweredResponse).
6
+ *
7
+ * 4.4.0 ADVERSARIAL REVIEW, BLOCKER B1. The previous predicate matched success markers anywhere in
8
+ * the tool response. But every lane echoes the MODEL's query back at structuredContent.retrieval.query
9
+ * (kb/grounded-response.mjs) — including the router-decline lane ("NO SEARCH WAS RUN",
10
+ * kb/search-outcome.mjs) and source discovery. A query that merely contained `Searched 37 RuvNet repos`
11
+ * turned a search that never ran into a 24-hour stamp, and the long-turn fallback then trusted it.
12
+ *
13
+ * THE RULE NOW: success is read from the ANSWER TEXT only — `answer` or `content[].text`, never
14
+ * `retrieval`, `grounding`, `routing` or any other echoed field — and the answer must BEGIN with the
15
+ * header the brain itself prints (kb/search-outcome.mjs, kb/card-lane.mjs renderCardHit), optionally
16
+ * after the degraded-search paragraph. Text the model controls never sits at the start of the answer.
17
+ * An oversize result counts only when the host's own notice is the WHOLE response's beginning and the
18
+ * saved file is the host's: under $HOME/.claude/projects/<p>/…/tool-results/, a regular file, not a
19
+ * link, written no later than the tool call, and itself beginning with an answer that passes this rule.
20
+ */
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const BANNER = /^Searched \d+ RuvNet repos \(/;
27
+ const CARD = /^⚡ FAST LANE — [^\n]*\n#1 repo=\S+ evidence=curated-capability-card\n/;
28
+ // The first failed repo's error is quoted inside this paragraph and can itself contain newlines, so the
29
+ // paragraph is matched up to its fixed closing sentence, bounded, not up to the first newline.
30
+ const DEGRADED = /^⚠ DEGRADED SEARCH: [\s\S]{0,4000}?\nResults below cover only the healthy repos\. Mention this degradation to the user\.\n\n/;
31
+ const EMPTY = '(no results — the search ran';
32
+ const OVERSIZE = /^Error: result \([\d,]+ characters\) exceeds maximum allowed tokens\. Output has been saved to (\S+?\.txt)\./;
33
+ const PERSISTED = /^<persisted-output>\nOutput too large \([^)]*\)\. Full output saved to: (\S+?\.txt)\n/;
34
+
35
+ /** Does this ANSWER TEXT carry the brain's own success header at its start (and not the empty result)? */
36
+ export function answerTextAnswered(text) {
37
+ const t = String(text ?? '').replace(DEGRADED, '');
38
+ if (CARD.test(t)) return true;
39
+ if (!BANNER.test(t)) return false;
40
+ const empty = t.indexOf(EMPTY);
41
+ return empty < 0 || (t.indexOf('#1 repo=') >= 0 && t.indexOf('#1 repo=') < empty);
42
+ }
43
+
44
+ /** JSON-unescape the leading string value of a truncated `{"answer":"…` document (no full parse possible). */
45
+ function leadingAnswer(raw) {
46
+ const m = /^\s*\{\s*"answer"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(raw);
47
+ if (!m) return null;
48
+ try { return JSON.parse(`"${m[1]}"`); } catch {
49
+ // Truncated mid-escape: drop a dangling backslash sequence and retry once.
50
+ try { return JSON.parse(`"${m[1].replace(/\\[^"]?$|\\u[0-9a-fA-F]{0,3}$/, '')}"`); } catch { return null; }
51
+ }
52
+ }
53
+
54
+ /** The answer text of a tool response in any shape a host delivers it, or null. Never an echoed field. */
55
+ export function answerOf(response) {
56
+ if (response == null) return null;
57
+ if (Array.isArray(response)) {
58
+ const texts = response.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text);
59
+ return texts.length ? answerOf(texts[0]) : null;
60
+ }
61
+ if (typeof response === 'object') {
62
+ if (typeof response.answer === 'string') return response.answer;
63
+ if (Array.isArray(response.content)) return answerOf(response.content);
64
+ if (response.structuredContent && typeof response.structuredContent.answer === 'string') return response.structuredContent.answer;
65
+ return null;
66
+ }
67
+ const s = String(response);
68
+ if (/^\s*[{[]/.test(s)) {
69
+ try { return answerOf(JSON.parse(s)); } catch { return leadingAnswer(s); }
70
+ }
71
+ return s; // plain text content
72
+ }
73
+
74
+ /** The host's saved-result path when the response IS the host's oversize notice, else null. */
75
+ export function oversizePath(response) {
76
+ if (typeof response !== 'string') return null;
77
+ const m = OVERSIZE.exec(response) || PERSISTED.exec(response);
78
+ return m ? m[1] : null;
79
+ }
80
+
81
+ /**
82
+ * Did the brain answer? `notAfterMs`: the file must not be modified after this instant (the tool
83
+ * call's end); `notBeforeMs`: nor long before it (a file planted earlier is not this call's output).
84
+ */
85
+ export function brainAnsweredResponse(response, { home = os.homedir(), notAfterMs = null, notBeforeMs = null } = {}) {
86
+ const file = oversizePath(response);
87
+ if (!file) return answerTextAnswered(answerOf(response));
88
+ const projects = path.join(home, '.claude', 'projects') + path.sep;
89
+ if (file.includes('..') || !file.startsWith(projects)
90
+ || !/^[^\\/].*[\\/]tool-results[\\/][^\\/]+$/.test(file.slice(projects.length))) return false;
91
+ try {
92
+ const st = fs.lstatSync(file);
93
+ if (!st.isFile() || st.isSymbolicLink()) return false;
94
+ if (notAfterMs != null && st.mtimeMs > notAfterMs) return false;
95
+ if (notBeforeMs != null && st.mtimeMs < notBeforeMs) return false;
96
+ const fd = fs.openSync(file, 'r');
97
+ try {
98
+ const buf = Buffer.alloc(65536);
99
+ const head = buf.subarray(0, fs.readSync(fd, buf, 0, buf.length, 0)).toString('utf8');
100
+ return answerTextAnswered(answerOf(head));
101
+ } finally { fs.closeSync(fd); }
102
+ } catch { return false; }
103
+ }
104
+
105
+ /** CLI for grounding-stamp.sh: a PostToolUse payload on stdin → prints `answered` or nothing. Exit 0. */
106
+ async function main() {
107
+ const chunks = [];
108
+ let bytes = 0;
109
+ await new Promise((resolve) => {
110
+ const done = () => resolve();
111
+ const t = setTimeout(done, 3000); t.unref?.();
112
+ process.stdin.on('data', (c) => { if (bytes < 2_097_152) { chunks.push(c); bytes += c.length; } });
113
+ process.stdin.once('end', done); process.stdin.once('error', done);
114
+ });
115
+ try {
116
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
117
+ const now = Date.now();
118
+ if (brainAnsweredResponse(payload?.tool_response, { notAfterMs: now + 2000, notBeforeMs: now - 300_000 })) {
119
+ process.stdout.write('answered\n');
120
+ }
121
+ } catch { /* unparseable payload: nothing minted */ }
122
+ process.exit(0);
123
+ }
124
+
125
+ function isMain() {
126
+ try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
127
+ catch { return false; }
128
+ }
129
+ if (isMain()) main();
@@ -23,12 +23,10 @@
23
23
  # Measured on the pre-fix tree, in tests/unit/brain-off.test.mjs's recorded red run: five
24
24
  # distinct non-answers, five valid stamps.
25
25
  #
26
- # The success signal is the one line kb/forge-mcp-all.mjs prints on every genuinely-executed search
27
- # and on nothing else — `Searched <n> RuvNet repos (...)` — with the four known non-answers refused
28
- # explicitly first. Cheapest reliable signal in the payload: no parsing, no field extraction, plain
29
- # substring matching over the raw stdin, all of it bash builtins. The refusal markers are quote-free
30
- # on purpose: a PostToolUse payload JSON-encodes the tool response, so anything containing a double
31
- # quote would arrive as \" and never match.
26
+ # The success signal is the header the brain prints at the START of a genuine answer and nowhere
27
+ # else — `Searched <n> RuvNet repos (...)` or the fast-lane card header. Since 4.4.0 it is decided by
28
+ # grounding-answer.mjs on the PARSED answer text (substring matching over the raw payload was forged
29
+ # twice: first by the query in tool_input, then by the same query echoed in retrieval.query).
32
30
  #
33
31
  # CONTRACT: PostToolUse is non-blocking — always exit 0, swallow every failure.
34
32
 
@@ -41,36 +39,33 @@ INPUT=""
41
39
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
42
40
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
43
41
  # the whole thing back at once and a per-iteration cap never fires.
42
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
44
43
  while IFS= read -r -t 2 _l; do
45
44
  INPUT+="$_l"
46
- [ ${#INPUT} -ge 65536 ] && break
45
+ [ ${#INPUT} -ge 2097152 ] && break
47
46
  done
48
47
  [ -n "$_l" ] && INPUT+="$_l"
49
- INPUT="${INPUT:0:65536}"
48
+ # 2 MiB, not 64 KiB (4.4.0): the verdict below PARSES the payload, and a payload cut mid-JSON parses as
49
+ # nothing — too small a cap would silently mint nothing for a large genuine answer.
50
+ INPUT="${INPUT:0:2097152}"
50
51
  [ -n "$INPUT" ] || exit 0
51
52
 
52
- shopt -s nocasematch 2>/dev/null || true
53
-
54
- # ── 1. REFUSE the known non-answers, before anything else. Each of these minted a real 24h stamp. ──
55
- case "$INPUT" in
56
- # ADR-054: the brain is switched off. The exact phrase is pinned to the producer by test.
57
- *"RuvNet Brain is disabled"*) exit 0 ;;
58
- # The GONG: every repo failed. An outage is not grounding.
59
- *"RUVNET BRAIN IS DOWN"*) exit 0 ;;
60
- # A thrown error inside the tool.
61
- *"search_ruvnet error:"*) exit 0 ;;
62
- # The search ran and matched nothing. A real answer to the wrong question — but the brain showed
63
- # the model no source, so there is nothing for a stamp to attest to.
64
- *"(no results"*) exit 0 ;;
65
- esac
66
-
67
- # ── 2. REQUIRE the success banner. No banner ⇒ no successful search happened in this payload ⇒ no
68
- # stamp. This is what makes a missing or empty tool_response mint nothing, which is the query-only
69
- # behaviour finally gone.
70
- case "$INPUT" in
71
- *"Searched "*"RuvNet repos"*) ;;
72
- *) exit 0 ;;
73
- esac
53
+ # ── 0-2. DID THE BRAIN ANSWER? ONE predicate, plugin/scripts/grounding-answer.mjs, shared with Stop. ──
54
+ # 4.4.0 adversarial review, BLOCKER B1: matching markers anywhere in tool_response still minted from
55
+ # the MODEL's query, because every lane echoes it back at structuredContent.retrieval.query — the
56
+ # router-decline lane ("NO SEARCH WAS RUN") and source discovery included. The predicate now PARSES
57
+ # the response and reads the ANSWER TEXT only (answer / content[].text), which must BEGIN with the
58
+ # brain's own header; an oversize notice counts only as the host's whole response, pointing at the
59
+ # host's own saved file, written during this call. No node, an unparseable payload, or any other
60
+ # shape ⇒ nothing mints: a stamp that cannot be proven is not minted.
61
+ HERE="$(cd "${BASH_SOURCE[0]%/*}" 2>/dev/null && pwd)" || exit 0 # builtin expansion: no dirname on a bare PATH
62
+ # hook-shim.mjs passes the node that is running it (RUVNET_NODE_BIN); PATH is only the fallback, since a
63
+ # host may hand its hooks a PATH with no node on it.
64
+ NODE_BIN="${RUVNET_NODE_BIN:-}"
65
+ [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node 2>/dev/null)" || NODE_BIN=""
66
+ [ -n "$NODE_BIN" ] && [ -f "$HERE/grounding-answer.mjs" ] || exit 0
67
+ VERDICT="$(printf '%s' "$INPUT" | "$NODE_BIN" "$HERE/grounding-answer.mjs" 2>/dev/null)" || VERDICT=""
68
+ [ "$VERDICT" = "answered" ] || exit 0
74
69
 
75
70
  DIR="$HOME/.cache/ruvnet-brain/grounded"
76
71
  mkdir -p "$DIR" 2>/dev/null || exit 0
@@ -89,9 +84,15 @@ mkdir -p "$DIR" 2>/dev/null || exit 0
89
84
 
90
85
  # ── 3. WHICH terms — from the QUERY only, as it always was. The first raw "query" key in the JSON is
91
86
  # tool_input's; inside tool_response text the quotes are escaped (\"query\") so they cannot match.
87
+ # Read from the tool_input segment only, so a raw "query" key inside an object-shaped response
88
+ # (Codex passes the MCP result as an object, and the result carries retrieval.query) cannot decide
89
+ # which products are stamped. Product terms match case-insensitively (a query says "RuVector").
92
90
  QUERY=""
91
+ TI="${INPUT#*\"tool_input\"}"
92
+ TI="${TI%%\"tool_response\"*}"
93
93
  re='"query"[[:space:]]*:[[:space:]]*"([^"]*)"'
94
- [[ $INPUT =~ $re ]] && QUERY="${BASH_REMATCH[1]}"
94
+ [[ $TI =~ $re ]] && QUERY="${BASH_REMATCH[1]}"
95
+ shopt -s nocasematch 2>/dev/null || true
95
96
  [ -n "$QUERY" ] || exit 0
96
97
 
97
98
  # WRITE_GATE terms — same product-term list as ground-before-write.sh's own copy, mirrored in both