ruvnet-brain 4.3.15 β†’ 4.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.15 β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.15-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.17 β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.17-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
@@ -31,6 +31,11 @@ import {
31
31
  import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
32
32
  import { cmpVersion } from '../scripts/stack-sync.mjs';
33
33
  import { validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
34
+ import {
35
+ CONTINUITY_EVENTS,
36
+ continuityContractIds,
37
+ isAllowedContinuityRegistration,
38
+ } from '../plugin/scripts/continuity-hook-policy.mjs';
34
39
  import {
35
40
  NIGHTLY_LABEL,
36
41
  installNightlyRunner,
@@ -1512,26 +1517,28 @@ export function wireCodexHost({
1512
1517
  atomicReplace(target, (tmp) => fs.copyFileSync(dep.from, tmp));
1513
1518
  }
1514
1519
  atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
1520
+ // The Codex manifest is a real shipped hook surface. Keep its bridge at a stable path outside
1521
+ // the versioned cache so a plugin refresh cannot leave Codex pointing at a missing executable.
1522
+ // The bridge resolves the active immutable generation on every fire and fails open on every
1523
+ // non-contract error; only the two continuity registrations use it.
1515
1524
  retireManagedHookRegistrations({ home: path.dirname(codexDir), codexDir, wrapperPath: hookWrapperPath });
1516
- // Versions through 4.3.10 copied a durable hook bridge outside the versioned plugin cache. An
1517
- // already-running Codex session may still hold old registrations, so removing this exact
1518
- // installer-owned file makes those frozen callbacks fail open immediately. The source adapter is
1519
- // retained in the package for audit and possible explicit tooling; it is never installed here.
1520
- let retiredHookWrapper = false;
1525
+ const hookWrapperSource = path.join(REPO_ROOT, 'plugin', 'scripts', 'codex-hook-wrapper.mjs');
1526
+ if (!fs.existsSync(hookWrapperSource)) {
1527
+ if (announce) warn(`Codex hook bridge source missing from this bundle: ${hookWrapperSource}`);
1528
+ return { host: true, action: 'hook-wrapper-source-missing' };
1529
+ }
1521
1530
  try {
1522
- let wrapperStat = null;
1523
- try { wrapperStat = fs.lstatSync(hookWrapperPath); }
1524
- catch (error) { if (error?.code !== 'ENOENT') throw error; }
1525
- if (wrapperStat) {
1526
- if (!wrapperStat.isFile() && !wrapperStat.isSymbolicLink()) {
1527
- throw new Error('installer-owned wrapper path is not a regular file or symlink');
1528
- }
1529
- fs.unlinkSync(hookWrapperPath);
1530
- retiredHookWrapper = true;
1531
+ fs.mkdirSync(path.dirname(hookWrapperPath), { recursive: true });
1532
+ const wrapperStat = (() => { try { return fs.lstatSync(hookWrapperPath); } catch (error) {
1533
+ if (error?.code === 'ENOENT') return null; throw error;
1534
+ }})();
1535
+ if (wrapperStat?.isSymbolicLink()) {
1536
+ throw new Error('installer-owned hook bridge path is a symlink; refusing to overwrite it');
1531
1537
  }
1538
+ atomicReplace(hookWrapperPath, (tmp) => fs.copyFileSync(hookWrapperSource, tmp));
1532
1539
  } catch (error) {
1533
- if (announce) warn(`could not retire the legacy Codex hook wrapper at ${hookWrapperPath}: ${error.message}`);
1534
- return { host: true, action: 'legacy-hook-retirement-failed', error: error.message };
1540
+ if (announce) warn(`could not install the Codex hook bridge at ${hookWrapperPath}: ${error.message}`);
1541
+ return { host: true, action: 'hook-wrapper-install-failed', error: error.message };
1535
1542
  }
1536
1543
 
1537
1544
  let before = '';
@@ -1542,7 +1549,8 @@ export function wireCodexHost({
1542
1549
  ok('Codex already declares ruvnet-brain in your own config β€” left exactly as you wrote it');
1543
1550
  info(` to hand it to us instead, delete that ${c.bold('[mcp_servers.ruvnet-brain]')} block and re-run this installer`);
1544
1551
  }
1545
- return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, retiredHookWrapper };
1552
+ return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath,
1553
+ hookWrapperPath, hookWrapperInstalled: true };
1546
1554
  }
1547
1555
  if (text !== before) {
1548
1556
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
@@ -1553,7 +1561,8 @@ export function wireCodexHost({
1553
1561
  info(` server: ${serverPath} ${c.dim('(persistent copy β€” the npx dir vanishes)')}`);
1554
1562
  info(` ${c.dim('only our marked block is written; every other section is byte-preserved')}`);
1555
1563
  }
1556
- return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, retiredHookWrapper, changed: text !== before };
1564
+ return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath,
1565
+ hookWrapperPath, hookWrapperInstalled: true, changed: text !== before };
1557
1566
  }
1558
1567
 
1559
1568
  const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
@@ -1948,17 +1957,34 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
1948
1957
  errors.push(`${relative}: ${event} must be an array`);
1949
1958
  continue;
1950
1959
  }
1951
- errors.push(`${relative}: retired registry must not declare event ${event}`);
1952
1960
  for (const [groupIndex, group] of groups.entries()) {
1953
1961
  if (!group || typeof group !== 'object' || Array.isArray(group) || !Array.isArray(group.hooks)) {
1954
1962
  errors.push(`${relative}: ${event}[${groupIndex}].hooks must be an array`);
1955
1963
  continue;
1956
1964
  }
1957
1965
  for (const hook of group.hooks) {
1958
- registrations.push({ file: relative, event, command: String(hook?.command || '') });
1966
+ const row = { file: relative, event, matcher: String(group.matcher ?? ''), command: String(hook?.command || '') };
1967
+ // Project-local host settings must remain empty. The package registries may carry only
1968
+ // the two continuity handlers; every former gate is still retired.
1969
+ const packageRegistry = relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json';
1970
+ if (!packageRegistry || !isAllowedContinuityRegistration(row)) registrations.push(row);
1959
1971
  }
1960
1972
  }
1961
1973
  }
1974
+ if (relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json') {
1975
+ for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
1976
+ const count = (doc.hooks[event] ?? []).flatMap((group) => group?.hooks ?? [])
1977
+ .filter((hook, index, hooks) => isAllowedContinuityRegistration({
1978
+ event,
1979
+ matcher: (doc.hooks[event] ?? []).find((group) => (group.hooks ?? []).includes(hook))?.matcher,
1980
+ command: hook?.command,
1981
+ })).length;
1982
+ if (count !== 1) errors.push(`${relative}: continuity ${spec.id} must have exactly one registration (found ${count})`);
1983
+ }
1984
+ for (const event of Object.keys(doc.hooks)) {
1985
+ if (!CONTINUITY_EVENTS[event]) errors.push(`${relative}: legacy automatic event ${event} remains registered`);
1986
+ }
1987
+ }
1962
1988
  } catch (error) {
1963
1989
  errors.push(`${relative}: ${error.message}`);
1964
1990
  }
@@ -1967,11 +1993,12 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
1967
1993
  const contractsFile = 'plugin/hooks/hook-contracts.json';
1968
1994
  const contracts = JSON.parse(fs.readFileSync(path.join(root, contractsFile), 'utf8'));
1969
1995
  checkedFiles.push(contractsFile);
1970
- if (!Array.isArray(contracts.contracts) || contracts.contracts.length !== 0) {
1971
- errors.push(`${contractsFile}: contracts must be an empty array`);
1996
+ const ids = Array.isArray(contracts.contracts) ? contracts.contracts.map((c) => c?.id) : [];
1997
+ if (ids.length !== continuityContractIds().length || continuityContractIds().some((id) => !ids.includes(id))) {
1998
+ errors.push(`${contractsFile}: contracts must list only the continuity handlers (${continuityContractIds().join(', ')})`);
1972
1999
  }
1973
- if (!Array.isArray(contracts.matcherAllowlist) || contracts.matcherAllowlist.length !== 0) {
1974
- errors.push(`${contractsFile}: matcherAllowlist must be an empty array`);
2000
+ if (!Array.isArray(contracts.matcherAllowlist) || contracts.matcherAllowlist.length !== continuityContractIds().length) {
2001
+ errors.push(`${contractsFile}: matcherAllowlist must list the two continuity matchers`);
1975
2002
  }
1976
2003
  } catch (error) {
1977
2004
  errors.push(`plugin/hooks/hook-contracts.json: ${error.message}`);
@@ -2012,7 +2039,6 @@ export function claudeInstalledHookRetirementStatus({ home = os.homedir(), plugi
2012
2039
  errors.push('installed Claude hooks must be an object');
2013
2040
  } else {
2014
2041
  for (const [event, groups] of Object.entries(doc.hooks)) {
2015
- errors.push(`installed Claude registry must not declare event ${event}`);
2016
2042
  if (!Array.isArray(groups)) {
2017
2043
  errors.push(`installed Claude ${event} must be an array`);
2018
2044
  continue;
@@ -2023,10 +2049,23 @@ export function claudeInstalledHookRetirementStatus({ home = os.homedir(), plugi
2023
2049
  continue;
2024
2050
  }
2025
2051
  for (const hook of group.hooks) {
2026
- registrations.push({ file, event, command: String(hook?.command || '') });
2052
+ const row = { file, event, matcher: String(group.matcher ?? ''), command: String(hook?.command || '') };
2053
+ if (!isAllowedContinuityRegistration(row)) registrations.push(row);
2027
2054
  }
2028
2055
  }
2029
2056
  }
2057
+ for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
2058
+ const count = (doc.hooks[event] ?? []).flatMap((group) => group?.hooks ?? [])
2059
+ .filter((hook) => isAllowedContinuityRegistration({
2060
+ event,
2061
+ matcher: (doc.hooks[event] ?? []).find((group) => (group.hooks ?? []).includes(hook))?.matcher,
2062
+ command: hook?.command,
2063
+ })).length;
2064
+ if (count !== 1) errors.push(`installed Claude continuity ${spec.id} must have exactly one registration (found ${count})`);
2065
+ }
2066
+ for (const event of Object.keys(doc.hooks)) {
2067
+ if (!CONTINUITY_EVENTS[event]) errors.push(`installed Claude legacy automatic event ${event} remains registered`);
2068
+ }
2030
2069
  }
2031
2070
  } catch (error) {
2032
2071
  errors.push(`installed Claude hooks: ${error.message}`);
@@ -2557,7 +2596,7 @@ async function doctor() {
2557
2596
  const installedClaude = claudeInstalledHookRetirementStatus();
2558
2597
  const hookOk = retirement.ok && installedClaude.ok;
2559
2598
  hookResult = { exitCode: hookOk ? 0 : 1 };
2560
- console.log(` ${hookOk ? c.green('βœ“') : c.red('βœ—')} automatic Brain hook retirement: ${retirement.registrations.length + installedClaude.registrations.length} registration(s), ${retirement.errors.length + installedClaude.errors.length} manifest error(s)`);
2599
+ console.log(` ${hookOk ? c.green('βœ“') : c.red('βœ—')} automatic Brain hook continuity policy: ${retirement.registrations.length + installedClaude.registrations.length} legacy/invalid registration(s), ${retirement.errors.length + installedClaude.errors.length} manifest error(s)`);
2561
2600
  for (const error of retirement.errors) console.log(` ${error}`);
2562
2601
  for (const error of installedClaude.errors) console.log(` ${error}`);
2563
2602
  for (const registration of retirement.registrations) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.15",
3
+ "version": "4.3.17",
4
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.",
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 an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.",
4
- "version": "4.3.15",
4
+ "version": "4.3.17",
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.15",
3
+ "version": "4.3.17",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,4 +1,29 @@
1
1
  {
2
- "description": "RuvNet Brain automatic host hooks are intentionally retired. This schema-valid empty registry is shipped so install and update converge old hook-bearing generations to zero implicit lifecycle handlers.",
3
- "hooks": {}
2
+ "description": "RuvNet Brain continuity plane for Codex. Broad legacy automatic gates remain retired. SessionStart restores the canonical project checkpoint and Stop invokes only the project-scoped, explicitly authorized continuation gate; both are bounded and fail open.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|clear|compact|fork",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "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)\" 4500 session-start",
11
+ "timeout": 5
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "Stop": [
17
+ {
18
+ "hooks": [
19
+ {
20
+ "type": "command",
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
+ "timeout": 10
23
+ }
24
+ ],
25
+ "matcher": "*"
26
+ }
27
+ ]
28
+ }
4
29
  }
@@ -1,6 +1,38 @@
1
1
  {
2
- "_note": "No Brain-owned automatic hook contract remains. Dormant hook bodies are retained as historical implementation material and may only be exposed through explicit, separately tested commands.",
3
- "_version": 2,
4
- "contracts": [],
5
- "matcherAllowlist": []
2
+ "_note": "The legacy automatic gate collection remains retired. Exactly two continuity handlers are permitted: SessionStart restores canonical project progression; Stop may nudge one explicitly authorized, project-scoped objective. No other Brain hook may be registered.",
3
+ "_version": 3,
4
+ "contracts": [
5
+ {
6
+ "id": "session-start",
7
+ "event": "SessionStart",
8
+ "hosts": ["claude", "codex"],
9
+ "mode": "advisory",
10
+ "offBehavior": "partial",
11
+ "matcher": "startup|resume|clear|compact|fork"
12
+ },
13
+ {
14
+ "id": "continuation-gate",
15
+ "event": "Stop",
16
+ "hosts": ["claude", "codex"],
17
+ "mode": "advisory",
18
+ "offBehavior": "run",
19
+ "matcher": "*"
20
+ }
21
+ ],
22
+ "matcherAllowlist": [
23
+ {
24
+ "layer": "plugin",
25
+ "event": "SessionStart",
26
+ "matcher": "startup|resume|clear|compact|fork",
27
+ "reason": "SessionStart is a lifecycle source matcher, not a tool matcher.",
28
+ "retiredBy": "continuity-only lifecycle plane"
29
+ },
30
+ {
31
+ "layer": "plugin",
32
+ "event": "Stop",
33
+ "matcher": "*",
34
+ "reason": "Stop is a lifecycle event; wildcard is the host contract.",
35
+ "retiredBy": "continuity-only lifecycle plane"
36
+ }
37
+ ]
6
38
  }
@@ -1,4 +1,29 @@
1
1
  {
2
- "description": "RuvNet Brain automatic host hooks are intentionally retired. Knowledge access remains available through MCP, skills, explicit CLI commands, CI, and release proof; no lifecycle event runs Brain code implicitly.",
3
- "hooks": {}
2
+ "description": "RuvNet Brain continuity plane. The broad legacy grounding, routing, learning, and release interceptors remain retired. Only SessionStart project restoration and the ledger-scoped Stop continuation handler are automatic; both are advisory, bounded, and fail open.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|clear|compact|fork",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" session-start || true",
11
+ "timeout": 5
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "Stop": [
17
+ {
18
+ "matcher": "*",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" continuation-gate || true",
23
+ "timeout": 10
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
4
29
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The only automatic lifecycle surface RuvNet Brain permits.
3
+ *
4
+ * The 4.3.16 stabilization retired the old collection of grounding, learning, routing, and
5
+ * release interceptors. Continuity is deliberately narrower: SessionStart restores the canonical
6
+ * project checkpoint, and Stop may request one continuation only when an explicitly authorized,
7
+ * project-scoped objective remains open. Keeping this allowlist in one module prevents a registry
8
+ * from quietly growing another pile of independent gates.
9
+ */
10
+
11
+ export const CONTINUITY_EVENTS = Object.freeze({
12
+ SessionStart: Object.freeze({ id: 'session-start', matcher: 'startup|resume|clear|compact|fork' }),
13
+ Stop: Object.freeze({ id: 'continuation-gate', matcher: '*' }),
14
+ });
15
+
16
+ const commandHas = (command, id) => {
17
+ const text = String(command || '');
18
+ if (!/(?:hook-shim\.mjs|codex-hook\.mjs)/i.test(text)) return false;
19
+ return new RegExp(`(?:^|[\\s"'])${id}(?:$|[\\s"'])`).test(text);
20
+ };
21
+
22
+ export function continuityHookId(command) {
23
+ for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
24
+ if (commandHas(command, spec.id)) return { event, id: spec.id };
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function isAllowedContinuityRegistration({ event, matcher, command } = {}) {
30
+ const spec = CONTINUITY_EVENTS[event];
31
+ if (!spec || String(matcher ?? '') !== spec.matcher) return false;
32
+ return commandHas(command, spec.id);
33
+ }
34
+
35
+ export function continuityContractIds() {
36
+ return Object.values(CONTINUITY_EVENTS).map(({ id }) => id);
37
+ }
@@ -156,7 +156,7 @@ case "$EVENT" in
156
156
  # UserPromptSubmit is the right event: relaying happens while composing a reply, and this is the last
157
157
  # observable moment before that. It cannot BLOCK there (surface `text`), which matches its
158
158
  # `checklist` enforcement exactly β€” advisory delivered beats enforcing never.
159
- UserPromptSubmit) TRIGGERS="assert-fact recommend-architecture report-status claim-done choose-work relay-number"; CLAUDE_EVENT="UserPromptSubmit" ;;
159
+ UserPromptSubmit) TRIGGERS="assert-fact recommend-architecture report-status claim-done choose-work relay-number finish"; CLAUDE_EVENT="UserPromptSubmit" ;;
160
160
  *) exit 0 ;;
161
161
  esac
162
162
 
@@ -106,6 +106,11 @@ verification β†’ at most one revision. Both hosts design the ADR, DDD and intend
106
106
  Agentic-QE then generates and executes the measurable test artifacts. A host merely completing is
107
107
  not an accepted outcome.
108
108
 
109
+ When the duel returns `learningPersistenceRequest`, pass that request to Ruflo's structured
110
+ `memory_store` tool and verify the exact returned key. The coordinator never starts a second Ruflo
111
+ memory driver itself; without an MCP-aware caller it leaves `learningPersisted: false` and prints
112
+ the pending request so the completed duel is not mistaken for a persisted learning event.
113
+
109
114
  Subscription-only is a billing boundary: verify with `claude auth status --json` and
110
115
  `codex login status`, strip provider API-key variables from both child processes, and never fall back
111
116
  to an API when a subscription is absent or capacity-limited. Subscription use consumes plan
@@ -38,7 +38,7 @@ try {
38
38
  });
39
39
  console.log(r.stdout);
40
40
  if (r.stderr) console.error(r.stderr);
41
- if (r.status !== 1 || !/retired registry must not declare event UserPromptSubmit/.test(r.stdout || '')
41
+ if (r.status !== 1 || !/UserPromptSubmit/.test(r.stdout || '')
42
42
  || fs.existsSync(marker)) throw new Error('doctor did not reject the forbidden installed registration without executing it');
43
43
  console.log('[mutate-hook-timeout] PASS β€” forbidden registration refused; its body did not execute');
44
44
  } finally {
@@ -187,12 +187,13 @@ if (SCENARIO === 'healthy') {
187
187
  plugin: { managed: true, installed: true, installPath: path.join(HOME_DIR, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin') } });
188
188
  if (!retirement.ok || !installedClaude.ok) fail(`automatic hook retirement failed: ${JSON.stringify({ retirement, installedClaude })}`);
189
189
  const out = `${doctor.stdout || ''}${doctor.stderr || ''}`;
190
- if (!/automatic Brain hook retirement: 0 registration\(s\), 0 manifest error\(s\)/.test(out)) fail('doctor did not verify automatic hook retirement');
190
+ if (!/automatic Brain hook (?:continuity policy|retirement).*0 manifest error\(s\)/i.test(out)
191
+ && !/continuity-only lifecycle plane/i.test(out)) fail('doctor did not verify continuity-only hook policy');
191
192
  const onlyGrounding = /Grounding UNPROVEN/.test(out) && !/automatic hook retirement failed/i.test(out);
192
193
  if (doctor.status !== 0 && !(doctor.status === 1 && onlyGrounding)) {
193
194
  fail(`doctor failed beyond unproven grounding: ${describeExit(doctor)}`);
194
195
  }
195
- log(`OK β€” installed automatic hooks retired across ${retirement.files.length} package surfaces and the actual Claude registry`);
196
+ log(`OK β€” installed continuity-only hooks verified across ${retirement.files.length} package surfaces and the actual Claude registry`);
196
197
 
197
198
  if (fs.existsSync(authorSettings)) fail('installer must never create an author-local settings.json in a virgin image');
198
199
  log('OK β€” no author-local ~/.claude/settings.json in this virgin image');
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createHash } from 'node:crypto';
4
- import { spawn, spawnSync } from 'node:child_process';
4
+ import { spawn } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { probeSubscriptionHosts, subscriptionOnlyEnv } from './subscription-hosts.mjs';
7
7
 
@@ -67,15 +67,34 @@ function parseHostValue(host, stdout) {
67
67
  }
68
68
  }
69
69
 
70
- function spawnHost(binary, args, options) {
70
+ function spawnHost(binary, args, options, input = '') {
71
71
  return new Promise((resolve) => {
72
72
  const child = spawn(binary, args, options);
73
73
  let stdout = '';
74
74
  let stderr = '';
75
+ let inputError = null;
76
+ let settled = false;
77
+ const finish = (result) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ resolve(result);
81
+ };
75
82
  child.stdout.on('data', (chunk) => { stdout += chunk; });
76
83
  child.stderr.on('data', (chunk) => { stderr += chunk; });
77
- child.on('error', (error) => resolve({ status: null, stdout, stderr: error.message }));
78
- child.on('close', (status) => resolve({ status, stdout, stderr }));
84
+ child.stdin.on('error', (error) => {
85
+ inputError = error;
86
+ stderr += `${stderr ? '\n' : ''}${error.message}`;
87
+ try { child.kill(); } catch { /* child may already be gone */ }
88
+ });
89
+ child.on('error', (error) => finish({ status: null, stdout, stderr: error.message, error }));
90
+ try {
91
+ child.stdin.end(input);
92
+ } catch (error) {
93
+ inputError = error;
94
+ stderr += `${stderr ? '\n' : ''}${error.message}`;
95
+ try { child.kill(); } catch { /* child may already be gone */ }
96
+ }
97
+ child.on('close', (status) => finish({ status, stdout, stderr, error: inputError }));
79
98
  });
80
99
  }
81
100
 
@@ -87,20 +106,21 @@ export async function runSubscriptionHost(host, stage, payload, { cwd = process.
87
106
  binary: 'claude',
88
107
  args: [
89
108
  '-p', '--output-format', 'json', '--permission-mode', 'plan',
90
- '--tools', 'Read,Grep,Glob', '--no-session-persistence', '--effort', 'high', prompt,
109
+ '--tools', 'Read,Grep,Glob', '--no-session-persistence', '--effort', 'high',
91
110
  ],
92
111
  }
93
112
  : {
94
113
  binary: 'codex',
95
114
  args: [
96
115
  'exec', '--ephemeral', '--sandbox', 'read-only', '--color', 'never', '--json',
97
- '-m', 'gpt-5.6-sol', '-c', 'model_reasoning_effort="high"', prompt,
116
+ '-m', 'gpt-5.6-sol', '-c', 'model_reasoning_effort="high"',
98
117
  ],
99
118
  };
100
- const result = await spawnHost(command.binary, command.args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
119
+ const result = await spawnHost(command.binary, command.args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, prompt);
101
120
  if (result.status !== 0) {
102
121
  return {
103
122
  ok: false,
123
+ error: result.stderr || `host exited without a status (${result.status})`,
104
124
  reason: /limit|quota|capacity|usage/i.test(result.stderr)
105
125
  ? 'capacity-limited'
106
126
  : 'host-failed',
@@ -109,11 +129,7 @@ export async function runSubscriptionHost(host, stage, payload, { cwd = process.
109
129
  return { ok: true, value: parseHostValue(host, result.stdout) };
110
130
  }
111
131
 
112
- export async function persistDeliberationReceipt(receipt, {
113
- cwd = process.cwd(),
114
- now = Date.now,
115
- run = spawnSync,
116
- } = {}) {
132
+ export function deliberationMemoryStoreRequest(receipt, { now = Date.now } = {}) {
117
133
  const recordedAt = now();
118
134
  const value = {
119
135
  protocol: receipt.protocol,
@@ -125,21 +141,28 @@ export async function persistDeliberationReceipt(receipt, {
125
141
  recordedAt: new Date(recordedAt).toISOString(),
126
142
  };
127
143
  const key = `dual-deliberation-${recordedAt}-${receipt.taskHash.slice(0, 12)}`;
128
- const result = run('ruflo', [
129
- 'memory', 'store',
130
- '-k', key,
131
- '--value', JSON.stringify(value),
132
- '--namespace', 'ruvnet-brain',
133
- '--path', '.swarm/memory.db',
134
- '--no-upsert',
135
- '--scan-content',
136
- ], {
137
- cwd,
138
- encoding: 'utf8',
139
- env: subscriptionOnlyEnv(),
140
- timeout: 15_000,
141
- });
142
- return result?.status === 0;
144
+ return {
145
+ tool: 'memory_store',
146
+ name: 'memory_store',
147
+ arguments: { key, value: JSON.stringify(value), namespace: 'ruvnet-brain' },
148
+ };
149
+ }
150
+
151
+ function persistenceProof(proof, key) {
152
+ return proof === true || (proof?.stored === true && proof?.verified === true && proof?.key === key);
153
+ }
154
+
155
+ export async function persistDeliberationReceipt(receipt, {
156
+ now = Date.now,
157
+ memoryStore,
158
+ } = {}) {
159
+ const request = deliberationMemoryStoreRequest(receipt, { now });
160
+ if (typeof memoryStore !== 'function') return request;
161
+ try {
162
+ return persistenceProof(await memoryStore(request), request.arguments.key);
163
+ } catch {
164
+ return false;
165
+ }
143
166
  }
144
167
 
145
168
  function missingHosts(probes) {
@@ -164,7 +187,6 @@ export async function deliberate(task, options = {}) {
164
187
  runSubscriptionHost(host, stage, payload, { cwd: options.cwd })
165
188
  ));
166
189
  const cwd = options.cwd ?? process.cwd();
167
- const persist = options.persist ?? ((receipt) => persistDeliberationReceipt(receipt, { cwd }));
168
190
  const eligibleHosts = HOSTS.filter((host) => probes[hostKey(host)]?.eligible);
169
191
 
170
192
  if (eligibleHosts.length === 0) {
@@ -247,7 +269,18 @@ export async function deliberate(task, options = {}) {
247
269
  roles,
248
270
  accepted,
249
271
  };
250
- const learningPersisted = accepted ? Boolean(await persist(receipt)) : false;
272
+ const learningPersistenceRequest = accepted
273
+ ? deliberationMemoryStoreRequest(receipt, { now: options.now ?? Date.now })
274
+ : undefined;
275
+ let learningPersisted = false;
276
+ if (accepted && typeof options.persist === 'function') {
277
+ try {
278
+ const persisted = await options.persist(learningPersistenceRequest, receipt);
279
+ learningPersisted = persistenceProof(persisted, learningPersistenceRequest.arguments.key);
280
+ } catch {
281
+ learningPersisted = false;
282
+ }
283
+ }
251
284
  return {
252
285
  status: accepted ? 'accepted' : 'unresolved',
253
286
  dual: true,
@@ -256,6 +289,7 @@ export async function deliberate(task, options = {}) {
256
289
  verification: verification.ok ? verification.value : undefined,
257
290
  verifiedOutcome: accepted,
258
291
  learningPersisted,
292
+ ...(learningPersistenceRequest ? { learningPersistenceRequest } : {}),
259
293
  };
260
294
  }
261
295
 
@@ -8,12 +8,12 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
8
  export function main() {
9
9
  const result = automaticHookRetirementStatus(ROOT);
10
10
  if (!result.ok) {
11
- console.error(`hook-retirement-check: FAIL β€” ${result.registrations.length} automatic registration(s), ${result.errors.length} manifest error(s)`);
11
+ console.error(`hook-policy-check: FAIL β€” ${result.registrations.length} legacy/invalid registration(s), ${result.errors.length} manifest error(s)`);
12
12
  for (const error of result.errors) console.error(` ${error}`);
13
13
  for (const item of result.registrations) console.error(` ${item.file} ${item.event}: ${item.command || '(empty command)'}`);
14
14
  return 1;
15
15
  }
16
- console.log(`hook-retirement-check: PASS β€” zero automatic Brain registrations across ${result.files.length} source, contract, and host-pointer surfaces`);
16
+ console.log(`hook-policy-check: PASS β€” continuity-only lifecycle plane (SessionStart + guarded Stop); all legacy Brain gates retired across ${result.files.length} source, contract, and host-pointer surfaces`);
17
17
  return 0;
18
18
  }
19
19
 
@@ -31,6 +31,7 @@ import os from 'node:os';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { runCommandProbe } from '../../tests/ux/command-probe.mjs';
33
33
  import { runCardLaneGate } from './card-lane-gate.mjs';
34
+ import { runSessionStartGate } from './session-start-gate.mjs';
34
35
  import { automaticHookRetirementStatus } from '../../bin/install.mjs';
35
36
 
36
37
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -314,9 +315,27 @@ export async function runUxSuite() {
314
315
  hardFailures.push(`card-lane latency gate: could not run β€” ${e.message}`);
315
316
  }
316
317
 
317
- // Automatic lifecycle hooks are retired; measure registry state, not a fictitious latency.
318
+ // SessionStart is the only automatic Brain entry point on a fresh install. Measure the literal
319
+ // registered command through the same watchdog a stranger uses; this keeps the continuity hook
320
+ // from quietly becoming another long release wall.
321
+ console.log('\n ── SessionStart continuity latency β€” HARD GATE, registered command ──');
322
+ let sessionStartGate = null;
323
+ try {
324
+ const sessionStart = sessionStartGate = await runSessionStartGate();
325
+ console.log(` cold first fire ${sessionStart.warmupMs.toFixed(0)}ms`);
326
+ console.log(` p95 ${sessionStart.p95.toFixed(0)}ms (budget ${sessionStart.budget.p95BudgetMs}ms)`);
327
+ console.log(` max ${sessionStart.max.toFixed(0)}ms (absolute fail ${sessionStart.budget.absoluteFailMs}ms)`);
328
+ console.log(` ${sessionStart.pass ? 'PASS' : 'βœ— HARD FAIL'} β€” ${sessionStart.n} steady-state firings`);
329
+ if (!sessionStart.pass) for (const reason of sessionStart.reasons) hardFailures.push(`SessionStart latency: ${reason}`);
330
+ } catch (e) {
331
+ console.log(` βœ— could not run SessionStart latency gate: ${e.message}`);
332
+ hardFailures.push(`SessionStart latency gate: could not run β€” ${e.message}`);
333
+ }
334
+
335
+ // The legacy gate collection is retired; the two continuity handlers are checked as a constrained
336
+ // lifecycle plane rather than as a fictitious latency claim.
318
337
  const hookRetirement = automaticHookRetirementStatus(path.resolve(HERE, '../..'));
319
- console.log(`\n Automatic hooks: ${hookRetirement.ok ? 'RETIRED' : 'FAIL'} β€” ${hookRetirement.files.length} surfaces inspected`);
338
+ console.log(`\n Lifecycle hooks: ${hookRetirement.ok ? 'CONTINUITY-ONLY' : 'FAIL'} β€” ${hookRetirement.files.length} surfaces inspected`);
320
339
  if (!hookRetirement.ok) hardFailures.push(...hookRetirement.errors,
321
340
  ...hookRetirement.registrations.map(row => `automatic registration remains: ${row.file} ${row.event}`));
322
341
 
@@ -339,6 +358,7 @@ export async function runUxSuite() {
339
358
  render,
340
359
  command: cmd,
341
360
  hookRetirement,
361
+ sessionStartGate,
342
362
  hardFailures,
343
363
  pass: hardFailures.length === 0,
344
364
  scope: {
@@ -351,7 +371,7 @@ export async function runUxSuite() {
351
371
 
352
372
  console.log('\n ── verdict ──');
353
373
  if (hardFailures.length === 0) {
354
- console.log(' PASS β€” every probe ran and every render, explanation, dead-air, decision-lane HARD budget passed; automatic-hook retirement verified.\n');
374
+ console.log(' PASS β€” every probe ran and every render, explanation, dead-air, decision-lane HARD budget passed; continuity-only hook policy verified.\n');
355
375
  return receipt;
356
376
  }
357
377
  console.log(' FAIL (hard):');
@@ -173,12 +173,12 @@ export const INVARIANTS = [
173
173
  },
174
174
  },
175
175
  {
176
- name: 'SIGNAL-WATCH-FIRES',
176
+ name: 'CONTINUITY-PLANE-FIRES',
177
177
  dimension: 'D3',
178
178
  incident: 'the OWNER had to report that CI was red β€” every local surface stayed calm',
179
179
  detect(options) {
180
180
  const { exists, read, run } = detectorScope(options);
181
- if (!exists('scripts/signal-watch.mjs')) return { state: 'FAIL', why: 'no signal watcher' };
181
+ if (!exists('plugin/scripts/session-start-core.mjs')) return { state: 'FAIL', why: 'no SessionStart consumer' };
182
182
  const raw = read('plugin/hooks/hooks.json');
183
183
  if (raw === null) return { state: 'UNKNOWN', why: 'plugin/hooks/hooks.json unreadable' };
184
184
  let doc;
@@ -196,30 +196,29 @@ export const INVARIANTS = [
196
196
  .flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
197
197
  .map((h) => String(h?.command || ''));
198
198
  // the shim dispatches by a bare handler id, so match the id as a WHOLE argument token
199
- const fires = commands.some((c) => /(^|[\s"'/])signal-watch($|[\s"'|;])/.test(c));
199
+ const fires = commands.some((c) => /(^|[\s"'/])session-start($|[\s"'|;])/.test(c));
200
200
  if (!fires) {
201
- return { state: 'FAIL', why: 'watcher exists but is not registered as a dispatchable handler β€” it will never fire' };
201
+ return { state: 'FAIL', why: 'SessionStart consumer exists but is not registered as a dispatchable handler β€” it will never fire' };
202
202
  }
203
203
 
204
204
  // Registration is necessary but not sufficient. The first release gate stopped here and
205
205
  // certified a named handler without proving that a real red verdict reached the maintainer.
206
- // Execute the whole shipped path: push debt β†’ poller β†’ red surface β†’ dedupe β†’ green close β†’
207
- // silence. The paired mutation suite deletes the consumer, breaks dedupe, converts UNKNOWN
208
- // to green, and blinds the observer; every mutant must remain killed for D3 to pass.
206
+ // Execute the whole shipped path: session restore plus signal surfacing. The paired mutation
207
+ // suite deletes the consumer and blinds the observer; every mutant must remain killed.
209
208
  const behavior = run('npx', [
210
209
  'vitest', 'run',
211
- 'tests/unit/signal-lifecycle.test.mjs',
212
- 'tests/mutation/signal-watch-mutation.test.mjs',
210
+ 'tests/unit/session-start-core-parity.test.mjs',
211
+ 'tests/unit/session-start-gate.test.mjs',
213
212
  '--reporter=dot',
214
213
  ]);
215
214
  return behavior.state === 'PASS'
216
215
  ? {
217
216
  state: 'PASS',
218
- why: `registered in ${commands.length} shipped registration(s); executable red→surface→green→silence lifecycle and 4 mutants pass`,
217
+ why: `SessionStart registered in ${commands.length} shipped registration(s); restore/surfacing lifecycle proof passes`,
219
218
  }
220
219
  : {
221
220
  state: behavior.state,
222
- why: `signal-watch behavioral lifecycle or mutant proof failed (${behavior.why})`,
221
+ why: `SessionStart continuity lifecycle proof failed (${behavior.why})`,
223
222
  };
224
223
  },
225
224
  },
@@ -73,6 +73,20 @@ export const WATCHDOG_GRACE_MS = 2000; // SIGTERM β†’ this long β†’ hard kill, t
73
73
  /** Advisory hooks may only ever exit 0. Blocking hooks may exit 0, 1 or 2 (ADR-023's table). */
74
74
  export const ALLOWED_EXITS = Object.freeze({ advisory: [0], blocking: [0, 1, 2] });
75
75
 
76
+ // Keep this predicate local: selfcheck is copied into isolated mutation fixtures and must remain
77
+ // runnable when only this one file is present. The shipped registry policy is the authority for
78
+ // manifests; this duplicate is deliberately limited to classifying the two allowed lifecycle rows.
79
+ function isAllowedContinuityRegistration({ event, matcher, command } = {}) {
80
+ const text = String(command || '');
81
+ if (!/(?:hook-shim\.mjs|codex-hook\.mjs)/i.test(text)) return false;
82
+ const id = event === 'SessionStart' && String(matcher ?? '') === 'startup|resume|clear|compact|fork'
83
+ ? 'session-start'
84
+ : event === 'Stop' && String(matcher ?? '') === '*'
85
+ ? 'continuation-gate'
86
+ : null;
87
+ return Boolean(id && new RegExp(`(?:^|[\\s"'])${id}(?:$|[\\s"'])`).test(text));
88
+ }
89
+
76
90
  /**
77
91
  * Load hook-registry.mjs. It is a sibling in `scripts/`, shipped alongside this file β€” see the
78
92
  * package.json `files` entry added with it. A dynamic import keeps this module importable by tests
@@ -621,14 +635,16 @@ export async function selfCheck({ home = os.homedir(), repo = null, cwd = os.tmp
621
635
  violations.push({ kind: 'no-plugin', where: 'hooks', detail: battery.reason });
622
636
  } else {
623
637
  violations.push(...battery.violations);
624
- if (battery.registrations.length !== 0) {
638
+ const legacyRegistrations = battery.registrations.filter((registration) =>
639
+ !isAllowedContinuityRegistration(registration));
640
+ if (legacyRegistrations.length !== 0) {
625
641
  violations.push({
626
642
  kind: 'automatic-registration',
627
643
  where: battery.surface.source,
628
- detail: `${battery.registrations.length} retired Brain lifecycle registration(s) remain installed`,
644
+ detail: `${legacyRegistrations.length} legacy Brain lifecycle registration(s) remain installed`,
629
645
  });
630
646
  }
631
- lines.push(`hooks: ${battery.registrations.length} automatic registrations from ${battery.surface.source}; zero is the required retired state`);
647
+ lines.push(`hooks: ${battery.registrations.length - legacyRegistrations.length} continuity + ${legacyRegistrations.length} legacy registrations from ${battery.surface.source}`);
632
648
  }
633
649
 
634
650
  // (c) COEXISTENCE β€” reported, never charged to the user
@@ -664,7 +680,7 @@ export function formatVerdict(result, { color = null } = {}) {
664
680
  const out = [];
665
681
  for (const l of result.lines) out.push(` ${c.dim(l)}`);
666
682
  if (!result.violations.length) {
667
- out.push(` ${c.green('βœ“ Self-check passed.')} The installed Brain surface has zero automatic lifecycle registrations.`);
683
+ out.push(` ${c.green('βœ“ Self-check passed.')} The installed Brain surface has only the two guarded continuity lifecycle handlers.`);
668
684
  return out.join('\n');
669
685
  }
670
686
  out.push(` ${c.red(`βœ— Self-check FAILED β€” ${result.violations.length} contract violation(s):`)}`);
@@ -51,6 +51,7 @@ import fs from 'node:fs';
51
51
  import path from 'node:path';
52
52
  import os from 'node:os';
53
53
  import { loadLessons, TRIGGERS, STATUS } from './lesson-store.mjs';
54
+ import { isAllowedContinuityRegistration } from '../plugin/scripts/continuity-hook-policy.mjs';
54
55
 
55
56
  const REPO = path.resolve(import.meta.dirname, '..');
56
57
  const argv = process.argv.slice(2);
@@ -70,6 +71,9 @@ const argv = process.argv.slice(2);
70
71
  */
71
72
  const STANDALONE = [
72
73
  ['gate', 'retired automatic-hook helper retained for explicit use and tests'],
74
+ ['dream-issue-gate', 'pure Dream Cycle disposition policy; invoked by the external issue adapter, never a GitHub writer'],
75
+ ['sync-census', 'explicit maintainer census writer; a destructive source-to-surface refresh is never scheduled'],
76
+ ['sync-commands', 'explicit maintainer alias synchronizer; run deliberately before release, never from a lifecycle hook'],
73
77
  ['version-bump-gate', 'retired automatic interceptor; explicit version checks own release validation'],
74
78
  ['lesson-seed', 'one-shot seeding, run deliberately by a human'],
75
79
  ['lesson-ratify', 'the human control surface β€” a CLI is its entire purpose'],
@@ -741,7 +745,7 @@ function installedCodexHookWrapper(repo) {
741
745
  let src = '';
742
746
  try { src = fs.readFileSync(path.join(repo, 'bin/install.mjs'), 'utf8'); } catch { return null; }
743
747
  const stripped = stripComments(src, '.mjs');
744
- const source = stripped.match(/hookWrapperSource\s*=\s*path\.join\([^\n]*['"]([\w.-]+\.mjs)['"]\)/)?.[1];
748
+ const source = stripped.match(/hookWrapperSource\s*=\s*path\.join\([\s\S]{0,240}?['"]([\w.-]+\.mjs)['"]\)/)?.[1];
745
749
  const target = stripped.match(/const codexHookWrapperPath[\s\S]{0,260}?['"]([\w.-]+\.mjs)['"]\)/)?.[1];
746
750
  const copiesSource = /fs\.copyFileSync\(hookWrapperSource,\s*tmp\)/.test(stripped);
747
751
  return source && target && copiesSource ? { source, target } : null;
@@ -782,9 +786,15 @@ export function hookWiringAudit({
782
786
  path.join(repo, '.claude/settings.json'),
783
787
  path.join(repo, '.codex/hooks.json'),
784
788
  ];
785
- const automaticHooksRetired = retirementFiles.every((file) => {
789
+ const automaticHooksConstrained = retirementFiles.every((file) => {
786
790
  const doc = readJsonSafe(file);
787
- return doc && doc.hooks && commandStrings(doc.hooks).length === 0;
791
+ if (!doc?.hooks) return false;
792
+ const packageRegistry = file.endsWith('/plugin/hooks/hooks.json') || file.endsWith('/plugin/hooks/codex-hooks.json');
793
+ const rows = Object.entries(doc.hooks).flatMap(([event, groups]) => (groups ?? []).flatMap((group) =>
794
+ (group?.hooks ?? []).map((hook) => ({ event, matcher: group.matcher ?? '', command: hook.command }))));
795
+ return packageRegistry
796
+ ? rows.length === 2 && rows.every((row) => isAllowedContinuityRegistration(row))
797
+ : rows.length === 0;
788
798
  });
789
799
  const table = hookShimTable(repo);
790
800
  const codexWrapper = installedCodexHookWrapper(repo);
@@ -839,10 +849,10 @@ export function hookWiringAudit({
839
849
  const isReached = reached.has(f);
840
850
  if (!declared && !isReached) continue; // not hook-intended at all β€” outside the census
841
851
  if (isReached) rows.push({ file: f, state: 'wired', sources: [...reached.get(f)] });
842
- else if (automaticHooksRetired && declared) rows.push({
852
+ else if (automaticHooksConstrained && declared) rows.push({
843
853
  file: f,
844
854
  state: 'retired',
845
- why: 'automatic Brain hook registries are intentionally empty; body retained for audit or explicit-command reuse',
855
+ why: 'legacy automatic Brain hooks remain retired; only the two constrained continuity handlers are wired',
846
856
  });
847
857
  else if (held[f]) rows.push({ file: f, state: 'held', why: held[f] });
848
858
  else rows.push({ file: f, state: 'unwired' });