ruvnet-brain 4.5.7 โ†’ 4.5.9

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 (35) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +53 -2
  3. package/package.json +5 -3
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/hooks/codex-hooks.json +31 -1
  7. package/plugin/hooks/hook-contracts.json +81 -1
  8. package/plugin/hooks/hooks.json +31 -1
  9. package/plugin/scripts/advocacy-catalog.mjs +1 -0
  10. package/plugin/scripts/agentdb-recall.mjs +38 -10
  11. package/plugin/scripts/continuity-hook-policy.mjs +3 -0
  12. package/plugin/scripts/ground-before-write.sh +27 -11
  13. package/plugin/scripts/grounding-code-projection.mjs +85 -0
  14. package/plugin/scripts/hook-shim.mjs +1 -1
  15. package/plugin/scripts/kb-copy-proof.mjs +80 -19
  16. package/plugin/scripts/learn-capture.mjs +44 -0
  17. package/plugin/scripts/learn-capture.sh +2 -241
  18. package/plugin/scripts/learn-flush.mjs +86 -191
  19. package/plugin/scripts/learning-observation.mjs +51 -0
  20. package/plugin/scripts/learning-queue.mjs +135 -0
  21. package/plugin/scripts/learning-store.mjs +105 -0
  22. package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
  23. package/plugin/scripts/project-progression-contract.mjs +58 -4
  24. package/plugin/scripts/runtime-preferences.mjs +45 -4
  25. package/plugin/scripts/session-start-core.mjs +8 -0
  26. package/plugin/scripts/session-start-proof.mjs +49 -0
  27. package/scripts/codex-fresh-host-proof.mjs +179 -0
  28. package/scripts/codex-host-execution-proof.mjs +246 -0
  29. package/scripts/codex-host-proof-runtime.mjs +104 -0
  30. package/scripts/console-engine.mjs +29 -16
  31. package/scripts/health-repair.mjs +47 -68
  32. package/scripts/onboarding-console.mjs +25 -54
  33. package/scripts/qa/progression-validation-benchmark.mjs +66 -0
  34. package/scripts/release-qualification-contract.mjs +58 -3
  35. package/scripts/remedy-registry.mjs +9 -2
package/README.md CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # ๐Ÿง  RuvNet Brain
9
9
 
10
- ### ๐Ÿง  RuvNet Brain โ€” [![RuvNet Brain version 4.5.7 โ€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.7-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### ๐Ÿง  RuvNet Brain โ€” [![RuvNet Brain version 4.5.9 โ€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.9-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack โ€” delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
package/bin/install.mjs CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  } from '../scripts/console-runtime-identity.mjs';
80
80
  import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
81
81
  import { codexTrustChanges, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
82
+ import { probeFreshCodexDeclarations } from '../scripts/codex-fresh-host-proof.mjs';
82
83
  import { repairSecurityGuidance } from '../scripts/security-guidance-codex-compat.mjs';
83
84
  import { installNativeLaunchers, installRoutingRuntime } from '../scripts/model-routing-launchers.mjs';
84
85
  import { installTerminalLaunchers } from '../scripts/model-terminal-launchers.mjs';
@@ -460,7 +461,9 @@ function resolveCacheDir() {
460
461
  );
461
462
  info(`brain dir: ${c.bold(cacheDir)}`);
462
463
  if (custom) info(`(from your RUVNET_BRAIN_KB override)`);
463
- fs.mkdirSync(cacheDir, { recursive: true });
464
+ // Activation creates the KB from a validated stage. A placeholder here becomes a false prior
465
+ // generation, which the cleanup proof correctly retains because it has no SOURCE.json.
466
+ fs.mkdirSync(path.dirname(cacheDir), { recursive: true });
464
467
  return { cacheDir, isCustom: Boolean(custom) };
465
468
  }
466
469
 
@@ -3045,6 +3048,21 @@ async function doctorRun({ json }) {
3045
3048
  const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
3046
3049
  { receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
3047
3050
  hostConvergence = classifyHostConvergence(recorded);
3051
+ const codex = recorded.hosts?.codex;
3052
+ if (codex?.state === 'ready' && codex.restartRequired === true && codex.restartScope === 'unproven') {
3053
+ let binary = process.env.CODEX_BIN || 'codex';
3054
+ if (!process.env.CODEX_BIN) {
3055
+ try {
3056
+ const configured = JSON.parse(fs.readFileSync(path.join(path.dirname(convergencePath), 'model-routing', 'terminal-launcher-config.json'), 'utf8'));
3057
+ if (typeof configured.realCodex === 'string' && path.isAbsolute(configured.realCodex)) binary = configured.realCodex;
3058
+ } catch { /* native PATH resolution remains bounded and fail closed */ }
3059
+ }
3060
+ const fresh = await probeFreshCodexDeclarations({ binary, codexHome: codexHomeDir(), cwd: process.cwd(),
3061
+ releasedPluginRoot: path.join(REPO_ROOT, 'plugin'), expectedVersion: PACKAGE_VERSION });
3062
+ hostConvergence = reconcileFreshCodexDeclarations(recorded, fresh);
3063
+ if (fresh.ok) info('Codex fresh-declarations-ready: current trusted hook declarations only; hook bodies and MCP execution were not tested. Existing open windows remain unproven.');
3064
+ else warn(`Codex fresh declarations remain unproven: ${fresh.reason}`);
3065
+ }
3048
3066
  if (hostConvergence.healthy) {
3049
3067
  ok(`host convergence receipt: ${hostConvergence.state}`);
3050
3068
  if (hostConvergence.notice) info(hostConvergence.notice);
@@ -3922,6 +3940,36 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3922
3940
  return { healthy: true, state: 'channels-converged' };
3923
3941
  }
3924
3942
 
3943
+ /** Activation and host readiness are independent; never claim a rollback from a host refusal. */
3944
+ export function hostSynchronizationFailureMessage(convergence, active, expectedVersion = PACKAGE_VERSION) {
3945
+ const verified = active?.version === expectedVersion && Number.isSafeInteger(active.generation) && active.generation > 0
3946
+ && active.codeRoot === `versions/${expectedVersion}`;
3947
+ const state = verified ? `runtime ${expectedVersion} generation ${active.generation} is active`
3948
+ : 'the active runtime generation could not be verified';
3949
+ return `host synchronization is incomplete โ€” ${state}${convergence?.error ? ` (${convergence.error})` : ''}`;
3950
+ }
3951
+
3952
+ /** Doctor projection only. Fresh hook metadata cannot clear an executable or MCP boot gap. */
3953
+ export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion = PACKAGE_VERSION) {
3954
+ const original = classifyHostConvergence(recorded, expectedVersion);
3955
+ const codex = recorded?.hosts?.codex;
3956
+ if (!proof?.ok || proof.state !== 'fresh-declarations-ready' || proof.expectedVersion !== expectedVersion
3957
+ || recorded?.desiredVersion !== expectedVersion || codex?.version !== expectedVersion
3958
+ || codex.state !== 'ready' || codex.restartRequired !== true || codex.restartScope !== 'unproven') return original;
3959
+ // Older receipts carry the paths only in this machine-generated reason. An unknown reason,
3960
+ // body file, skill/command surface, or MCP declaration retains the historical restart refusal.
3961
+ const prefix = 'boot-level declarations changed: ';
3962
+ const reason = codex.sessionSafetyReason;
3963
+ const changed = typeof reason === 'string' && reason.startsWith(prefix) ? reason.slice(prefix.length).split(', ') : [];
3964
+ if (!changed.length || changed.some((file) => !['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file))) {
3965
+ return { ...original, freshDeclarations: proof, notice: 'Fresh Codex hook declarations are ready; executable/MCP boot readiness and existing open windows remain unproven.' };
3966
+ }
3967
+ const projected = classifyHostConvergence({ ...recorded, hosts: { ...recorded.hosts,
3968
+ codex: { ...codex, restartScope: 'open-sessions' } } }, expectedVersion);
3969
+ return { ...projected, ...(projected.healthy ? { state: 'fresh-declarations-ready' } : {}), freshDeclarations: proof,
3970
+ notice: 'Fresh Codex hook declarations are ready; hook bodies and MCP execution were not tested. Existing open windows remain unproven.' };
3971
+ }
3972
+
3925
3973
  const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
3926
3974
  /** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
3927
3975
  export function openSessionsNotice(hosts, version) {
@@ -4233,7 +4281,9 @@ async function runUpdate() {
4233
4281
  info(c.dim('\nsynchronizing every detected host to this exact published versionโ€ฆ\n'));
4234
4282
  const convergence = syncHostsAfterUpdate(kbDir);
4235
4283
  if (!convergence.ok) {
4236
- warn(`host synchronization is incomplete โ€” runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
4284
+ let active;
4285
+ try { active = JSON.parse(fs.readFileSync(path.join(footprintRoots().brainHome, 'active.json'), 'utf8')); } catch { /* report unknown activation */ }
4286
+ warn(hostSynchronizationFailureMessage(convergence, active));
4237
4287
  updateStatus = 1;
4238
4288
  } else if (convergence.convergence?.notice) info(convergence.convergence.notice);
4239
4289
  try { reportLegacyRufloDebris(); } catch (error) { warn(`legacy ruflo debris cleanup failed: ${error.message}`); }
@@ -4953,6 +5003,7 @@ export function syncManagedRouterTools({ routerDir = path.join(os.homedir(), '.c
4953
5003
  }
4954
5004
  // Preserve package-relative imports without replacing user policy.mjs overrides.
4955
5005
  for (const runtimeRelative of [path.join('plugin', 'scripts', 'runtime-preferences.mjs'),
5006
+ path.join('plugin', 'scripts', 'project-identity.mjs'),
4956
5007
  path.join('config', 'model-router', 'policy.default.mjs')]) {
4957
5008
  const runtimeTarget = path.join(routerDir, runtimeRelative);
4958
5009
  const runtimeBytes = fs.readFileSync(path.join(packageRoot, runtimeRelative));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.7",
4
- "description": "One-command installer for RuvNet Brain โ€” 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.",
3
+ "version": "4.5.9",
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": {
7
7
  "ruvnet-brain": "bin/install.mjs"
@@ -74,7 +74,9 @@
74
74
  "hooks:check": "node scripts/hook-retirement-check.mjs",
75
75
  "release:qualify": "node scripts/release-qualification.mjs",
76
76
  "rehearse:corpus": "node scripts/rehearse-corpus-pipeline.mjs",
77
- "single-source:check": "node scripts/single-source-check.mjs"
77
+ "single-source:check": "node scripts/single-source-check.mjs",
78
+ "benchmark:continuity": "node scripts/qa/progression-validation-benchmark.mjs",
79
+ "host:codex:execution-proof": "node scripts/codex-host-execution-proof.mjs"
78
80
  },
79
81
  "files": [
80
82
  ".claude-plugin/marketplace.json",
@@ -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.5.7",
4
+ "version": "4.5.9",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.7",
3
+ "version": "4.5.9",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "RuvNet Brain continuity plane for Codex. Broad legacy automatic gates remain retired. Registrations here are MEASURED, not assumed: a probe hook was registered on all twelve event names the installed codex-cli 0.154.0 binary declares and a real `codex exec` run was observed (2026-09-11). SessionStart, UserPromptSubmit and SessionEnd FIRED and carry handlers. Stop keeps the pre-existing, project-scoped continuation gate; session-snapshot was added at Stop on 2026-09-29 to record each turn's outcome (turn-outcome-capture.mjs) \u2014 Codex SessionEnd carries no last_assistant_message, so Stop is the only boundary that can. Its delivery rests on codex-cli 0.158.0's declared stop.command.input schema and the other Codex Stop handlers; it has NOT been live-observed and fails open. PreCompact is DECLARED ABSENT here: the binary declares the event but the probe never observed it, and a capture registered on an unobserved event would look symmetrical while capturing nothing. All handlers are bounded and fail open. Amended 2026-09-11: UserPromptSubmit also runs ground-ruvnet (grounding injection, ADR-040 \u00a7Amendment). Amended 2026-09-12: the 2026-09-11 PreToolUse/PostToolUse \"not observed\" note was measured with a prompt (`codex exec \"reply OK\"`) that never invoked a tool, so neither event had anything to fire on \u2014 that was an untested path, not a failing one. Re-probed with prompts that actually call a tool: a real apply_patch write fired PreToolUse/PostToolUse with tool_name \"apply_patch\", and a real MCP call to this repo's own search_ruvnet server fired both with tool_name \"mcp__ruvnet_brain__search_ruvnet\". Both are now registered below: decision-gate's write route (matcher includes apply_patch, Codex's raw write-tool name) and grounding-stamp (unchanged matcher already recognizes the mcp__..__search_ruvnet shape). The bash route (exec_command) remains unregistered \u2014 today's measurement covered a write and an MCP call, not exec_command, and extending on that evidence would be the same unproven leap this note replaces. Also added 2026-09-12: grounding-turn-mark (UserPromptSubmit) and grounding-turn-gate (Stop), the \"answered without searching\" pair \u2014 a prompt-level grounding directive is advisory, so this records whether it fired and forces continuation at Stop if no search_ruvnet call was recorded since (reusing grounding-stamp's own stamp evidence). Both are registered on Codex identically to Claude: the Stop-block contract (hookSpecificOutput.additionalContext) already has a proven Codex translation via codex-hook-adapter.mjs's Stop branch (see tests/unit/codex-lifecycle-hooks.test.mjs), the same path continuation-gate already uses. Capacity-aware parallel-work guidance is also registered at UserPromptSubmit: it advises the coordinator to launch only real independent workers within sampled resource headroom and the live runtime/tool cap; it never spawns workers or claims execution. Automatic normalized project observations are captured at prompt, pre-tool, post-tool and child completion; Claude also observes PostToolUseFailure. Pending and degraded memory are disclosed; this does not establish synchronous ADR-073 conformance or native Grok recall. SessionStart has an 8-second measured replay/restore envelope.",
2
+ "description": "RuvNet Brain continuity plane for Codex. Broad legacy automatic gates remain retired. Registrations here are MEASURED, not assumed: a probe hook was registered on all twelve event names the installed codex-cli 0.154.0 binary declares and a real `codex exec` run was observed (2026-09-11). SessionStart, UserPromptSubmit and SessionEnd FIRED and carry handlers. Stop keeps the pre-existing, project-scoped continuation gate; session-snapshot was added at Stop on 2026-09-29 to record each turn's outcome (turn-outcome-capture.mjs) \u2014 Codex SessionEnd carries no last_assistant_message, so Stop is the only boundary that can. Its delivery rests on codex-cli 0.158.0's declared stop.command.input schema and the other Codex Stop handlers; it has NOT been live-observed and fails open. PreCompact is DECLARED ABSENT here: the binary declares the event but the probe never observed it, and a capture registered on an unobserved event would look symmetrical while capturing nothing. All handlers are bounded and fail open. Amended 2026-09-11: UserPromptSubmit also runs ground-ruvnet (grounding injection, ADR-040 \u00a7Amendment). Amended 2026-09-12: the 2026-09-11 PreToolUse/PostToolUse \"not observed\" note was measured with a prompt (`codex exec \"reply OK\"`) that never invoked a tool, so neither event had anything to fire on \u2014 that was an untested path, not a failing one. Re-probed with prompts that actually call a tool: a real apply_patch write fired PreToolUse/PostToolUse with tool_name \"apply_patch\", and a real MCP call to this repo's own search_ruvnet server fired both with tool_name \"mcp__ruvnet_brain__search_ruvnet\". Both are now registered below: decision-gate's write route (matcher includes apply_patch, Codex's raw write-tool name) and grounding-stamp (unchanged matcher already recognizes the mcp__..__search_ruvnet shape). The bash route (exec_command) remains unregistered \u2014 today's measurement covered a write and an MCP call, not exec_command, and extending on that evidence would be the same unproven leap this note replaces. Also added 2026-09-12: grounding-turn-mark (UserPromptSubmit) and grounding-turn-gate (Stop), the \"answered without searching\" pair \u2014 a prompt-level grounding directive is advisory, so this records whether it fired and forces continuation at Stop if no search_ruvnet call was recorded since (reusing grounding-stamp's own stamp evidence). Both are registered on Codex identically to Claude: the Stop-block contract (hookSpecificOutput.additionalContext) already has a proven Codex translation via codex-hook-adapter.mjs's Stop branch (see tests/unit/codex-lifecycle-hooks.test.mjs), the same path continuation-gate already uses. Capacity-aware parallel-work guidance is also registered at UserPromptSubmit: it advises the coordinator to launch only real independent workers within sampled resource headroom and the live runtime/tool cap; it never spawns workers or claims execution. Automatic normalized project observations are captured at prompt, pre-tool, post-tool and child completion; Claude also observes PostToolUseFailure. Pending and degraded memory are disclosed; this does not establish synchronous ADR-073 conformance or native Grok recall. SessionStart has an 8-second measured replay/restore envelope. Issue377: consent-eligible learning captures only fixed workflow metadata; lifecycle recovery schedules one finite worker. Original records remain retained with hash-bound acknowledgments. Native delivery must be separately evidenced; registration is not proof.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -11,6 +11,16 @@
11
11
  "timeout": 8
12
12
  }
13
13
  ]
14
+ },
15
+ {
16
+ "matcher": "startup|resume|clear|compact|fork",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "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 learn-flush",
21
+ "timeout": 3
22
+ }
23
+ ]
14
24
  }
15
25
  ],
16
26
  "Stop": [
@@ -77,6 +87,16 @@
77
87
  "timeout": 5
78
88
  }
79
89
  ]
90
+ },
91
+ {
92
+ "matcher": "^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$",
93
+ "hooks": [
94
+ {
95
+ "type": "command",
96
+ "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 learn-capture",
97
+ "timeout": 3
98
+ }
99
+ ]
80
100
  }
81
101
  ],
82
102
  "UserPromptSubmit": [
@@ -121,6 +141,16 @@
121
141
  "timeout": 3
122
142
  }
123
143
  ]
144
+ },
145
+ {
146
+ "matcher": "*",
147
+ "hooks": [
148
+ {
149
+ "type": "command",
150
+ "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 learn-flush",
151
+ "timeout": 3
152
+ }
153
+ ]
124
154
  }
125
155
  ],
126
156
  "SubagentStop": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_note": "The legacy automatic gate collection remains retired. What is permitted is the continuity plane below and nothing else: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint; Stop may nudge one explicitly authorized project-scoped objective AND capture a project snapshot; PreCompact and SessionEnd capture a project snapshot. Version 3 of this file permitted exactly two handlers, which was not a safety property but a contradiction: it demanded a restore while forbidding any event that could WRITE the journal the restore reads, so the canonical store held zero progression rows. Adding to this list is still a deliberate act that must pass `npm run hooks:check`; what changed is that the shape can now express the plane that actually works. Amended 2026-09-11 (Stuart): the continuity-only charter had retired the ONLY enforcement of ADR-0012 \u2014 never write rUv-product code the brain has not seen \u2014 and the failure it exists to prevent recurred the day it was measured absent. The plane now also carries three grounding registrations: ground-ruvnet (UserPromptSubmit, grounding injection \u2014 a second owner of that event, scoped by ADR-040 \u00a7Amendment 2026-09-11 to directives rather than speech), decision-gate\u2019s write route (PreToolUse, the one refuser, ADR-067), and grounding-stamp (PostToolUse on a successful search_ruvnet, the receipt that opens the write gate). Amended 2026-09-12: the 2026-09-11 claim that Codex PreToolUse/PostToolUse delivery had never been observed was measured with a prompt that never invoked a tool (`codex exec \"reply OK\"`), so it was an untested path, not a failing one. Re-measured with prompts that actually call a tool (a real apply_patch write, a real MCP search_ruvnet call against this repo's own server): both events FIRED on codex-cli 0.154.0 with real payloads. decision-gate's write route and grounding-stamp are now dual-host (see _codexCapture and contracts below); the bash route (exec_command) remains Claude-only \u2014 today's measurement did not exercise it. Also amended 2026-09-12: added grounding-turn-mark (UserPromptSubmit) and grounding-turn-gate (Stop), the \"answered without searching\" pair \u2014 ground-ruvnet's Gate 1 directive is advisory, so nothing previously checked whether the model complied before the turn ended. grounding-turn-mark records that Gate 1 fired for a turn; grounding-turn-gate forces continuation at Stop if grounding-stamp's own evidence shows no search_ruvnet call happened since. Both dual-host from the start (the Stop-block contract is already proven on Codex via continuation-gate). Added capacity-aware-parallel-work at UserPromptSubmit on both measured hosts: it is context-only, resource-bounded, and requires the coordinator to check actual tool/runtime slots; it does not spawn or claim workers. Amended 2026-09-30 (ADR-0030 decision point #1, no new registration): grounding-turn-mark also arms a turn when the prompt asks a capability/feasibility/architecture question about any tool or platform, and grounding-turn-gate then requires every capability claim in the final answer to be bound to a relevant STRONG source read this turn after the last weak one (a WebFetch body is a small model's summary: weak); on Claude it reads the transcript instead of stamp mtimes, which removed the measured 'no search recorded' false alarms. ADR-0030 #2/#3 run in shadow only. Amendment 2026-10-03: normalized prompt/tool/failure/child observations are automatic; startup replays before restoring. Queued and unavailable evidence remain distinct from exact committed readback. Native Grok prompt recall is unsupported.",
3
- "_version": 9,
3
+ "_version": 10,
4
4
  "_eventOwners": [
5
5
  {
6
6
  "event": "SessionStart",
@@ -169,6 +169,36 @@
169
169
  "codex"
170
170
  ],
171
171
  "responsibility": "Capture bounded redacted observable intent or outcome through the canonical progression bridge; retain pending/failed status and do not infer success from pre-tool intent."
172
+ },
173
+ {
174
+ "event": "PostToolUse",
175
+ "owner": "learn-capture",
176
+ "class": "workflow learning",
177
+ "hosts": [
178
+ "claude",
179
+ "codex"
180
+ ],
181
+ "responsibility": "Fixed vocabulary capture or finite selected-scope orphan recovery; no cross-scope legacy drain; registration alone does not prove native delivery."
182
+ },
183
+ {
184
+ "event": "SessionEnd",
185
+ "owner": "learn-flush",
186
+ "class": "workflow learning",
187
+ "hosts": [
188
+ "claude",
189
+ "codex"
190
+ ],
191
+ "responsibility": "Fixed vocabulary capture or finite selected-scope orphan recovery; no cross-scope legacy drain; registration alone does not prove native delivery."
192
+ },
193
+ {
194
+ "event": "SessionStart",
195
+ "owner": "learn-flush",
196
+ "class": "workflow learning",
197
+ "hosts": [
198
+ "claude",
199
+ "codex"
200
+ ],
201
+ "responsibility": "Fixed vocabulary capture or finite selected-scope orphan recovery; no cross-scope legacy drain; registration alone does not prove native delivery."
172
202
  }
173
203
  ],
174
204
  "_notInThisPlane": [
@@ -407,6 +437,42 @@
407
437
  "offBehavior": "run",
408
438
  "matcher": "*",
409
439
  "timeout": 10
440
+ },
441
+ {
442
+ "id": "learn-capture",
443
+ "event": "PostToolUse",
444
+ "hosts": [
445
+ "claude",
446
+ "codex"
447
+ ],
448
+ "mode": "advisory",
449
+ "offBehavior": "silence",
450
+ "matcher": "^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$",
451
+ "timeout": 3
452
+ },
453
+ {
454
+ "id": "learn-flush",
455
+ "event": "SessionEnd",
456
+ "hosts": [
457
+ "claude",
458
+ "codex"
459
+ ],
460
+ "mode": "advisory",
461
+ "offBehavior": "silence",
462
+ "matcher": "*",
463
+ "timeout": 3
464
+ },
465
+ {
466
+ "id": "learn-flush",
467
+ "event": "SessionStart",
468
+ "hosts": [
469
+ "claude",
470
+ "codex"
471
+ ],
472
+ "mode": "advisory",
473
+ "offBehavior": "silence",
474
+ "matcher": "startup|resume|clear|compact|fork",
475
+ "timeout": 3
410
476
  }
411
477
  ],
412
478
  "matcherAllowlist": [
@@ -549,6 +615,20 @@
549
615
  "matcher": "*",
550
616
  "reason": "Explicit supported Codex lifecycle registration, corresponding to the host-filtered continuity policy; its enforcing handler and live registration are checked independently.",
551
617
  "retiredBy": "not retired - canonical continuity host parity"
618
+ },
619
+ {
620
+ "layer": "plugin",
621
+ "event": "PostToolUse",
622
+ "matcher": "^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$",
623
+ "reason": "Consent-eligible normalized fixed workflow vocabulary, privacy-minimized before write.",
624
+ "retiredBy": "reviewed issue377 learning repair"
625
+ },
626
+ {
627
+ "layer": "codex",
628
+ "event": "PostToolUse",
629
+ "matcher": "^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$",
630
+ "reason": "Consent-eligible normalized fixed workflow vocabulary, privacy-minimized before write.",
631
+ "retiredBy": "reviewed issue377 learning repair"
552
632
  }
553
633
  ]
554
634
  }
@@ -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 \u2014 ADR-040 \u00a7Amendment 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 \u2014 the Stop stdout envelopes are advisory at the shim boundary but keep the agent working. Automatic normalized project observations are captured at prompt, pre-tool, post-tool and child completion; Claude also observes PostToolUseFailure. Pending and degraded memory are disclosed; this does not establish synchronous ADR-073 conformance or native Grok recall. SessionStart has an 8-second measured replay/restore envelope.",
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 \u2014 ADR-040 \u00a7Amendment 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 \u2014 the Stop stdout envelopes are advisory at the shim boundary but keep the agent working. Automatic normalized project observations are captured at prompt, pre-tool, post-tool and child completion; Claude also observes PostToolUseFailure. Pending and degraded memory are disclosed; this does not establish synchronous ADR-073 conformance or native Grok recall. SessionStart has an 8-second measured replay/restore envelope. Issue377: consent-eligible learning captures only fixed workflow metadata; lifecycle recovery schedules one finite worker. Original records remain retained with hash-bound acknowledgments. Native delivery must be separately evidenced; registration is not proof.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -11,6 +11,16 @@
11
11
  "timeout": 8
12
12
  }
13
13
  ]
14
+ },
15
+ {
16
+ "matcher": "startup|resume|clear|compact|fork",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" learn-flush || true",
21
+ "timeout": 3
22
+ }
23
+ ]
14
24
  }
15
25
  ],
16
26
  "UserPromptSubmit": [
@@ -87,6 +97,16 @@
87
97
  "timeout": 5
88
98
  }
89
99
  ]
100
+ },
101
+ {
102
+ "matcher": "^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$",
103
+ "hooks": [
104
+ {
105
+ "type": "command",
106
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" learn-capture || true",
107
+ "timeout": 3
108
+ }
109
+ ]
90
110
  }
91
111
  ],
92
112
  "Stop": [
@@ -133,6 +153,16 @@
133
153
  "timeout": 10
134
154
  }
135
155
  ]
156
+ },
157
+ {
158
+ "matcher": "*",
159
+ "hooks": [
160
+ {
161
+ "type": "command",
162
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" learn-flush || true",
163
+ "timeout": 3
164
+ }
165
+ ]
136
166
  }
137
167
  ],
138
168
  "SubagentStop": [
@@ -145,6 +145,7 @@ export const INTENTS = Object.freeze([
145
145
  fit: 'the request is to retrieve by meaning rather than by literal match',
146
146
  cues: Object.freeze([
147
147
  /\bby meaning\b/,
148
+ /^(?:please\s+)?(?:search|retrieve|find)\s+(?:these|those|my|our|the)\s+(?:docs?|documents?|passages?|chunks?|notes?)\s+by meaning\b/,
148
149
  /\bsemantic(ally)?\s+(search|retriev|match|similar)/,
149
150
  /\b(instead of|rather than|not just|beyond)\s+(an?\s+)?(exact\s+)?(keywords?|string|substring|literal|grep)/,
150
151
  /\bvector\s+(search|database|db|store|index|embedding)/,
@@ -14,6 +14,9 @@ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
14
14
  import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
15
15
  import { resolveProjectStore } from './project-store-resolver.mjs';
16
16
  import { isHarnessGenerated, readStdinBounded } from './hook-input.mjs';
17
+ import { learningContext } from './runtime-preferences.mjs';
18
+ import { learningTarget, LEARNING_NAMESPACE } from './learning-store.mjs';
19
+ import { safeAction } from './learning-queue.mjs';
17
20
 
18
21
  import { redactText } from './continuity-events.mjs';
19
22
  export const STORE_FILES = Object.freeze(['memory.db']);
@@ -146,11 +149,22 @@ export function evidenceExcerpt(value, key, prompt = '') {
146
149
  return clean(start >= 0 ? text.slice(start) : text, 110);
147
150
  }
148
151
 
152
+ export function learningObservationExcerpt(raw) {
153
+ try {
154
+ const row = JSON.parse(raw);
155
+ if (Object.keys(row).sort().join(',') !== 'action,authoritative,outcome,provenance,schemaVersion,scope,tool'
156
+ || row.schemaVersion !== 1 || row.authoritative !== false || row.provenance !== 'system-observation'
157
+ || row.outcome !== 'host-reported-success' || !['project', 'user'].includes(row.scope)
158
+ || !['Bash', 'Write', 'Edit', 'MultiEdit'].includes(row.tool) || safeAction(row.tool, row.action) !== row.action) return null;
159
+ return `Unratified host-reported success: ${row.tool} ${row.action}. Verify current results.`;
160
+ } catch { return null; }
161
+ }
162
+
149
163
  export function pickRows(results, limit = 3) {
150
164
  const candidates = results.flatMap((r) => r.rows.filter((p) => p.namespace === r.namespace && (!NOISE_KEY.test(p.key) || p.namespace === 'turns' && /^turn[-_]/i.test(p.key))
151
165
  && (!r.family || p.key.toLowerCase().includes(r.family))
152
- && Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, targeted: Boolean(r.family) })));
153
- const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : 0;
166
+ && Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, storePath: r.storePath, targeted: Boolean(r.family) })));
167
+ const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : p.namespace === LEARNING_NAMESPACE ? -1 : 0;
154
168
  const ranked = candidates.sort((a, b) => Number(b.targeted) - Number(a.targeted) || signal(b) - signal(a) || b.score - a.score);
155
169
  // Curated lessons and patterns are signal; lifecycle transcript telemetry is not.
156
170
  // Do not reserve a slot for a weak match just because its namespace was searched.
@@ -159,7 +173,7 @@ export function pickRows(results, limit = 3) {
159
173
  if (chosen.length >= limit) break;
160
174
  if (!chosen.some((r) => r.key === row.key && r.namespace === row.namespace)) chosen.push(row);
161
175
  }
162
- return chosen.map((p) => ({ store: 'memory.db', key: p.key, namespace: p.namespace,
176
+ return chosen.map((p) => ({ store: 'memory.db', ...(p.storePath ? { storePath: p.storePath } : {}), key: p.key, namespace: p.namespace,
163
177
  score: p.score, preview: clean(p.preview, 110) }));
164
178
  }
165
179
 
@@ -227,7 +241,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
227
241
  const budget = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 1900) : DEFAULT_DEADLINE_MS;
228
242
  const deadline = started + budget;
229
243
  const { root, stores } = agentdbStores(projectDir, Math.max(1, Math.min(100, Math.floor(budget / 4))));
230
- if (!stores.length) return empty;
244
+ let learningStore;
245
+ try {
246
+ const context = learningContext({ env, cwd: root });
247
+ if (context.enabled) {
248
+ const db = learningTarget(context, { env });
249
+ if (fs.lstatSync(db).isFile()) learningStore = { name: 'memory.db', path: db };
250
+ }
251
+ } catch { /* No global fallback: only an explicitly authorized existing learning store. */ }
252
+ if (!stores.length && !learningStore) return empty;
231
253
  const bin = ruflo === undefined ? resolveRuflo({ env }) : ruflo;
232
254
  if (!bin) return { ...empty, stores };
233
255
  const store = stores[0];
@@ -238,10 +260,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
238
260
  : /where are we|status|catch me up/i.test(prompt) ? 'project-state-current'
239
261
  : /releas|publish|workflow run|dispatch/i.test(prompt) ? 'release'
240
262
  : /requirement|always|every prompt/i.test(prompt) ? 'decision-agentdb' : null;
241
- const jobs = namespaces.flatMap((namespace) => [{ namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
242
- ...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
243
- const results = await Promise.all(jobs.map(async ({ namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily,
244
- ...await searchOnce({ ruflo: bin, store, deadline, env, scratch: scratchFor, args }) })));
263
+ const jobs = (store ? namespaces : []).flatMap((namespace) => [{ store, namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
264
+ ...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ store, namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
265
+ const words = promptKeywords(prompt, 14);
266
+ const workflowQuery = words.map(word => safeAction('Bash', word))
267
+ .find(action => action && action !== 'command');
268
+ if (learningStore && workflowQuery) jobs.push({ store: learningStore, namespace: LEARNING_NAMESPACE, family: null,
269
+ args: ['--format', 'json', '-q', workflowQuery, '-n', LEARNING_NAMESPACE, '-t', 'keyword', '--limit', '4'] });
270
+ const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
271
+ ...await searchOnce({ ruflo: bin, store: jobStore, deadline, env, scratch: scratchFor, args }) })));
245
272
  let status = results.every((r) => r.state === 'ok') ? 'ok'
246
273
  : results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
247
274
  // Overfetch a bounded six exact values so rejected turn metadata cannot hide
@@ -250,11 +277,12 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
250
277
  // Ruflo previews are ~60 characters. Read the actual selected values within
251
278
  // the same deadline so the block contains useful evidence rather than titles.
252
279
  const retrieved = await Promise.all(candidates.map(async (p) => {
253
- const r = await searchOnce({ ruflo: bin, store, deadline, env, scratch: scratchFor, operation: 'retrieve',
280
+ const r = await searchOnce({ ruflo: bin, store: { name: 'memory.db', path: p.storePath }, deadline, env, scratch: scratchFor, operation: 'retrieve',
254
281
  args: ['-k', p.key, '-n', p.namespace, '--value-only'] });
255
282
  if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]')) {
256
283
  const clauses = p.namespace === 'turns' ? turnOutcomeClauses(r.value, prompt) : null;
257
- const preview = clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
284
+ const preview = p.namespace === LEARNING_NAMESPACE ? learningObservationExcerpt(r.value)
285
+ : clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
258
286
  return { pick: preview ? { ...p, preview, clauses } : null, state: 'ok' };
259
287
  }
260
288
  return { pick: null, state: r.state === 'ok' ? 'unavailable' : r.state };
@@ -123,6 +123,7 @@ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts:
123
123
  export const CONTINUITY_EVENTS = Object.freeze({
124
124
  SessionStart: Object.freeze([
125
125
  registration('session-start', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
126
+ registration('learn-flush', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
126
127
  ]),
127
128
  UserPromptSubmit: Object.freeze([
128
129
  registration('session-snapshot', '*', ['claude', 'codex']),
@@ -153,6 +154,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
153
154
  // no change either (proven live: the real MCP tool_response's `content[0].text` carries the exact
154
155
  // "Searched N RuvNet repos" banner and query text the script already looks for).
155
156
  PostToolUse: Object.freeze([
157
+ registration('learn-capture', '^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$', ['claude', 'codex']),
156
158
  registration('session-snapshot', '*', ['claude', 'codex']),
157
159
  registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
158
160
  ]),
@@ -184,6 +186,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
184
186
  registration('session-snapshot', '*', ['claude']),
185
187
  ]),
186
188
  SessionEnd: Object.freeze([
189
+ registration('learn-flush', '*', ['claude', 'codex']),
187
190
  registration('session-snapshot', '*', ['claude', 'codex']),
188
191
  ]),
189
192
  });
@@ -98,11 +98,10 @@ SKIP_RECENCY=0
98
98
  [ "${RUVNET_SKIP_GROUNDING_CHECK:-0}" = "1" ] && SKIP_RECENCY=1
99
99
 
100
100
  # This gate is a BLOCKING wall (the #1-failure preventer), so by deliberate design (ADR-0021, and
101
- # enforced by ground-before-write.test.mjs) it depends on NOTHING fragile โ€” pure bash builtins, no
102
- # node/jq/python โ€” and therefore cannot fail-open because a tool went missing. The #13 quote-truncation
103
- # that justified hook-input.mjs for design-wall does NOT bite here: the product-term scan below runs
104
- # over the RAW payload (untouched by field()), and the only parsed values are tool_name (Write/Edit โ€”
105
- # no quotes) and file_path (a truncated path merely fails the extension check โ†’ exit 0, harmless).
101
+ # enforced by ground-before-write.test.mjs) its default refusal depends on no JSON parser. The optional
102
+ # inert-code exemption below can narrow a valid input; a missing helper or any uncertainty leaves the
103
+ # raw product scan intact. The legacy field parser below selects only tool name and code-file path;
104
+ # it never supplies the code text used by the optional projection.
106
105
  field() { local re="\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\""; [[ $INPUT =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"; }
107
106
 
108
107
  # Case-insensitive (4.5): Grok sends `write` / `search_replace`; a case-sensitive match allowed a Grok
@@ -134,8 +133,8 @@ shopt -s nocasematch 2>/dev/null || true
134
133
  STAMP_DIR="$HOME/.cache/ruvnet-brain/grounded"
135
134
  NOW=$(date +%s 2>/dev/null) || exit 0
136
135
 
137
- # Scan the WHOLE tool input (path + content + new_string) โ€” where the code mentions the
138
- # product is where the hand-roll hides.
136
+ # Scan raw product terms without a parser; the optional inert-text exemption below may narrow
137
+ # this result, but missing dependencies cannot erase the raw refusal.
139
138
  MISSING=""
140
139
  SEEN=""
141
140
  for t in agentdb metaharness ruvector aidefence agentic-flow agentic-qe ruv-swarm rvf ruflo; do
@@ -156,10 +155,10 @@ done
156
155
  # PASSED: a term the model has never grounded is stopped by stage 1 and never reaches here.
157
156
  #
158
157
  # โ”€โ”€ THE DEPENDENCY CONTRACT, AND WHY THIS DOES NOT BREAK IT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
159
- # Everything above this line is pure bash builtins, by standing contract (ADR-0021, enforced by
160
- # ground-before-write.test.mjs): the recency wall is THE blocking wall, so it can never fail open
161
- # because a tool went missing. That contract is unchanged and still tested โ€” the assertion now
162
- # reads the file UP TO THIS MARKER.
158
+ # Everything above this marker remains pure bash: the recency wall computes a refusal without
159
+ # node/jq/python (ADR-0021). The optional exemption below can only narrow that raw result after
160
+ # proving an inert mention; absence or uncertainty retains it. The substance stage keeps its
161
+ # separate, explicitly fail-open contradiction contract.
163
162
  #
164
163
  # The substance stage is different in kind and is allowed one dependency, node, because deciding
165
164
  # "does this write contradict the source the brain returned?" requires a real JSON parse of the
@@ -169,6 +168,23 @@ done
169
168
  # produce a refusal here is a detector that found a contradiction, which is ADR-055 ยง1.2's
170
169
  # "malfunction โ‰  decision" applied to the one stage that has something to malfunction.
171
170
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
171
+ # Optional positive exemption (#373), only when a raw product mention exists. A missing helper,
172
+ # node, empty output or lexical uncertainty leaves the pure-bash result unchanged. This stage
173
+ # cannot manufacture a refusal and never changes the raw payload used by the substance wall.
174
+ PROJECTION="$(dirname "${BASH_SOURCE[0]}")/grounding-code-projection.mjs"
175
+ if [ -n "$SEEN" ] && [ -f "$PROJECTION" ] && command -v node >/dev/null 2>&1; then
176
+ PROJECTED=$(printf '%s' "$INPUT" | node "$PROJECTION" 2>/dev/null)
177
+ if [ "$?" = "0" ] && [ -n "$PROJECTED" ]; then
178
+ PROJECTED_MISSING=""; PROJECTED_SEEN=""
179
+ for t in $SEEN; do
180
+ [[ $PROJECTED == *"$t"* ]] || continue
181
+ PROJECTED_SEEN="$PROJECTED_SEEN$t "
182
+ [[ " $MISSING" == *" $t "* ]] && PROJECTED_MISSING="$PROJECTED_MISSING$t "
183
+ done
184
+ MISSING="$PROJECTED_MISSING"; SEEN="$PROJECTED_SEEN"
185
+ fi
186
+ fi
187
+
172
188
  if [ -z "$MISSING" ] || [ "$SKIP_RECENCY" = "1" ]; then
173
189
  [ -n "$SEEN" ] || exit 0
174
190
  SUBSTANCE="$(dirname "${BASH_SOURCE[0]}")/grounding-substance.mjs"