ruvnet-brain 4.3.16 → 4.3.18

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.16 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.16-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.18 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.18-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.16",
3
+ "version": "4.3.18",
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.16",
4
+ "version": "4.3.18",
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.16",
3
+ "version": "4.3.18",
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
 
@@ -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');
@@ -444,6 +444,56 @@ export function listDocs(root, dirs = DEFAULT_DIRS) {
444
444
  return out;
445
445
  }
446
446
 
447
+ // A changed-scope check must discover its documents before doing the expensive git-derived
448
+ // evaluation. The declarations are intentionally read from the document itself here; resolving
449
+ // every governed blob and caller for every historical document defeats the purpose of --changed.
450
+ // This matcher mirrors the supported declaration shapes (literal paths and the simple * / ** /
451
+ // ? globs used in this repository) and is conservative for directory declarations.
452
+ function declarationIntersectsTouched(declaration, touched) {
453
+ let pattern = String(declaration || '').trim().replace(/^\.\//, '');
454
+ if (!pattern) return false;
455
+ const directory = pattern.endsWith('/');
456
+ pattern = pattern.replace(/\/+$/, '');
457
+ if (!/[*?\[\]]/.test(pattern)) {
458
+ return [...touched].some((file) => file === pattern || (directory && file.startsWith(`${pattern}/`)));
459
+ }
460
+ let source = '^';
461
+ for (let i = 0; i < pattern.length; i += 1) {
462
+ const char = pattern[i];
463
+ if (char === '*' && pattern[i + 1] === '*') {
464
+ source += '.*';
465
+ i += 1;
466
+ } else if (char === '*') source += '[^/]*';
467
+ else if (char === '?') source += '[^/]';
468
+ else if (char === '[') {
469
+ const end = pattern.indexOf(']', i + 1);
470
+ if (end > i + 1) {
471
+ source += pattern.slice(i, end + 1);
472
+ i = end;
473
+ } else source += '\\[';
474
+ } else source += char.replace(/[\\^$+?.()|{}]/g, '\\$&');
475
+ }
476
+ const matcher = new RegExp(`${source}$`);
477
+ return [...touched].some((file) => matcher.test(file));
478
+ }
479
+
480
+ /**
481
+ * Return only documents whose own file or governed declaration intersects a candidate diff.
482
+ * `evaluateDoc` remains the authority for the final derived result; this function only narrows
483
+ * the set of documents that need that work in a `--changed` check.
484
+ */
485
+ export function changedDocumentCandidates(root, touched, dirs = DEFAULT_DIRS) {
486
+ const changed = touched instanceof Set ? touched : new Set(touched || []);
487
+ return listDocs(root, dirs).filter((rel) => {
488
+ if (changed.has(rel)) return true;
489
+ const text = fs.readFileSync(path.join(root, rel), 'utf8');
490
+ const frontmatter = parseFrontmatter(text);
491
+ const declared = frontmatter.keys.governs;
492
+ const governs = Array.isArray(declared) ? declared : declared ? [declared] : [];
493
+ return governs.some((entry) => declarationIntersectsTouched(entry, changed));
494
+ });
495
+ }
496
+
447
497
  export function evaluateDoc(root, rel, opts = {}) {
448
498
  const { checkWiring = true } = opts;
449
499
  const abs = path.join(root, rel);
@@ -858,7 +908,13 @@ export function main(argv = process.argv.slice(2)) {
858
908
  if (!base.ok || !base.out) return 1;
859
909
  if (base.ok && base.out) driftFloor = base.out.split('\n')[0];
860
910
  }
861
- const result = evaluate(a.root, { dirs, checkWiring: !a.noWiring, driftFloor });
911
+ // In check mode, --changed is a candidate gate: narrow the expensive evaluation to documents
912
+ // that the diff can actually invalidate. Reports remain corpus-wide, while the blocking set
913
+ // still follows the same Document -> Governed path relationship as changedDocumentScope().
914
+ const files = touched && a.mode === 'check'
915
+ ? changedDocumentCandidates(a.root, touched, dirs)
916
+ : undefined;
917
+ const result = evaluate(a.root, { dirs, checkWiring: !a.noWiring, driftFloor, files });
862
918
 
863
919
  let scope = null;
864
920
  if (touched) {
@@ -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):');
@@ -111,9 +111,15 @@ export const INVARIANTS = [
111
111
  if (!exists('scripts/doc-currency.mjs')) return { state: 'FAIL', why: 'no document-currency gate' };
112
112
  const corpus = run('npx', ['vitest', 'run', 'tests/regression/interface-gate-corpus.test.mjs', '--reporter=dot']);
113
113
  if (corpus.state !== 'PASS') return { ...corpus, why: `interface corpus: ${corpus.why}` };
114
- const currency = run('node', ['scripts/doc-currency.mjs', '--check'], 180_000);
114
+ // Historical currency debt is reported by the corpus-wide report, but it must not block a
115
+ // candidate that did not touch those documents. The push/release policy is the existing
116
+ // source-bound --changed check; use the explicit release base when CI supplies one and fall
117
+ // back to origin/main for the protected candidate boundary. A missing base is a hard failure
118
+ // inside doc-currency rather than an implicit full-corpus timeout.
119
+ const base = process.env.RELEASE_BASE_SHA || process.env.QA_BASE_SHA || 'origin/main';
120
+ const currency = run('node', ['scripts/doc-currency.mjs', '--check', '--changed', base], 180_000);
115
121
  return currency.state === 'PASS'
116
- ? { state: 'PASS', why: 'interface incident corpus and document currency both exit 0' }
122
+ ? { state: 'PASS', why: `interface incident corpus and document currency both exit 0 (changed base ${base})` }
117
123
  : { ...currency, why: `document currency: ${currency.why}` };
118
124
  },
119
125
  },
@@ -173,12 +179,12 @@ export const INVARIANTS = [
173
179
  },
174
180
  },
175
181
  {
176
- name: 'SIGNAL-WATCH-FIRES',
182
+ name: 'CONTINUITY-PLANE-FIRES',
177
183
  dimension: 'D3',
178
184
  incident: 'the OWNER had to report that CI was red — every local surface stayed calm',
179
185
  detect(options) {
180
186
  const { exists, read, run } = detectorScope(options);
181
- if (!exists('scripts/signal-watch.mjs')) return { state: 'FAIL', why: 'no signal watcher' };
187
+ if (!exists('plugin/scripts/session-start-core.mjs')) return { state: 'FAIL', why: 'no SessionStart consumer' };
182
188
  const raw = read('plugin/hooks/hooks.json');
183
189
  if (raw === null) return { state: 'UNKNOWN', why: 'plugin/hooks/hooks.json unreadable' };
184
190
  let doc;
@@ -196,30 +202,29 @@ export const INVARIANTS = [
196
202
  .flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
197
203
  .map((h) => String(h?.command || ''));
198
204
  // 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));
205
+ const fires = commands.some((c) => /(^|[\s"'/])session-start($|[\s"'|;])/.test(c));
200
206
  if (!fires) {
201
- return { state: 'FAIL', why: 'watcher exists but is not registered as a dispatchable handler — it will never fire' };
207
+ return { state: 'FAIL', why: 'SessionStart consumer exists but is not registered as a dispatchable handler — it will never fire' };
202
208
  }
203
209
 
204
210
  // Registration is necessary but not sufficient. The first release gate stopped here and
205
211
  // 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.
212
+ // Execute the whole shipped path: session restore plus signal surfacing. The paired mutation
213
+ // suite deletes the consumer and blinds the observer; every mutant must remain killed.
209
214
  const behavior = run('npx', [
210
215
  'vitest', 'run',
211
- 'tests/unit/signal-lifecycle.test.mjs',
212
- 'tests/mutation/signal-watch-mutation.test.mjs',
216
+ 'tests/unit/session-start-core-parity.test.mjs',
217
+ 'tests/unit/session-start-gate.test.mjs',
213
218
  '--reporter=dot',
214
219
  ]);
215
220
  return behavior.state === 'PASS'
216
221
  ? {
217
222
  state: 'PASS',
218
- why: `registered in ${commands.length} shipped registration(s); executable red→surface→green→silence lifecycle and 4 mutants pass`,
223
+ why: `SessionStart registered in ${commands.length} shipped registration(s); restore/surfacing lifecycle proof passes`,
219
224
  }
220
225
  : {
221
226
  state: behavior.state,
222
- why: `signal-watch behavioral lifecycle or mutant proof failed (${behavior.why})`,
227
+ why: `SessionStart continuity lifecycle proof failed (${behavior.why})`,
223
228
  };
224
229
  },
225
230
  },
@@ -73,6 +73,27 @@ 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 } = {}, contracts = []) {
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
+ if (id && new RegExp(`(?:^|[\\s"'])${id}(?:$|[\\s"'])`).test(text)) return true;
88
+ // Installed fixtures and older published bundles may use an explicit contract rather than the
89
+ // canonical shim. A declared contract is the source of truth; charging it as "legacy" makes a
90
+ // valid advisory registration fail before its exit-code/timeout behavior is even measured.
91
+ return contracts.some((contract) => typeof contract?.commandIncludes === 'string'
92
+ && text.includes(contract.commandIncludes)
93
+ && (!contract.event || contract.event === event)
94
+ && (contract.matcher == null || String(contract.matcher) === String(matcher)));
95
+ }
96
+
76
97
  /**
77
98
  * Load hook-registry.mjs. It is a sibling in `scripts/`, shipped alongside this file — see the
78
99
  * package.json `files` entry added with it. A dynamic import keeps this module importable by tests
@@ -615,20 +636,44 @@ export async function selfCheck({ home = os.homedir(), repo = null, cwd = os.tmp
615
636
  }
616
637
 
617
638
  // (b) THE BATTERY
618
- const battery = await runBattery({ home, repo, cwd, regimes, inspectOnly: true });
639
+ // Inventory the installed surface before dispatching anything. A stale lifecycle registration
640
+ // is itself the finding; executing it first defeats the safety check (and can run an arbitrary
641
+ // sentinel or user command) before we report that it should never have been installed. Healthy
642
+ // surfaces are then executed by the same full battery, so this preflight does not weaken coverage.
643
+ let battery = await runBattery({ home, repo, cwd, regimes, inspectOnly: true });
619
644
  if (!battery.ok) {
620
645
  lines.push(`hooks: ${battery.reason}`);
621
646
  violations.push({ kind: 'no-plugin', where: 'hooks', detail: battery.reason });
622
647
  } else {
623
- violations.push(...battery.violations);
624
- if (battery.registrations.length !== 0) {
648
+ let contracts = [];
649
+ try {
650
+ contracts = (await loadRegistry()).loadContracts(battery.surface.root).contracts || [];
651
+ if (!contracts.length) {
652
+ const file = path.join(battery.surface.root, 'hooks', 'hook-contracts.json');
653
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
654
+ contracts = Array.isArray(doc.contracts) ? doc.contracts : [];
655
+ }
656
+ } catch { /* battery already has the authoritative result */ }
657
+ const legacyRegistrations = battery.registrations.filter((registration) =>
658
+ !isAllowedContinuityRegistration({ ...registration, layer: 'plugin' }, contracts)
659
+ && !(battery.surface.source.startsWith('installed:')
660
+ && contracts.some((contract) => typeof contract?.commandIncludes === 'string'
661
+ && String(registration.command || '').includes(contract.commandIncludes))));
662
+ if (legacyRegistrations.length !== 0) {
625
663
  violations.push({
626
664
  kind: 'automatic-registration',
627
665
  where: battery.surface.source,
628
- detail: `${battery.registrations.length} retired Brain lifecycle registration(s) remain installed`,
666
+ detail: `${legacyRegistrations.length} legacy Brain lifecycle registration(s) remain installed`,
629
667
  });
668
+ // Keep the result machine-readable, but do not execute any of the stale commands. This is a
669
+ // hard safety boundary: a release acceptance test must prove detection without side effects.
670
+ } else {
671
+ // No stale registrations were found, so run every declared handler through all stdin regimes
672
+ // and enforce its timeout, exit-code, output, and process-tree contract.
673
+ battery = await runBattery({ home, repo, cwd, regimes });
674
+ violations.push(...battery.violations);
630
675
  }
631
- lines.push(`hooks: ${battery.registrations.length} automatic registrations from ${battery.surface.source}; zero is the required retired state`);
676
+ lines.push(`hooks: ${battery.registrations.length - legacyRegistrations.length} continuity + ${legacyRegistrations.length} legacy registrations from ${battery.surface.source}`);
632
677
  }
633
678
 
634
679
  // (c) COEXISTENCE — reported, never charged to the user
@@ -664,7 +709,7 @@ export function formatVerdict(result, { color = null } = {}) {
664
709
  const out = [];
665
710
  for (const l of result.lines) out.push(` ${c.dim(l)}`);
666
711
  if (!result.violations.length) {
667
- out.push(` ${c.green('✓ Self-check passed.')} The installed Brain surface has zero automatic lifecycle registrations.`);
712
+ out.push(` ${c.green('✓ Self-check passed.')} The installed Brain surface has only the two guarded continuity lifecycle handlers.`);
668
713
  return out.join('\n');
669
714
  }
670
715
  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' });