ruvnet-brain 4.3.35 → 4.3.37

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 (34) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/plugin/hooks/codex-hooks.json +6 -1
  6. package/plugin/hooks/hook-contracts.json +6 -4
  7. package/plugin/scripts/continuity-hook-policy.mjs +10 -3
  8. package/plugin/scripts/coverage-integrity.mjs +58 -2
  9. package/plugin/scripts/hook-shim.mjs +3 -1
  10. package/plugin/scripts/session-snapshot-hook.mjs +11 -1
  11. package/plugin/scripts/turn-outcome-capture.mjs +292 -0
  12. package/scripts/build-bundle.mjs +27 -1
  13. package/scripts/code-release-corpus.mjs +276 -0
  14. package/scripts/corpus-candidate.mjs +19 -3
  15. package/scripts/corpus-coverage-sidecar.mjs +202 -0
  16. package/scripts/corpus-currency.mjs +71 -0
  17. package/scripts/corpus-dispatch-decision.mjs +138 -0
  18. package/scripts/corpus-next-seed.mjs +69 -12
  19. package/scripts/corpus-reconcile.mjs +340 -61
  20. package/scripts/corpus-store-failure.mjs +82 -0
  21. package/scripts/corpus-watchdog.mjs +334 -0
  22. package/scripts/derive-passage-content-map.mjs +71 -0
  23. package/scripts/fixture-denominator.mjs +72 -0
  24. package/scripts/knowledge-input-digest.mjs +163 -0
  25. package/scripts/oracle/repo-recall.mjs +142 -21
  26. package/scripts/public-verification-inputs.mjs +42 -4
  27. package/scripts/rehearse-corpus-pipeline.mjs +350 -20
  28. package/scripts/release-transaction-provider.mjs +29 -6
  29. package/scripts/release.mjs +156 -18
  30. package/scripts/retrieval-canary.mjs +83 -24
  31. package/scripts/retrieval-passage-identity.mjs +58 -0
  32. package/scripts/source-coverage.mjs +19 -3
  33. package/scripts/sync-census.mjs +0 -0
  34. package/scripts/wired-check.mjs +4 -0
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.3.35 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.35-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.3.37 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.37-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
 
@@ -562,7 +562,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
562
562
 
563
563
  This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) — we don't claim “done,” “complete,” or “zero hallucinations.” Where it stands:
564
564
 
565
- - ✅ **The grounding brain is real and proven** — 182 public stores · 143,682 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
565
+ - ✅ **The grounding brain is real and proven** — 199 public stores · 160,685 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
566
566
  - ✅ **Code-level depth** — the code-rich repos are indexed to full function bodies; “how is it implemented?” returns the implementation. Verified in the shipped bundle (clean-room 3/3).
567
567
  - ✅ **Routing holds** — named 47/48, described 26/28, scenario 7/8; behavioral L1–L3 all pass (**L4 downgraded — it measures that the brain spoke, not that anything listened**); private stores fenced out of the public bundle (zero-leak verified).
568
568
  - ⚠️ **Two routing residuals** (above) — surfaced, not hidden.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.35",
3
+ "version": "4.3.37",
4
4
  "description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.3.35",
4
+ "version": "4.3.37",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.35",
3
+ "version": "4.3.37",
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 only the pre-existing, project-scoped continuation gate. 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 §Amendment). 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 — 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 — 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 — 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.",
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) — 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 §Amendment). 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 — 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 — 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 — 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.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -21,6 +21,11 @@
21
21
  "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 continuation-gate",
22
22
  "timeout": 10
23
23
  },
24
+ {
25
+ "type": "command",
26
+ "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot Stop",
27
+ "timeout": 10
28
+ },
24
29
  {
25
30
  "type": "command",
26
31
  "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 grounding-turn-gate",
@@ -37,9 +37,10 @@
37
37
  "owner": "session-snapshot",
38
38
  "class": "continuity capture",
39
39
  "hosts": [
40
- "claude"
40
+ "claude",
41
+ "codex"
41
42
  ],
42
- "responsibility": "Append one project snapshot to the canonical store and commit any outbox debt a previously interrupted session left behind."
43
+ "responsibility": "Append one project snapshot to the canonical store and commit any outbox debt a previously interrupted session left behind. Also records the turn's outcome (final assistant text, files changed, command descriptions — never user text) to AgentDB namespace `turns`, in the project store when it exists or the machine-wide store outside the repository; SessionEnd/PreCompact distill those records (ruflo ADR-174)."
43
44
  },
44
45
  {
45
46
  "event": "PreCompact",
@@ -153,7 +154,7 @@
153
154
  "Stop": "The 2026-09-11 probe turn never completed, so run_turn_stop_hooks had no completion to fire on. The binary declares the event and StopCommandOutputWire, so this is not-proven rather than unsupported. Stop keeps only the pre-existing continuation gate; no capture handler was added to an event whose delivery has not been seen.",
154
155
  "PreCompact": "A one-line turn never approaches a compaction threshold. The binary declares PreCompact and PreCompactCommandOutputWire. No capture handler registered."
155
156
  },
156
- "consequence": "Codex captures at SessionEnd only; Stop/PreCompact remain unproven and unregistered as above (unchanged by this measurement). PreToolUse/PostToolUse are now proven for a write (apply_patch) and an MCP tool call (search_ruvnet) specifically, and decision-gate's write route plus grounding-stamp are registered on Codex accordingly (see contracts below). Bash-class tool calls (exec_command) were not exercised by this measurement and remain unregistered — extending on unexercised evidence would repeat the exact mistake this record corrects."
157
+ "consequence": "Codex captures at SessionEnd only; Stop/PreCompact remain unproven and unregistered as above (unchanged by this measurement). PreToolUse/PostToolUse are now proven for a write (apply_patch) and an MCP tool call (search_ruvnet) specifically, and decision-gate's write route plus grounding-stamp are registered on Codex accordingly (see contracts below). Bash-class tool calls (exec_command) were not exercised by this measurement and remain unregistered — extending on unexercised evidence would repeat the exact mistake this record corrects. Amended 2026-09-29: session-snapshot is registered on Codex Stop so each Codex turn's outcome is recorded (SessionEnd carries no last_assistant_message). Stop delivery is still NOT live-observed; the registration rests on codex-cli 0.158.0's declared stop.command.input schema and fails open."
157
158
  },
158
159
  "contracts": [
159
160
  {
@@ -196,7 +197,8 @@
196
197
  "id": "session-snapshot",
197
198
  "event": "Stop",
198
199
  "hosts": [
199
- "claude"
200
+ "claude",
201
+ "codex"
200
202
  ],
201
203
  "mode": "advisory",
202
204
  "offBehavior": "run",
@@ -20,7 +20,7 @@
20
20
  * session-start continuity recovery at SessionStart claude, codex
21
21
  * unprompted-speech advisory delivery at UserPromptSubmit claude, codex
22
22
  * continuation-gate continuation nudge at turn end claude, codex
23
- * session-snapshot continuity capture at turn end claude
23
+ * session-snapshot continuity capture at turn end claude, codex
24
24
  * session-snapshot continuity capture at PreCompact claude
25
25
  * session-snapshot continuity capture at SessionEnd claude, codex
26
26
  * ground-ruvnet grounding injection at UserPromptSubmit claude, codex
@@ -95,7 +95,8 @@ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts:
95
95
  * struct, so this is "not proven", not "not supported".
96
96
  * PreCompact NOT OBSERVED — a one-line turn never approaches a compaction threshold.
97
97
  *
98
- * Therefore Codex capture is registered at SessionEnd ONLY. Stop keeps the pre-existing
98
+ * Therefore Codex capture was registered at SessionEnd ONLY (Stop added 2026-09-29 for turn
99
+ * outcomes — see the Stop registration below; its delivery is still unobserved). Stop keeps the pre-existing
99
100
  * continuation-gate registration (unchanged by this lane); no NEW handler is added to an event whose
100
101
  * delivery has not been seen. hook-contracts.json carries the same measurement and its date.
101
102
  *
@@ -154,7 +155,13 @@ export const CONTINUITY_EVENTS = Object.freeze({
154
155
  ]),
155
156
  Stop: Object.freeze([
156
157
  registration('continuation-gate', '*', ['claude', 'codex']),
157
- registration('session-snapshot', '*', ['claude']),
158
+ // Codex added 2026-09-29 (owner requirement: every turn's outcome recorded on BOTH hosts —
159
+ // turn-outcome-capture.mjs). Codex SessionEnd carries no last_assistant_message, so Stop is the
160
+ // only boundary that can record a Codex turn. Evidence: codex-cli 0.158.0's own
161
+ // stop.command.input schema (last_assistant_message, turn_id) and the two Codex Stop handlers
162
+ // already registered above/below. NOT yet live-observed firing — see the probe box: the capture
163
+ // fails open, so an unfired registration costs nothing but must not be read as proof.
164
+ registration('session-snapshot', '*', ['claude', 'codex']),
158
165
  // The "answered without searching" gate, half 2 of 2 (2026-09-12). Forces continuation
159
166
  // (hookSpecificOutput.additionalContext — the same contract continuation-gate.mjs already uses
160
167
  // and codex-hook-adapter.mjs already translates to Codex's decision:block on both hosts) when
@@ -225,6 +225,41 @@ export function generationLedgerBytes(ledger) {
225
225
  return Buffer.from(`${JSON.stringify(ledger, null, 2)}\n`);
226
226
  }
227
227
 
228
+ const isIsoOrNull = (value) => value === null || (typeof value === 'string' && Number.isFinite(Date.parse(value)));
229
+ const isAttemptCount = (value) => Number.isSafeInteger(value) && value >= 1;
230
+
231
+ /**
232
+ * ADR-0091 D5: how an ELIGIBLE repository row stands in a bundle. It returns
233
+ * 'shipped' — the store's bytes ship: CURRENT, or STALE with a verified `carry` record (the refresh
234
+ * failed and the previous generation's bytes, re-hashed against the ledger, were kept);
235
+ * 'absent' — MISSING with a `failure` record: a store that failed with no prior bytes, excluded
236
+ * from the repository store set;
237
+ * null — anything else. A STALE/MISSING row WITHOUT its record, FAILED and UNVERIFIED stay
238
+ * rejected, exactly as before.
239
+ * The status token is never new: the distinction lives in the object (0.1.0's STALE_CARRIED and
240
+ * PENDING_FAILED were withdrawn).
241
+ */
242
+ export function eligibleRepositoryStanding(row) {
243
+ const status = row?.status;
244
+ const { carry, failure } = row || {};
245
+ if (status === 'CURRENT') return carry === undefined && failure === undefined ? 'shipped' : null;
246
+ if (status === 'STALE' && carry && typeof carry === 'object' && failure === undefined) {
247
+ const carried = String(carry.carriedSourceCommit || '').toLowerCase();
248
+ const shipped = typeof carry.reason === 'string' && carry.reason.length > 0
249
+ && HEX40.test(carried) && carried === String(row.artifact?.sourceCommit || '').toLowerCase()
250
+ && HEX40.test(String(carry.missedUpstream || '').toLowerCase())
251
+ && String(carry.missedUpstream).toLowerCase() === String(row.upstream?.sha || '').toLowerCase()
252
+ && carried !== String(carry.missedUpstream).toLowerCase()
253
+ && isAttemptCount(carry.attempts) && isIsoOrNull(carry.carriedCommittedAt)
254
+ && row.artifact?.bytesVerified === true && row.artifact?.passagesPresent === true;
255
+ return shipped ? 'shipped' : null;
256
+ }
257
+ if (status === 'MISSING' && failure && typeof failure === 'object' && carry === undefined) {
258
+ return typeof failure.reason === 'string' && failure.reason.length > 0 && isAttemptCount(failure.attempts) ? 'absent' : null;
259
+ }
260
+ return null;
261
+ }
262
+
228
263
  export function validatePublicInventory({ assetsDir, coverage, ledger, installedPublicStores = null, gistReceipt = null }) {
229
264
  const root = path.resolve(assetsDir);
230
265
  const selected = installedPublicStores === null ? null : [...installedPublicStores].map((store) => String(store).toLowerCase());
@@ -248,11 +283,23 @@ export function validatePublicInventory({ assetsDir, coverage, ledger, installed
248
283
  if (stores.some((store) => !store) || new Set(stores).size !== stores.length) throw new Error(`${family} stores are missing or duplicated`);
249
284
  return stores.sort();
250
285
  };
251
- const repos = coverage.rows.filter((row) => row.kind === 'repository' && row.disposition === 'eligible');
252
- if (repos.some((row) => row.status !== 'CURRENT')) throw new Error('an eligible repository is not CURRENT');
286
+ const eligibleRepos = coverage.rows.filter((row) => row.kind === 'repository' && row.disposition === 'eligible');
287
+ const rejected = eligibleRepos.find((row) => eligibleRepositoryStanding(row) === null);
288
+ if (rejected) {
289
+ throw new Error(`an eligible repository is not CURRENT (${rejected.artifact?.store || rejected.name}: ${rejected.status} `
290
+ + 'without a verified carry/failure record)');
291
+ }
292
+ const repos = eligibleRepos.filter((row) => eligibleRepositoryStanding(row) === 'shipped');
253
293
  const repositories = uniqueStores(repos, 'repository');
294
+ // A MISSING-with-failure store ships no bytes: it is in neither the repository set nor the excluded
295
+ // set, so a stray file under its name still fails below as an unclassified public store.
296
+ const absentRepositories = uniqueStores(eligibleRepos.filter((row) => eligibleRepositoryStanding(row) === 'absent'),
297
+ 'absent repository');
254
298
  const excludedRepositories = uniqueStores(coverage.rows.filter((row) => row.kind === 'repository'
255
299
  && row.disposition !== 'eligible'), 'excluded repository');
300
+ if (absentRepositories.some((store) => repositories.includes(store) || excludedRepositories.includes(store))) {
301
+ throw new Error('an absent repository store is also shipped or excluded');
302
+ }
256
303
  const excludedSet = new Set(excludedRepositories);
257
304
  if (repositories.some((store) => excludedSet.has(store))) throw new Error('eligible and excluded repository stores overlap');
258
305
  const gists = coverage.rows.filter((row) => row.kind === 'gist' && row.disposition === 'eligible');
@@ -339,6 +386,15 @@ export function validatePublicInventory({ assetsDir, coverage, ledger, installed
339
386
  throw new Error(`${store} generation record provenance is incomplete`);
340
387
  }
341
388
  }
389
+ // A carried store ships the PREVIOUS generation's bytes: the ledger must name exactly the commit
390
+ // the carry record says was kept, or the row describes bytes this bundle does not carry.
391
+ for (const row of repos) {
392
+ if (!row.carry) continue;
393
+ const store = String(row.artifact.store).toLowerCase();
394
+ if (String(ledger.stores[store]?.sourceCommit || '').toLowerCase() !== String(row.carry.carriedSourceCommit).toLowerCase()) {
395
+ throw new Error(`${store} carry record names a source commit the generation ledger does not carry`);
396
+ }
397
+ }
342
398
  const publicStores = expected;
343
399
  evidenceFiles.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
344
400
  return { repositories, excludedRepositories, gistAggregate, derived, publicStores,
@@ -120,7 +120,9 @@ const TABLE = {
120
120
  // way — it stopped trusting hook-shim.mjs as a blind generic spawner, not their presence here.
121
121
  'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
122
122
  'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
123
- 'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
123
+ // 1 MiB, not 64 KiB: the Stop payload now carries `last_assistant_message`, and a long closing
124
+ // message truncated mid-JSON would parse as `{}` and silently drop the whole capture.
125
+ 'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 1048576 },
124
126
  'md-stamp': { file: 'md-stamp.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
125
127
  // THE EXTERNAL-SIGNAL WATCH PLANE, W1 OBSERVED (ADR-058 §D3; DDD-0013 Context 2). PostToolUse,
126
128
  // matcher ^Bash$ (anchored — an unanchored matcher is F3/F4). Classifies gh/vercel/netlify/npm
@@ -10,6 +10,7 @@ import { projectDirectory } from './project-identity.mjs';
10
10
  import { buildProjectProgression } from './project-progression-producer.mjs';
11
11
  import { ProjectProgressionStore } from './project-progression-store.mjs';
12
12
  import { resolveProjectStore } from './project-store-resolver.mjs';
13
+ import { captureTurnOutcome } from './turn-outcome-capture.mjs';
13
14
 
14
15
  /**
15
16
  * The capture boundary's whole budget. hooks.json declares 10s; this keeps the internal work well
@@ -97,11 +98,19 @@ export function runSessionSnapshotHook(projectDir, event, {
97
98
  produce = buildProjectProgression,
98
99
  budgetMs = CAPTURE_BUDGET_MS,
99
100
  now = Date.now,
101
+ captureTurn = captureTurnOutcome,
100
102
  } = {}) {
101
103
  const metadataWritten = writeSessionSnapshot(projectDir, event);
102
104
  let payload;
103
105
  try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
104
- const idle = { metadataWritten, progressionCaptured: false, receipt: null };
106
+ // TURN OUTCOMES FIRST, and independent of `.swarm`: every turn in every repository is recorded
107
+ // (a project without `.swarm` records to the machine-wide db outside it — turn-outcome-capture.mjs).
108
+ // It only reads and spawns a detached writer, so it costs the progression budget below nothing.
109
+ let turn;
110
+ try { turn = captureTurn({ projectDir, event, payload, host }); } catch (error) {
111
+ turn = { recorded: false, skipped: `turn capture failed: ${error.message}` };
112
+ }
113
+ const idle = { metadataWritten, progressionCaptured: false, receipt: null, turn };
105
114
 
106
115
  if (hasProjectProgression(payload)) {
107
116
  if (payload.hook_event_name !== event) {
@@ -162,6 +171,7 @@ export function runSessionSnapshotHook(projectDir, event, {
162
171
  return {
163
172
  metadataWritten,
164
173
  progressionCaptured: true,
174
+ turn,
165
175
  replayed,
166
176
  receipt: result.receipt,
167
177
  provenance: produced.provenance,
@@ -0,0 +1,292 @@
1
+ /**
2
+ * turn-outcome-capture.mjs — record what each turn CONCLUDED, and make it recallable knowledge.
3
+ *
4
+ * Called from session-snapshot-hook.mjs's runSessionSnapshotHook, so it rides the capture boundary
5
+ * both hosts already register (Claude: hooks.json; Codex: codex-hooks.json via codex-hook-adapter,
6
+ * which sets RUVNET_HOOK_HOST=codex). Two jobs:
7
+ *
8
+ * Stop → one AgentDB record per distinct turn outcome, namespace `turns`
9
+ * SessionEnd/PreCompact → `ruflo memory distill run` on the same db (ruflo ADR-174: memory_entries
10
+ * is the RECORDING tier; distill mines it into episodes/reasoning_patterns/
11
+ * causal_edges, the KNOWLEDGE tier, which stays empty unless it is run)
12
+ *
13
+ * WHERE: the project's `.swarm/memory.db` when it exists, else the machine-wide
14
+ * `~/.claude/global-memory/.swarm/memory.db` (outside every repository). `.swarm` is NEVER created
15
+ * inside a project — see session-snapshot-hook.mjs's writeSessionSnapshot for why that is trespass.
16
+ *
17
+ * WHAT (measured facts carried over from the owner's local Claude hook, 2026-09-29):
18
+ * • The Brain's continuation gate continues most turns, so the turn's REAL final Stop carries
19
+ * stop_hook_active=true. It is NOT skipped; exact repeats are dropped by a per-session
20
+ * fingerprint of (finalText, files) instead.
21
+ * • The transcript may not contain the closing message yet when Stop fires, so the payload's
22
+ * `last_assistant_message` wins when present; only without it is the transcript waited on
23
+ * (bounded) until it stops growing.
24
+ * • Content = assistant outcome text, files changed, Bash descriptions. NEVER raw user text (a
25
+ * 2026-07-13 measurement: prompt echoes made 87% of a store noise). Trivial turns are skipped.
26
+ *
27
+ * CODEX: codex-cli 0.158.0's own `stop.command.input` schema (read from the installed binary
28
+ * 2026-09-29) carries `last_assistant_message` (nullable) and `transcript_path` (nullable).
29
+ * The Codex rollout format is NOT parsed: project-progression-sources.mjs already declares it
30
+ * unknown, and the rollout records observed locally (custom_tool_call / function_call with free-form
31
+ * inputs) give no stable file-change shape. Codex records therefore carry the outcome text only.
32
+ *
33
+ * LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
34
+ * (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
35
+ * distill in order; the hook itself only reads, fingerprints and spawns. A breadcrumb line is
36
+ * appended next to the db BEFORE the spawn, and the worker appends a receipt per step, so a lost
37
+ * write is visible rather than silent. Advisory always: nothing here throws to the caller.
38
+ */
39
+ import fs from 'node:fs';
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+ import crypto from 'node:crypto';
43
+ import { spawn, spawnSync } from 'node:child_process';
44
+ import { fileURLToPath } from 'node:url';
45
+ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
46
+
47
+ export const TURN_NAMESPACE = 'turns';
48
+ export const MIN_OUTCOME_CHARS = 200;
49
+ const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
50
+ const STEP_TIMEOUT_MS = 60_000;
51
+ const RUFLO_ENV = { RUFLO_DAEMON_AUTOSTART: '0' };
52
+
53
+ function textOf(content) {
54
+ if (typeof content === 'string') return content;
55
+ if (!Array.isArray(content)) return '';
56
+ return content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n');
57
+ }
58
+
59
+ function patchFiles(patch) {
60
+ const out = new Set();
61
+ for (const line of String(patch || '').split(/\r?\n/)) {
62
+ const m = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line) || /^\*\*\* Move to: (.+)$/.exec(line);
63
+ if (m) out.add(m[1].trim());
64
+ }
65
+ return [...out];
66
+ }
67
+
68
+ /**
69
+ * The current turn of a Claude JSONL transcript = every record after the last genuine user message
70
+ * (a user record carrying text, not a tool_result). User text is used ONLY to find that boundary.
71
+ */
72
+ export function claudeTurn(lines) {
73
+ const recs = [];
74
+ for (const l of lines) { try { recs.push(JSON.parse(l)); } catch { /* partial or foreign line */ } }
75
+ let start = 0;
76
+ recs.forEach((o, i) => {
77
+ const role = o?.message?.role || o?.role;
78
+ const c = o?.message?.content;
79
+ const isToolResult = Array.isArray(c) && c.some((x) => x && x.type === 'tool_result');
80
+ if (role === 'user' && !isToolResult && textOf(c).trim()) start = i + 1;
81
+ });
82
+ const texts = [];
83
+ const files = new Set();
84
+ const actions = [];
85
+ for (const o of recs.slice(start)) {
86
+ if ((o?.message?.role || o?.role) !== 'assistant') continue;
87
+ const c = o.message?.content;
88
+ const t = textOf(c).trim();
89
+ if (t) texts.push(t);
90
+ if (!Array.isArray(c)) continue;
91
+ for (const u of c) {
92
+ if (!u || u.type !== 'tool_use') continue;
93
+ const inp = u.input || {};
94
+ if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name)) {
95
+ const f = inp.file_path || inp.notebook_path;
96
+ if (typeof f === 'string' && f) files.add(f);
97
+ } else if (u.name === 'apply_patch') {
98
+ for (const f of patchFiles(typeof inp === 'string' ? inp : inp.command || inp.input)) files.add(f);
99
+ } else if (u.name === 'Bash' && typeof inp.description === 'string' && inp.description) {
100
+ actions.push(inp.description);
101
+ }
102
+ }
103
+ }
104
+ // The closing message usually carries the outcome; when it is short (a tool-heavy turn) the turn's
105
+ // other assistant text is kept too, newest last, so the record still says what happened.
106
+ const last = texts.length ? texts[texts.length - 1] : '';
107
+ const finalText = last.length >= MIN_OUTCOME_CHARS ? last : texts.join('\n').slice(-2500);
108
+ return { finalText, files: [...files], actions };
109
+ }
110
+
111
+ function readTail(file, bytes = TRANSCRIPT_TAIL_BYTES) {
112
+ const size = fs.statSync(file).size;
113
+ const offset = Math.max(0, size - bytes);
114
+ const handle = fs.openSync(file, 'r');
115
+ try {
116
+ const buf = Buffer.alloc(size - offset);
117
+ fs.readSync(handle, buf, 0, buf.length, offset);
118
+ const lines = buf.toString('utf8').split(/\r?\n/);
119
+ if (offset > 0) lines.shift(); // the first line of a tail is almost always cut mid-record
120
+ return lines;
121
+ } finally { fs.closeSync(handle); }
122
+ }
123
+
124
+ /** Wait (bounded) until the transcript stops growing, then return its tail lines. */
125
+ export function readSettledTranscript(file, { stableMs = 400, maxMs = 2000, sleep } = {}) {
126
+ const pause = sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
127
+ const deadline = Date.now() + Math.max(0, maxMs);
128
+ let size = -1;
129
+ for (;;) {
130
+ const now = fs.statSync(file).size;
131
+ if (now === size || Date.now() >= deadline) break;
132
+ size = now;
133
+ pause(Math.min(stableMs, Math.max(0, deadline - Date.now())));
134
+ }
135
+ return readTail(file);
136
+ }
137
+
138
+ export function buildTurnRecord({ turn, project, host, session, at = new Date() }) {
139
+ const parts = [`[turn ${at.toISOString()} project=${project} host=${host}]`,
140
+ `OUTCOME: ${turn.finalText.replace(/\s+/g, ' ').slice(0, 2500)}`];
141
+ if (turn.files.length) parts.push(`FILES CHANGED: ${turn.files.slice(0, 25).join(', ')}`);
142
+ if (turn.actions.length) parts.push(`ACTIONS: ${turn.actions.slice(-15).join(' • ')}`);
143
+ parts.push(`SESSION: ${session || '?'}`);
144
+ return parts.join(' || ').slice(0, 4000);
145
+ }
146
+
147
+ /** Project db if the project already has one; otherwise the machine-wide db outside every repo. */
148
+ export function resolveTurnDb({ projectDir, home = os.homedir(), env = process.env } = {}) {
149
+ const projectDb = path.join(projectDir, '.swarm', 'memory.db');
150
+ try { if (fs.statSync(projectDb).isFile()) return { db: projectDb, scope: 'project' }; } catch { /* absent */ }
151
+ const globalDb = env.RUVNET_TURN_GLOBAL_DB || path.join(home, '.claude', 'global-memory', '.swarm', 'memory.db');
152
+ return { db: globalDb, scope: 'global' };
153
+ }
154
+
155
+ const projectName = (projectDir) => path.basename(path.resolve(projectDir)).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
156
+
157
+ /** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
158
+ export function launchDetached(steps, { receipts }) {
159
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
160
+ cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, ...RUFLO_ENV },
161
+ });
162
+ child.unref();
163
+ return { launched: true, pid: child.pid };
164
+ }
165
+
166
+ function sameTurnSeen(stateFile, sessionKey, fingerprint) {
167
+ let last = {};
168
+ try { last = JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { /* first run */ }
169
+ if (last[sessionKey] === fingerprint) return true;
170
+ const keys = Object.keys(last);
171
+ for (const k of keys.slice(0, Math.max(0, keys.length - 200))) delete last[k];
172
+ try {
173
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 });
174
+ fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: fingerprint }), { mode: 0o600 });
175
+ } catch { /* dedupe is best effort; a duplicate record beats a lost one */ }
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Capture this turn's outcome (Stop) and/or queue distillation (SessionEnd, PreCompact).
181
+ * Returns a plain report; `skipped` / `distill.skipped` carry the reason whenever nothing is written.
182
+ */
183
+ export function captureTurnOutcome({
184
+ projectDir, event, payload = {}, host = 'claude',
185
+ env = process.env, home = os.homedir(),
186
+ brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'),
187
+ ruflo = resolveRuflo({ env, home }),
188
+ launch = launchDetached,
189
+ readTranscript = readSettledTranscript,
190
+ settleMs = 2000,
191
+ now = () => new Date(),
192
+ } = {}) {
193
+ const report = { event, host, recorded: false, distill: { queued: false } };
194
+ if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') {
195
+ return { ...report, skipped: 'RUVNET_TURN_CAPTURE=off', distill: { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
196
+ }
197
+ if (!ruflo) return { ...report, skipped: 'ruflo not found', distill: { queued: false, skipped: 'ruflo not found' } };
198
+ const { db, scope } = resolveTurnDb({ projectDir, home, env });
199
+ Object.assign(report, { db, scope });
200
+ const steps = [];
201
+ const project = projectName(projectDir);
202
+ const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
203
+
204
+ if (event === 'Stop') {
205
+ const sessionKey = String(payload.session_id || payload.transcript_path || '');
206
+ const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
207
+ let turn = { finalText: '', files: [], actions: [] };
208
+ if (host === 'claude' && typeof payload.transcript_path === 'string' && payload.transcript_path) {
209
+ // With the closing message in hand, the transcript is read immediately (tool calls are already
210
+ // flushed); without it, wait for the closing message to land, bounded.
211
+ try { turn = claudeTurn(readTranscript(payload.transcript_path, { maxMs: message ? 0 : settleMs })); } catch { /* unreadable */ }
212
+ }
213
+ if (message && (message.length >= MIN_OUTCOME_CHARS || message.length >= turn.finalText.length)) turn.finalText = message;
214
+ if (!sessionKey) report.skipped = 'no session identity in the host payload';
215
+ else if (turn.finalText.length < MIN_OUTCOME_CHARS && !turn.files.length) {
216
+ report.skipped = host === 'codex' && !message ? 'codex payload carried no last_assistant_message' : 'trivial turn';
217
+ } else {
218
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
219
+ const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
220
+ if (sameTurnSeen(stateFile, `${host}:${sessionKey}`, fingerprint)) report.skipped = 'same turn outcome already recorded';
221
+ else {
222
+ const at = now();
223
+ const key = `turn-${project}-${at.getTime()}`;
224
+ const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
225
+ steps.push({ kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
226
+ '--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
227
+ Object.assign(report, { recorded: true, key, value });
228
+ }
229
+ }
230
+ } else report.skipped = `turn outcomes are recorded at Stop, not ${event}`;
231
+
232
+ if (event === 'SessionEnd' || event === 'PreCompact') {
233
+ if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
234
+ else {
235
+ steps.push({ kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
236
+ report.distill = { queued: true, db };
237
+ }
238
+ }
239
+ if (!steps.length) return report;
240
+
241
+ try {
242
+ // The machine-wide db lives outside every repository; its directory is created on first use so a
243
+ // fresh machine records from its first turn. A project's `.swarm` is never created (scope check).
244
+ if (scope === 'global') fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
245
+ if (report.recorded) {
246
+ fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
247
+ `${JSON.stringify({ ts: Date.now(), key: report.key, project, host, value: report.value })}\n`, { mode: 0o600 });
248
+ }
249
+ report.launch = launch(steps, { receipts });
250
+ } catch (error) {
251
+ return { ...report, recorded: false, distill: { queued: false, skipped: `launch failed: ${error.message}` }, skipped: `launch failed: ${error.message}` };
252
+ }
253
+ return report;
254
+ }
255
+
256
+ /** The detached worker: run each step in order, bounded, and append one receipt per step. */
257
+ export function runSteps({ steps = [], receipts } = {}, { run = spawnSync } = {}) {
258
+ const results = [];
259
+ for (const step of steps) {
260
+ let status = null;
261
+ let error = null;
262
+ const db = step.args[step.args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
263
+ try {
264
+ const { executable, args } = rufloInvocation(step.ruflo, step.args);
265
+ // CONTAINMENT (measured 2026-09-29, ruflo 3.48.0): even with an explicit --path, `memory store`
266
+ // writes `.swarm/hnsw.index` and `ruvector.db` relative to its CWD. Run from an inherited cwd,
267
+ // that planted `.swarm/` + `ruvector.db` in a repository that never adopted the brain — the
268
+ // exact trespass session-snapshot-hook.mjs forbids. So every step runs INSIDE the db's own
269
+ // directory, with ruflo's memory root pinned there too.
270
+ const home = path.dirname(db);
271
+ const r = run(executable, args, { stdio: 'ignore', timeout: STEP_TIMEOUT_MS, cwd: home, windowsHide: true,
272
+ env: { ...process.env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: home } });
273
+ status = r.status;
274
+ if (r.error) error = r.error.message;
275
+ } catch (e) { error = e.message; }
276
+ const row = { at: new Date().toISOString(), kind: step.kind, db,
277
+ key: step.kind === 'store' ? step.args[step.args.indexOf('-k') + 1] : undefined, status, error };
278
+ results.push(row);
279
+ if (receipts) {
280
+ try {
281
+ fs.mkdirSync(path.dirname(receipts), { recursive: true, mode: 0o700 });
282
+ fs.appendFileSync(receipts, `${JSON.stringify(row)}\n`, { mode: 0o600 });
283
+ } catch { /* receipts are best effort */ }
284
+ }
285
+ }
286
+ return results;
287
+ }
288
+
289
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--run-steps') {
290
+ try { runSteps(JSON.parse(process.argv[3] || '{}')); } catch { /* a detached worker has no one to report to */ }
291
+ process.exit(0);
292
+ }