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 +1 -1
- package/bin/install.mjs +66 -27
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +27 -2
- package/plugin/hooks/hook-contracts.json +36 -4
- package/plugin/hooks/hooks.json +27 -2
- package/plugin/scripts/continuity-hook-policy.mjs +37 -0
- package/plugin/scripts/lesson-hooks.sh +1 -1
- package/scripts/ci/mutate-hook-timeout.mjs +1 -1
- package/scripts/ci/stranger-scenario.mjs +3 -2
- package/scripts/doc-currency.mjs +57 -1
- package/scripts/hook-retirement-check.mjs +2 -2
- package/scripts/qe/ux-suite.mjs +23 -3
- package/scripts/release-vector.mjs +18 -13
- package/scripts/selfcheck.mjs +51 -6
- package/scripts/wired-check.mjs +15 -5
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 — [](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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1523
|
-
try {
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
1534
|
-
return { host: true, action: '
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
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 !==
|
|
1974
|
-
errors.push(`${contractsFile}: matcherAllowlist must
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
4
|
+
"version": "4.3.18",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "RuvNet Brain
|
|
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": "
|
|
3
|
-
"_version":
|
|
4
|
-
"contracts": [
|
|
5
|
-
|
|
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
|
}
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "RuvNet Brain
|
|
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 || !/
|
|
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
|
|
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
|
|
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');
|
package/scripts/doc-currency.mjs
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
package/scripts/qe/ux-suite.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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;
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
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/
|
|
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"'/])
|
|
205
|
+
const fires = commands.some((c) => /(^|[\s"'/])session-start($|[\s"'|;])/.test(c));
|
|
200
206
|
if (!fires) {
|
|
201
|
-
return { state: 'FAIL', why: '
|
|
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:
|
|
207
|
-
//
|
|
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/
|
|
212
|
-
'tests/
|
|
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);
|
|
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: `
|
|
227
|
+
why: `SessionStart continuity lifecycle proof failed (${behavior.why})`,
|
|
223
228
|
};
|
|
224
229
|
},
|
|
225
230
|
},
|
package/scripts/selfcheck.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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: `${
|
|
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}
|
|
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
|
|
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):`)}`);
|
package/scripts/wired-check.mjs
CHANGED
|
@@ -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\([
|
|
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
|
|
789
|
+
const automaticHooksConstrained = retirementFiles.every((file) => {
|
|
786
790
|
const doc = readJsonSafe(file);
|
|
787
|
-
|
|
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 (
|
|
852
|
+
else if (automaticHooksConstrained && declared) rows.push({
|
|
843
853
|
file: f,
|
|
844
854
|
state: 'retired',
|
|
845
|
-
why: 'automatic Brain
|
|
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' });
|