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.
- package/README.md +2 -2
- package/bin/install.mjs +179 -36
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +6 -2
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +33 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-reader.mjs +10 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +201 -6
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +383 -37
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +203 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +162 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- 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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
4
|
+
"version": "4.4.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -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)\"
|
|
96
|
-
"timeout":
|
|
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
|
}
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -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
|
|
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
|
|
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"
|
|
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
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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
|
|
45
|
+
[ ${#INPUT} -ge 2097152 ] && break
|
|
47
46
|
done
|
|
48
47
|
[ -n "$_l" ] && INPUT+="$_l"
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
[[ $
|
|
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
|