ruvnet-brain 4.5.16 → 4.5.17
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 +1 -1
- package/bin/install.mjs +1 -1
- package/config/practical-rule-catalog.json +111 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +63 -22
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +1 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/source-scope-receipt.mjs +9 -4
|
@@ -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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
59
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
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(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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' ?
|
|
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)' : '
|
|
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};
|
|
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":
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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)
|
|
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.
|
|
161
|
-
//
|
|
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 ? [] :
|
|
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) :
|
|
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
|
|
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'
|
|
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
|
-
|
|
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({
|