ruvnet-brain 4.5.16 → 4.6.0

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 (86) hide show
  1. package/README.md +15 -19
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +15 -3
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/config/practical-rule-catalog.json +111 -0
  6. package/console/CONTRACT.md +47 -3
  7. package/console/app.js +112 -20
  8. package/package.json +5 -2
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/commands/brain-console.md +13 -1
  12. package/plugin/commands/configure.md +2 -2
  13. package/plugin/commands/rnb.md +13 -1
  14. package/plugin/commands/rnbc.md +13 -1
  15. package/plugin/commands/rvbc.md +13 -1
  16. package/plugin/commands/rvcb.md +13 -1
  17. package/plugin/hooks/codex-hooks.json +13 -1
  18. package/plugin/hooks/hook-contracts.json +20 -4
  19. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  20. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  21. package/plugin/scripts/agentdb-recall.mjs +161 -26
  22. package/plugin/scripts/automatic-update.mjs +11 -2
  23. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  24. package/plugin/scripts/capability-registry.mjs +55 -114
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  26. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  27. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  28. package/plugin/scripts/continuation-gate.mjs +2 -1
  29. package/plugin/scripts/continuity-brief.mjs +55 -22
  30. package/plugin/scripts/continuity-events.mjs +93 -17
  31. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  32. package/plugin/scripts/continuity-journal.mjs +77 -29
  33. package/plugin/scripts/decision-gate.mjs +62 -37
  34. package/plugin/scripts/decision-outcomes.mjs +192 -199
  35. package/plugin/scripts/detach.mjs +53 -13
  36. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  37. package/plugin/scripts/developer-update-lock.mjs +30 -0
  38. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  39. package/plugin/scripts/developer-update-owner.mjs +24 -0
  40. package/plugin/scripts/developer-update-policy.mjs +48 -0
  41. package/plugin/scripts/developer-update.mjs +435 -0
  42. package/plugin/scripts/doc-currency.mjs +993 -0
  43. package/plugin/scripts/ground-ruvnet.sh +16 -3
  44. package/plugin/scripts/grounding-answer.mjs +18 -16
  45. package/plugin/scripts/grounding-stamp.sh +3 -0
  46. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  47. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  48. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  49. package/plugin/scripts/hook-context-budget.mjs +90 -0
  50. package/plugin/scripts/hook-registry.mjs +129 -1
  51. package/plugin/scripts/hook-shim.mjs +20 -2
  52. package/plugin/scripts/host-update.mjs +6 -3
  53. package/plugin/scripts/lesson-gate.mjs +1 -1
  54. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  55. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  56. package/plugin/scripts/project-capture-queue.mjs +22 -15
  57. package/plugin/scripts/project-progression-producer.mjs +15 -8
  58. package/plugin/scripts/project-progression-reader.mjs +14 -4
  59. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  60. package/plugin/scripts/project-progression-sources.mjs +24 -10
  61. package/plugin/scripts/project-progression-store.mjs +7 -2
  62. package/plugin/scripts/project-store-resolver.mjs +10 -7
  63. package/plugin/scripts/project-transition-hook.mjs +20 -12
  64. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  65. package/plugin/scripts/session-start-budget.mjs +18 -0
  66. package/plugin/scripts/session-start-core.mjs +32 -6
  67. package/plugin/scripts/session-start-trace.mjs +2 -1
  68. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  69. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  70. package/plugin/scripts/user-settings.mjs +7 -1
  71. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  72. package/scripts/codex-managed-terminal.mjs +1 -1
  73. package/scripts/console-runtime-identity.mjs +2 -0
  74. package/scripts/console-suite-update.mjs +117 -0
  75. package/scripts/doc-currency.mjs +6 -990
  76. package/scripts/hook-qualify-core.mjs +30 -1
  77. package/scripts/model-managed-prompt.mjs +1 -1
  78. package/scripts/model-managed-workflow-service.mjs +113 -14
  79. package/scripts/model-routing-controller.mjs +112 -21
  80. package/scripts/onboarding-console.mjs +41 -12
  81. package/scripts/practical-rule-selector.mjs +51 -0
  82. package/scripts/product-integrity-contract.mjs +11 -4
  83. package/scripts/release-qualification-contract.mjs +14 -0
  84. package/scripts/remedy-registry.mjs +1 -0
  85. package/scripts/source-scope-receipt.mjs +9 -4
  86. package/scripts/stack-sync.mjs +19 -132
@@ -652,30 +652,43 @@ if [ -n "$BLK" ]; then
652
652
  # is never marked delivered (measured: the once-a-day flywheel offer starved behind the memory offer).
653
653
  # Without a session there is no budget either — the pre-4.5 behaviour, everything every time.
654
654
  [ -n "$INJ_DIR" ] || INJ_BUDGET=999999999
655
+ [ "${RUVNET_HOOK_CONTEXT_BUDGET:-0}" = "1" ] && [ "$INJ_BUDGET" -gt 7900 ] && INJ_BUDGET=7900
655
656
  INJ_USED=0
656
657
  for _f in $(cd "$BLK" 2>/dev/null && ls | grep -v '\.' | sort -t- -k2,2n -k1,1n); do
657
658
  _rest=${_f#*-}; _prio=${_rest%%-*}; _id=${_rest#*-}
658
659
  if [ "$_id" != "resume" ] && [ "${_id#agentdb-recall-}" = "$_id" ] && inj_seen "$_id"; then
659
- if [ -f "$BLK/$_f.short" ]; then echo short > "$BLK/$_f.pick"; INJ_SHORTENED=$((INJ_SHORTENED + 1)); fi
660
+ if [ -f "$BLK/$_f.short" ]; then
661
+ if [ "${RUVNET_HOOK_CONTEXT_BUDGET:-0}" = "1" ] && [ "$_prio" != "0" ]; then
662
+ _short_size=$(wc -c < "$BLK/$_f.short")
663
+ if [ $((INJ_USED + _short_size)) -gt 7900 ]; then printf '%s\n' "$_f" >> "$BLK/.budget-deferred"; INJ_DEFERRED=$((INJ_DEFERRED + 1)); continue; fi
664
+ INJ_USED=$((INJ_USED + _short_size))
665
+ fi
666
+ echo short > "$BLK/$_f.pick"; INJ_SHORTENED=$((INJ_SHORTENED + 1))
667
+ fi
660
668
  continue
661
669
  fi
662
670
  _size=$(($(wc -c < "$BLK/$_f" 2>/dev/null || echo 0)))
663
671
  # A block bigger than the whole budget still goes out when it is the prompt's ONLY non-safety block —
664
672
  # otherwise an offer longer than the budget (the flywheel's is ~2.9 KB) could never be made at all.
665
- if [ "$_prio" != "0" ] && [ "$INJ_USED" -gt 0 ] && [ $((INJ_USED + _size)) -gt "$INJ_BUDGET" ]; then
666
- INJ_DEFERRED=$((INJ_DEFERRED + 1)); continue
673
+ if [ "$_prio" != "0" ] && { [ "$INJ_USED" -gt 0 ] || { [ "${RUVNET_HOOK_CONTEXT_BUDGET:-0}" = "1" ] && [ "$_size" -gt 7900 ]; }; } && [ $((INJ_USED + _size)) -gt "$INJ_BUDGET" ]; then
674
+ printf '%s\n' "$_f" >> "$BLK/.budget-deferred"; INJ_DEFERRED=$((INJ_DEFERRED + 1)); continue
667
675
  fi
668
676
  if [ "$_id" = "flywheel" ]; then claim_flywheel_day || continue; fi
669
677
  echo full > "$BLK/$_f.pick"
670
678
  [ "$_prio" != "0" ] && INJ_USED=$((INJ_USED + _size))
671
679
  [ "$_id" != "resume" ] && [ "${_id#agentdb-recall-}" = "$_id" ] && inj_mark "$_id"
672
680
  done
681
+ if [ "${RUVNET_HOOK_CONTEXT_BUDGET:-0}" = "1" ] &&
682
+ "${RUVNET_NODE_BIN:-node}" "$(dirname "$0")/hook-context-budget.mjs" --ground-dir "$BLK" > "$BLK/.bounded-context"; then
683
+ cat "$BLK/.bounded-context"
684
+ else
673
685
  for _f in $(cd "$BLK" 2>/dev/null && ls | grep -v '\.' | sort -n); do
674
686
  case "$(cat "$BLK/$_f.pick" 2>/dev/null)" in
675
687
  full) cat "$BLK/$_f" ;;
676
688
  short) cat "$BLK/$_f.short" ;;
677
689
  esac
678
690
  done
691
+ fi
679
692
  rm -rf "$BLK" 2>/dev/null
680
693
  fi
681
694
 
@@ -22,6 +22,7 @@ import fs from 'node:fs';
22
22
  import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
+ import { normalizeToolOutcome } from './continuity-events.mjs';
25
26
 
26
27
  const BANNER = /^Searched \d+ RuvNet repos \(/;
27
28
  const CARD = /^⚡ FAST LANE — [^\n]*\n#1 repo=\S+ evidence=curated-capability-card\n/;
@@ -41,32 +42,30 @@ export function answerTextAnswered(text) {
41
42
  return empty < 0 || (t.indexOf('#1 repo=') >= 0 && t.indexOf('#1 repo=') < empty);
42
43
  }
43
44
 
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
- }
45
+ const rejectedExecution = (response) => {
46
+ const execution = normalizeToolOutcome({ content: response });
47
+ return execution.uncertain || ['fail', 'pending', 'interrupted'].includes(execution.outcome);
48
+ };
53
49
 
54
50
  /** 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;
51
+ export function answerOf(response, depth = 0) {
52
+ if (response == null || depth > 8 || rejectedExecution(response)) return null;
57
53
  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;
54
+ const block = response.find((b) => b && b.type === 'text' && typeof b.text === 'string');
55
+ return block ? answerOf(block, depth + 1) : null;
60
56
  }
61
57
  if (typeof response === 'object') {
62
58
  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;
59
+ if (Array.isArray(response.content)) return answerOf(response.content, depth + 1);
60
+ if (response.structuredContent && typeof response.structuredContent.answer === 'string') return answerOf(response.structuredContent, depth + 1);
61
+ if (response.type === 'text' && typeof response.text === 'string') return answerOf(response.text, depth + 1);
65
62
  return null;
66
63
  }
67
64
  const s = String(response);
68
65
  if (/^\s*[{[]/.test(s)) {
69
- try { return answerOf(JSON.parse(s)); } catch { return leadingAnswer(s); }
66
+ // The exact envelope supplying an answer must be complete: a prefix can hide later error flags.
67
+ if (Buffer.byteLength(s, 'utf8') > 2_097_152) return null;
68
+ try { return answerOf(JSON.parse(s), depth + 1); } catch { return null; }
70
69
  }
71
70
  return s; // plain text content
72
71
  }
@@ -83,6 +82,7 @@ export function oversizePath(response) {
83
82
  * call's end); `notBeforeMs`: nor long before it (a file planted earlier is not this call's output).
84
83
  */
85
84
  export function brainAnsweredResponse(response, { home = os.homedir(), notAfterMs = null, notBeforeMs = null } = {}) {
85
+ if (rejectedExecution(response)) return false;
86
86
  const file = oversizePath(response);
87
87
  if (!file) return answerTextAnswered(answerOf(response));
88
88
  const projects = path.join(home, '.claude', 'projects') + path.sep;
@@ -116,6 +116,8 @@ async function main() {
116
116
  const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
117
117
  const now = Date.now();
118
118
  // Grok duplicates the result as camelCase `toolResult`; read either (its capture also carries tool_response).
119
+ const execution = normalizeToolOutcome({ ...payload, content: payload?.tool_response ?? payload?.toolResult });
120
+ if (execution.uncertain || ['fail', 'pending', 'interrupted'].includes(execution.outcome)) process.exit(0);
119
121
  if (brainAnsweredResponse(payload?.tool_response ?? payload?.toolResult, { notAfterMs: now + 2000, notBeforeMs: now - 300_000 })) {
120
122
  process.stdout.write('answered\n');
121
123
  }
@@ -70,6 +70,9 @@ VERDICT="$(printf '%s' "$INPUT" | "$NODE_BIN" "$HERE/grounding-answer.mjs" 2>/de
70
70
  # No HOME, no stamp dir to write — and under `set -u` a bare $HOME is an "unbound variable" on stderr
71
71
  # (found by hook-qualify's home-unset case on an ANSWERED search, 4.5). Exit quietly instead.
72
72
  [ -n "${HOME:-}" ] || exit 0
73
+ # Same-turn proof is separate from the shared 24h write-gate freshness stamps below.
74
+ # Native session/project/turn identity and the current marker nonce must all match.
75
+ printf '%s' "$INPUT" | "$NODE_BIN" "$HERE/grounding-turn-mark.mjs" --record-search >/dev/null 2>&1 || true
73
76
  DIR="$HOME/.cache/ruvnet-brain/grounded"
74
77
  mkdir -p "$DIR" 2>/dev/null || exit 0
75
78
 
@@ -1,38 +1,19 @@
1
1
  /**
2
- * grounding-turn-evidence.mjs — the pure half of ADR-0030 decision-point gate #1: "before asserting
3
- * what a tool or platform can or cannot do, did you CHECK a relevant source this turn, or are you
4
- * recalling?" Plus gates #2 (architecture needs >= 3 options) and #3 (relayed numbers need a
5
- * re-check), which run in SHADOW mode only: they are measured and logged, never delivered.
6
- *
7
- * WHY (owner, 2026-09-30): "a nudge without enforcement is worthless." The concrete incident: an
8
- * answer asserted "No hook can change the model of the current turn" from a WebFetch page summary
9
- * written by a small model, and a design was built on it. The existing grounding-turn-gate only
10
- * checked that SOME search_ruvnet stamp was newer than the turn marker — presence, not relevance or
11
- * order — armed only on rUv-stack prompts, and could not see WebFetch at all.
12
- *
13
- * DESIGN, deterministic and local (rUv ADR-G004 rejects LLM gate evaluation; no model is called):
14
- * 1. UserPromptSubmit (grounding-turn-mark.mjs) arms the turn only when the prompt ASKS for a
15
- * capability / feasibility / architecture judgement AND names a subject (classifyPrompt). The
16
- * caller replayed a naive output-only regex at 13.5% of turns and an order-aware one at 0.93%,
17
- * mostly on harmless hedges; arming at prompt time is what keeps unarmed turns out of scope.
18
- * 2. The sources read this turn come from the host transcript (turnSources) — the ordered,
19
- * complete record of every tool call and its result, including search_ruvnet's returned paths.
20
- * A WebFetch body is a small model's summary of the page, so it is WEAK evidence; so is a
21
- * subagent's relayed report and a WebSearch snippet list.
22
- * 3. At Stop, each capability claim in the final answer (capability-claim-evidence.mjs's own
23
- * extractor, widened with this turn's vocabulary) needs one STRONG source whose path, URL,
24
- * command or query names the claim's subject and that was read AFTER the last weak source about
25
- * that subject (auditAssertions). Otherwise: one correction.
26
- *
27
- * Claims continuation-gate.mjs already audits (the RUVNET_TOOL behaviour class) are skipped here —
28
- * one correction per claim, never two gates arguing over the same sentence.
2
+ * Grounding evidence for ADR-0030: observed successful substantive results, not requested paths.
3
+ * Native failure/running/missing precedence is shared with continuity-events. A native non-error
4
+ * Read result may establish read success without inventing a process exit code. Raw command reads
5
+ * require terminal zero and one literal source command through the existing commandNodes parser.
6
+ * Weak summaries/relays remain weak. Claims still require matching returned body after weak sources;
7
+ * missing execution stays UNKNOWN. This verifies observed evidence, not semantic entailment.
29
8
  */
30
9
  import fs from 'node:fs';
31
10
  import os from 'node:os';
32
11
  import path from 'node:path';
33
12
  import { fileURLToPath } from 'node:url';
34
13
  import { RUVNET_GATE1_TERMS } from './ruvnet-gate1-pattern.mjs';
35
- import { brainAnsweredResponse } from './grounding-answer.mjs';
14
+ import { answerOf, brainAnsweredResponse } from './grounding-answer.mjs';
15
+ import { normalizeToolOutcome } from './continuity-events.mjs';
16
+ import { commandNodes } from './hook-input.mjs';
36
17
  import { extractClaims } from './capability-claim-evidence.mjs';
37
18
  import { currentTurnRecords, strippedProse } from './completion-claim-evidence.mjs';
38
19
 
@@ -146,32 +127,51 @@ export function brainAnswered(r, { home = os.homedir(), notAfterMs = null } = {}
146
127
  return brainAnsweredResponse(r, { home, notAfterMs });
147
128
  }
148
129
 
149
- export function sourceOf(name, input = {}, result = '', { resultAtMs = null } = {}) {
150
- const n = String(name || '');
151
- const r = String(result || '');
130
+ const substantive = text => {
131
+ if (/^(?:[A-Za-z]:[\\/]|\/)\S+$/.test(text.trim())) return false;
132
+ try { const value = JSON.parse(text); if (value && typeof value === 'object' && !Array.isArray(value)
133
+ && Object.keys(value).some(key => !['path', 'file_path', 'url', 'query', 'description', 'tool_name'].includes(key))) return true; } catch {}
134
+ return [...tokenSet(text)].filter(word => /[a-z]/.test(word)).length >= 3;
135
+ };
136
+ function directSourceCommand(command) {
137
+ if (/[;&|<>`$\n]/.test(command)) return false;
138
+ const nodes = commandNodes(command), node = nodes[0];
139
+ if (nodes.length !== 1 || node.dynamic || node.assigns.length || node.argv.some(word => !word)) return false;
140
+ const [exe, ...args] = node.argv, name = path.basename(exe);
141
+ return (name === 'cat' && args.length === 1 && !args[0].startsWith('-'))
142
+ || (name === 'sed' && args.length === 3 && args[0] === '-n' && /^\d+(?:,\d+)?p$/.test(args[1]))
143
+ || (['head', 'tail'].includes(name) && args.length === 3 && args[0] === '-n' && /^\d+$/.test(args[1]))
144
+ || (name === 'curl' && args.some(arg => ['-f', '--fail', '-fs', '-fsS'].includes(arg))
145
+ && args.filter(arg => /^https:\/\//.test(arg)).length === 1
146
+ && args.every(arg => /^https:\/\//.test(arg) || ['-f', '--fail', '-s', '--silent', '-S', '--show-error', '-fs', '-fsS'].includes(arg)))
147
+ || (!['echo', 'printf', 'eval', 'sh', 'bash', 'zsh'].includes(name) && args.length === 1 && args[0] === '--help');
148
+ }
149
+ export function sourceOf(name, input = {}, result = '', { resultAtMs = null, resultEvidence = null } = {}) {
150
+ const content = resultEvidence && Object.hasOwn(resultEvidence, 'content') ? resultEvidence.content : result;
151
+ const n = String(name || ''), r = textOf(content), body = r.replace(/^(?:Exit code\s*:?\s*-?\d+|Process exited with code\s+-?\d+|Wall time:.*)\s*$/gim, ''), outcome = normalizeToolOutcome(resultEvidence || { content });
152
+ const bodyText = body.trim();
153
+ const observed = resultEvidence?.type === 'tool_result' && typeof resultEvidence.tool_use_id === 'string';
154
+ const failed = ['fail', 'interrupted'].includes(outcome.outcome), pending = outcome.outcome === 'pending';
155
+ const nativeSuccess = observed && outcome.successfulToolResult === true;
156
+ const base = (kind, ref, strength, body = bodyText, extra = {}) => ({ kind, ref, strength: failed ? 'failed' : !observed || pending ? 'unknown' : strength,
157
+ text: ref, result: body.slice(0, 20000), terminalOutcome: outcome.outcome, ...extra });
152
158
  if (/(?:^|__)search_ruvnet$/.test(n)) {
153
- const ok = brainAnswered(r, { notAfterMs: resultAtMs == null ? null : resultAtMs + 5000 });
154
- const paths = [...r.matchAll(/^path : (\S+)/gm)].map((m) => m[1]).slice(0, 20);
155
- return { kind: 'search_ruvnet', ref: String(input.query || ''), strength: ok ? 'strong' : 'failed', ok, text: [input.query, ...paths].join(' ') };
156
- }
157
- if (n === 'WebFetch') return { kind: 'WebFetch', ref: String(input.url || ''), strength: 'weak', why: 'summarised-by-small-model', text: String(input.url || '') };
158
- if (n === 'WebSearch') {
159
- const urls = [...r.matchAll(/https?:\/\/[^\s)"'\]]+/g)].map((m) => m[0]).slice(0, 10);
160
- return { kind: 'WebSearch', ref: String(input.query || ''), strength: 'weak', why: 'search-snippets', text: [input.query, ...urls].join(' ') };
161
- }
162
- if (n === 'Agent' || n === 'Task') {
163
- return { kind: n, ref: String(input.description || input.subagent_type || ''), strength: 'weak', why: 'relayed-by-subagent',
164
- text: `${input.description || ''} ${String(input.prompt || '').slice(0, 400)}`, result: r.slice(0, 20000) };
165
- }
166
- if (n === 'Read' || n === 'NotebookRead') return { kind: 'Read', ref: String(input.file_path || input.notebook_path || ''), strength: 'strong', text: String(input.file_path || input.notebook_path || ''), result: r.slice(0, 20000) };
167
- if (n === 'Grep' || n === 'Glob') {
168
- const ref = [input.pattern, input.path, input.glob].filter(Boolean).join(' ');
169
- return { kind: n, ref, strength: 'strong', text: ref, result: r.slice(0, 20000) };
170
- }
171
- if (n === 'Bash') return { kind: 'Bash', ref: String(input.description || input.command || '').slice(0, 120), strength: 'strong', text: String(input.command || '').slice(0, 2000), result: r.slice(0, 20000) };
172
- if (n.startsWith('mcp__') && !MCP_MUTATING.test(n)) {
173
- return { kind: 'mcp', ref: n.split('__').pop(), strength: 'strong', text: `${n} ${JSON.stringify(input).slice(0, 1000)}`, result: r.slice(0, 20000) };
159
+ const ok = observed && !failed && !pending && !outcome.uncertain && brainAnswered(content, { notAfterMs: resultAtMs == null ? null : resultAtMs + 5000 });
160
+ // Only the producer's returned document sections bind subjects; global repo/query banners do not.
161
+ const answer = answerOf(content) || '', body = answer.split(/^={10,}\s*$/m)
162
+ .map(hit => hit.split(/^----- full document(?: \([^\n]*\))? -----\r?\n/m).slice(1).join('\n')).join('\n');
163
+ return base('search_ruvnet', String(input.query || ''), ok && outcome.successfulToolResult && substantive(body) ? 'strong' : ok ? 'unknown' : 'failed', body, { ok });
174
164
  }
165
+ if (n === 'WebFetch') return base(n, String(input.url || ''), r ? 'weak' : 'unknown', r, { why: 'summarised-by-small-model' });
166
+ if (n === 'WebSearch') return base(n, String(input.query || ''), r ? 'weak' : 'unknown', r, { why: 'search-snippets' });
167
+ if (n === 'Agent' || n === 'Task') return base(n, String(input.description || input.subagent_type || ''), r ? 'weak' : 'unknown', r, { why: 'relayed-by-subagent' });
168
+ if (n === 'Read' || n === 'NotebookRead') return base('Read', String(input.file_path || input.notebook_path || ''),
169
+ nativeSuccess && substantive(bodyText) ? 'strong' : 'unknown', bodyText, { successEvidence: nativeSuccess ? 'native-read-success' : null });
170
+ if (n === 'Grep' || n === 'Glob') return base(n, [input.pattern, input.path, input.glob].filter(Boolean).join(' '),
171
+ n === 'Grep' && input.output_mode === 'content' && nativeSuccess && substantive(bodyText) ? 'strong' : 'unknown');
172
+ if (n === 'Bash') return base(n, String(input.description || input.command || '').slice(0, 120),
173
+ outcome.outcome === 'pass' && directSourceCommand(String(input.command || '')) && substantive(bodyText) ? 'strong' : 'unknown');
174
+ if (n.startsWith('mcp__') && !MCP_MUTATING.test(n)) return base('mcp', n.split('__').pop(), 'unknown');
175
175
  if (NOT_A_SOURCE.test(n)) return null;
176
176
  return null;
177
177
  }
@@ -183,7 +183,7 @@ export function turnSources(lines) {
183
183
  for (const o of recs) {
184
184
  const c = o?.message?.content;
185
185
  const at = Date.parse(o?.timestamp || '');
186
- if (Array.isArray(c)) for (const r of c) if (r?.type === 'tool_result' && r.tool_use_id) results.set(r.tool_use_id, { text: textOf(r.content), at: Number.isFinite(at) ? at : null });
186
+ if (Array.isArray(c)) for (const r of c) if (r?.type === 'tool_result' && r.tool_use_id) results.set(r.tool_use_id, { text: textOf(r.content), evidence: r, at: Number.isFinite(at) ? at : null });
187
187
  }
188
188
  const sources = [];
189
189
  for (const o of recs) {
@@ -192,7 +192,7 @@ export function turnSources(lines) {
192
192
  for (const u of c) {
193
193
  if (u?.type !== 'tool_use') continue;
194
194
  const res = results.get(u.id) || { text: '', at: null };
195
- const s = sourceOf(u.name, u.input || {}, res.text, { resultAtMs: res.at });
195
+ const s = sourceOf(u.name, u.input || {}, res.text, { resultAtMs: res.at, resultEvidence: res.evidence });
196
196
  if (s) sources.push({ ...s, order: sources.length });
197
197
  }
198
198
  }
@@ -382,18 +382,13 @@ export function capabilityClaims(rawMessage, tools) {
382
382
  return [...out.values()];
383
383
  }
384
384
 
385
- /**
386
- * Sources that name every word of the subject. A STRONG source may also bind through what it
387
- * returned (a file read, a command's output, a search's hits); a weak one only through what it
388
- * pointed at — a summary's own wording is exactly what is not trusted. A word of 5+ characters also
389
- * matches inside a longer compound (`displaylink` in `DisplayLinkUserAgent`).
390
- */
385
+ /** Strong sources bind through returned content only; summaries bind through their declared target. */
391
386
  export function bindingSources(subject, sources) {
392
387
  const words = subjectWords(subject);
393
388
  if (!words.length) return [];
394
389
  const has = (tokens, w) => tokens.has(w) || (w.length >= 5 && [...tokens].some((t) => t.length > w.length && t.includes(w)));
395
390
  return sources.filter((s) => {
396
- const t = tokenSet(s.strength === 'strong' ? `${s.text} ${s.ref} ${s.result || ''}` : s.text);
391
+ const t = tokenSet(s.strength === 'strong' ? s.result || '' : s.text);
397
392
  return words.every((w) => has(t, w));
398
393
  });
399
394
  }
@@ -431,7 +426,7 @@ export function auditAssertions({ message, subjects = [], vocab = [], sources =
431
426
  const lastWeak = Math.max(-1, ...binding.filter((s) => s.strength === 'weak').map((s) => s.order));
432
427
  const strongAfter = binding.some((s) => s.strength === 'strong' && s.order > lastWeak);
433
428
  if (!strongAfter) {
434
- findings.push({ ...claim, reason: lastWeak >= 0 ? 'the only sources about it this turn are weak (summarised or relayed)' : 'no source about it was read this turn',
429
+ findings.push({ ...claim, reason: lastWeak >= 0 ? 'the only verified sources about it this turn are weak (summarised or relayed)' : 'successful relevant returned source content is unproven (UNKNOWN or failed evidence)',
435
430
  read: describeSources(lastWeak >= 0 ? binding : sources) });
436
431
  }
437
432
  }
@@ -440,14 +435,14 @@ export function auditAssertions({ message, subjects = [], vocab = [], sources =
440
435
 
441
436
  export function describeSources(sources, max = 4) {
442
437
  if (!sources.length) return ['nothing'];
443
- const shown = sources.slice(-max).map((s) => `${s.kind} ${JSON.stringify(String(s.ref).slice(0, 70))}${s.strength === 'weak' ? ` [${s.why} = weak evidence]` : ''}`);
438
+ const shown = sources.slice(-max).map((s) => `${s.kind} ${JSON.stringify(String(s.ref).slice(0, 70))}${s.strength === 'weak' ? ` [${s.why} = weak evidence]` : ['failed', 'unknown'].includes(s.strength) ? ` [${s.strength} evidence]` : ''}`);
444
439
  return sources.length > max ? [`${sources.length - max} earlier`, ...shown] : shown;
445
440
  }
446
441
 
447
442
  export function correctionText(findings) {
448
443
  const f = findings[0];
449
444
  const lines = [
450
- `You asserted "${f.text.slice(0, 200)}" about ${f.subject}; no relevant source was read this turn`
445
+ `You asserted "${f.text.slice(0, 200)}" about ${f.subject}; a successful relevant source read is unproven this turn`
451
446
  + ` (${f.reason}; read: ${f.read.join('; ')}).`,
452
447
  ...findings.slice(1, 3).map((x) => `Also unsourced: "${x.text.slice(0, 160)}" about ${x.subject}.`),
453
448
  'Check the real source now (read the file, run the command with --help, search_ruvnet, or fetch the',
@@ -476,7 +471,7 @@ export function relayShadow({ message, sources }) {
476
471
  const relayed = nums.filter((n) => {
477
472
  const from = agentIdx.find((a) => String(a.result || '').includes(n));
478
473
  if (!from) return false;
479
- return !sources.some((s) => s.order > from.order && s.kind !== 'Agent' && s.kind !== 'Task' && String(s.result || '').includes(n));
474
+ return !sources.some((s) => s.order > from.order && s.strength === 'strong' && s.kind !== 'Agent' && s.kind !== 'Task' && String(s.result || '').includes(n));
480
475
  });
481
476
  return relayed.length ? { gate: 'adr-0030-3-relayed-number', numbers: relayed.slice(0, 8), wouldBlock: true } : null;
482
477
  }
@@ -29,20 +29,12 @@
29
29
  * tests/unit/ruvnet-gate1-pattern.test.mjs. This file does not re-test the prompt itself —
30
30
  * Stop's payload carries no prompt text — it reads grounding-turn-mark.mjs's marker instead
31
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
- * UPDATE (H1 / GitHub #316): per-product term files alone under-reported "was it searched" —
39
- * grounding-stamp.sh used to recognise only a 9-term write-gate vocabulary that omitted `ruvnet`
40
- * itself (and every other Gate-1 term), so a search literally about "ruvnet" minted nothing and
41
- * this gate wrongly fired. grounding-stamp.sh now also writes a vocabulary-independent
42
- * `.any-search` marker into this SAME directory on every successful search regardless of query
43
- * content, so newestGroundingStampMs below (which already scans every file, by name-agnostic
44
- * design) sees it with no code change needed here — a search_ruvnet call this turn always mints
45
- * evidence here now, not only when its query happens to name a recognised product.
32
+ * - "was search_ruvnet called": Claude's complete turn transcript is primary evidence. When
33
+ * transcript history is unavailable, only a successful PostToolUse receipt bound to the marker
34
+ * nonce and native session/project/turn identity may satisfy the obligation. Global product
35
+ * stamps remain write-gate freshness data and cannot certify a current turn. Unobservable native
36
+ * identity or absent relevant receipts is UNKNOWN, with one correction to check or qualify the
37
+ * claim; it is not a claim that no search happened.
46
38
  * - The Stop block/continue contract: `{"hookSpecificOutput":{"hookEventName":"Stop",
47
39
  * "additionalContext":"..."}}` on stdout, exit 0. This is not a new discovery — it is the exact
48
40
  * contract continuation-gate.mjs already uses and this repo's own tests already prove works on
@@ -64,7 +56,7 @@
64
56
  * mtimes. The stamp was a lossy proxy: 7 real false alarms were measured, 3 from a queued
65
57
  * mid-turn prompt re-dating the marker (fixed in grounding-turn-mark.mjs), 3 pre-H1 vocabulary
66
58
  * misses, 1 successful search whose stamp never minted. Codex's rollout is not parsed anywhere in
67
- * this repo, so Codex keeps the stamp evidence.
59
+ * this repo, so Codex requires native-turn-bound search receipts.
68
60
  * - When the marker says the prompt asked a capability/feasibility/architecture question, every
69
61
  * capability claim in the final answer needs a RELEVANT, STRONG source read this turn after the
70
62
  * last weak one (auditAssertions). A WebFetch body is a small model's summary: weak.
@@ -79,7 +71,7 @@
79
71
  * search is demanded only when the final answer asserts a rUv capability (ruvCapabilityClaims);
80
72
  * measured on a held-out set of 70 real Stop points: false positives 68/68 -> 0/68, and 2 borderline
81
73
  * claims (copula, parenthetical) are missed — tests/unit/grounding-turn-false-alarm.test.mjs.
82
- * A LONG turn (the transcript tail cannot see its start) falls back to the stamps, never to a pass.
74
+ * A LONG turn (the transcript tail cannot see its start) requires a bound receipt or reports UNKNOWN.
83
75
  *
84
76
  * FAILS OPEN ALWAYS. Exit 0 unconditionally — a gate that breaks a turn's completion because a
85
77
  * cache directory was unreadable would be disabled within a day.
@@ -90,7 +82,7 @@ import path from 'node:path';
90
82
  import { fileURLToPath } from 'node:url';
91
83
  import { groundingSubjectAllowed } from './ruvnet-gate1-pattern.mjs';
92
84
  import { readStopHookInput } from './hook-input.mjs';
93
- import { markerPathFor, consumeMarker } from './grounding-turn-mark.mjs';
85
+ import { MARKER_DIR, markerPathFor, consumeMarker, groundingIdentity, sameGroundingIdentity } from './grounding-turn-mark.mjs';
94
86
  import { readSettledTranscript } from './turn-outcome-capture.mjs';
95
87
  import {
96
88
  architectureShadow, auditAssertions, correctionText, describeSources, loadVocabulary, logShadow,
@@ -147,27 +139,30 @@ export function wasGroundedSince(markerMs, newestStampMs) {
147
139
  * The whole Stop decision for one armed turn: the correction text, or null. Exported so tests can
148
140
  * drive it with a synthetic transcript. Every failure inside returns null (fail open).
149
141
  */
150
- export function decide({ hookInput, marker, markerMs, env = process.env, read = readSettledTranscript }) {
142
+ export function decide({ hookInput, marker, markerMs, searchEvidence = null, ownershipUnknown = false, env = process.env, read = readSettledTranscript }) {
151
143
  try {
152
144
  const host = env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : 'claude';
153
145
  const tp = hookInput.transcript_path;
154
146
  let turn = null;
155
- if (host === 'claude' && typeof tp === 'string' && /\.jsonl$/i.test(tp)) {
147
+ if (!ownershipUnknown && host === 'claude' && typeof tp === 'string' && /\.jsonl$/i.test(tp)) {
156
148
  try { turn = turnSources(read(tp, { maxMs: 0 })); } catch { turn = null; }
157
149
  }
158
150
  // The transcript is read as a bounded TAIL. When the turn's opening prompt is not inside it
159
151
  // (a long turn), the tail is a suffix of the turn and cannot prove a search did NOT happen
160
- // earlier — so it is not evidence either way. Fall back to the stamp evidence (the same path
161
- // Codex uses), never to a silent pass: `return null` here let every long turn skip the gate.
152
+ // earlier — so it is not evidence either way. Only a bound native receipt can supply the missing
153
+ // proof; shared freshness or an unreadable history cannot certify this turn.
162
154
  if (turn && !turn.boundaryFound) turn = null;
163
155
  const sources = turn ? turn.sources : null;
164
156
  const message = String(hookInput.last_assistant_message || '');
157
+ const bound = searchEvidence && sameGroundingIdentity(marker, groundingIdentity(hookInput, env))
158
+ && sameGroundingIdentity(searchEvidence, marker) && searchEvidence.nonce === marker.nonce;
159
+ const terms = bound ? searchEvidence.terms : [];
165
160
 
166
161
  let assertion = null;
167
162
  if (marker.assert && message) {
168
163
  const vocab = loadVocabulary({ env });
169
164
  const audit = auditAssertions({ message, subjects: marker.subjects, vocab, sources,
170
- stampTerms: sources ? [] : stampTermsSince(markerMs),
165
+ stampTerms: sources ? [] : terms,
171
166
  subjectAllowed: (subject) => groundingSubjectAllowed(subject, marker.groundingScope) });
172
167
  if (audit.findings.length) assertion = audit.findings;
173
168
  const shadow = [architectureShadow({ architecture: marker.architecture, message }), relayShadow({ message, sources })].filter(Boolean);
@@ -177,8 +172,16 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
177
172
  // Gate 1 demands a search only when the answer ASSERTS what a rUv product does (the directive's
178
173
  // own words). A status report, git/CI check or memory write on a rUv-named repo asserts nothing.
179
174
  const ruvClaims = marker.gate1 === false ? [] : ruvCapabilityClaims(message).filter((claim) => groundingSubjectAllowed(claim.subject, marker.groundingScope));
175
+ if (!sources && (ruvClaims.length || assertion)) {
176
+ const queried = ruvClaims.every(claim => terms.includes(claim.subject));
177
+ if (!bound || !queried) return [
178
+ 'Grounding evidence for this session and turn is UNKNOWN; shared product freshness is not same-turn proof.',
179
+ 'Check the relevant product source with `search_ruvnet` in this native turn, or restate the capability as UNVERIFIED.',
180
+ 'This is one bounded correction; missing host evidence does not prove that no search occurred.',
181
+ ].join('\n');
182
+ }
180
183
  const grounded = !ruvClaims.length
181
- || (sources ? searchedThisTurn(sources) : wasGroundedSince(markerMs, newestGroundingStampMs()));
184
+ || (sources ? searchedThisTurn(sources) : Boolean(bound));
182
185
  if (assertion) {
183
186
  return correctionText(assertion) + (grounded ? '' : '\nThis turn also asserted what a rUv tool does and no successful search_ruvnet call was recorded: call it with the product term(s).');
184
187
  }
@@ -189,7 +192,7 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
189
192
  'ground-ruvnet\'s directive requires calling the search_ruvnet MCP tool before asserting what any',
190
193
  'RuvNet tool can/cannot do — but no successful',
191
194
  sources ? `search_ruvnet call is in this turn's transcript (read this turn: ${describeSources(sources).join('; ')}).`
192
- : 'search_ruvnet call was recorded this turn (checked against the grounding-stamp evidence).',
195
+ : 'search_ruvnet call was proven by a receipt bound to this native session, project, turn and marker nonce.',
193
196
  '',
194
197
  'Do NOT end the turn on an ungrounded rUv-domain answer. Call `search_ruvnet` now with the',
195
198
  'relevant product term(s) in the query, ground your answer in the cited source paths it returns,',
@@ -203,18 +206,20 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
203
206
  async function main() {
204
207
  const hookInput = await readStopHookInput();
205
208
  if (hookInput.__source !== 'stdin') process.exit(EXIT_ALLOW);
206
- if (hookInput.stop_hook_active) process.exit(EXIT_ALLOW);
207
- if (hookInput.hook_event_name !== 'Stop' || hookInput.interrupted || hookInput.cancelled) {
209
+ if (hookInput.stop_hook_active && !hookInput.interrupted && !hookInput.cancelled) process.exit(EXIT_ALLOW);
210
+ if (hookInput.hook_event_name !== 'Stop') {
208
211
  process.exit(EXIT_ALLOW);
209
212
  }
210
213
  if (!hookInput.session_id) process.exit(EXIT_ALLOW);
211
214
 
212
- const marker = markerPathFor(hookInput.session_id);
215
+ const identity = groundingIdentity(hookInput), marker = markerPathFor(hookInput.session_id, MARKER_DIR, identity.host);
213
216
  if (!marker) process.exit(EXIT_ALLOW);
214
217
 
215
- const episode = consumeMarker(marker);
218
+ const episode = consumeMarker(marker, identity);
216
219
  if (!episode) process.exit(EXIT_ALLOW);
217
- const text = decide({ hookInput, marker: episode.marker, markerMs: episode.markerMs });
220
+ if (hookInput.interrupted || hookInput.cancelled) process.exit(EXIT_ALLOW);
221
+ const text = decide({ hookInput, marker: episode.marker, markerMs: episode.markerMs,
222
+ searchEvidence: episode.searchEvidence, ownershipUnknown: episode.ownershipUnknown });
218
223
  if (!text) process.exit(EXIT_ALLOW);
219
224
 
220
225
  process.stdout.write(JSON.stringify({