ruvnet-brain 4.3.8 → 4.3.10
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 +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +11 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +472 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-two-run-proof.mjs +415 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +53 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +108 -8
- package/scripts/publication-receipt.mjs +173 -66
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-publication-handoff.mjs +101 -0
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/release.mjs +10 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- package/scripts/wired-check.mjs +31 -2
|
@@ -79,7 +79,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
79
79
|
// (issues #112, #113): the name of the nightly job the installer loads, and which hooks a session
|
|
80
80
|
// really has wired. Both are imported from the modules that own them, statically — a missing sibling
|
|
81
81
|
// here is a broken build caught by tests, not a runtime degradation to paper over.
|
|
82
|
-
import {
|
|
82
|
+
import { nightlyStatus } from './nightly-controller.mjs';
|
|
83
83
|
import { buildRegistry, REPO } from './hook-registry.mjs';
|
|
84
84
|
|
|
85
85
|
const HOME = os.homedir();
|
|
@@ -419,29 +419,11 @@ export const CAPABILITIES = [
|
|
|
419
419
|
{
|
|
420
420
|
key: 'memory-distillation',
|
|
421
421
|
label: 'Memory distillation',
|
|
422
|
-
whatItBuysYou: 'Loose notes from past sessions
|
|
422
|
+
whatItBuysYou: 'Loose notes from past sessions can be mined into reusable patterns. Automatic undo is unavailable, so the Console does not offer a one-click change.',
|
|
423
423
|
scope: SCOPE.PROJECT,
|
|
424
|
-
// The
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
// the action actually handed to the user. Here that was literal — the inverse advertised for
|
|
428
|
-
// distillation restores snapshots that `health-repair.mjs --distill-fleet` takes, while this line
|
|
429
|
-
// used to hand over the raw command, which (verified against `--help`) takes no snapshot at all.
|
|
430
|
-
// Run it, dislike the result, and there was nothing to go back to.
|
|
431
|
-
//
|
|
432
|
-
// The wrapper sequences rUv's own commands so the operation is reversible: WAL-safe
|
|
433
|
-
// `ruflo memory backup` FIRST (cp on a live WAL DB silently amputates the newest transactions —
|
|
434
|
-
// this project has lost data that way), a durable fsync'd receipt fail-closed BEFORE any mutation,
|
|
435
|
-
// `distill run --db` scoped to THIS project rather than whatever the cwd implies, and a verified
|
|
436
|
-
// pattern delta reported as a measurement. `--restore` is the tested inverse.
|
|
437
|
-
//
|
|
438
|
-
// PROVEN end to end against the real store, 2026-07-24: 644 → 648 patterns (+4), restore → 644,
|
|
439
|
-
// re-run → 648, five durable receipts, $0.0000. This is the ONE capability whose undo has actually
|
|
440
|
-
// been run rather than merely promised — which is precisely what makes it the only one offerable.
|
|
441
|
-
turnOn: selfTurnOn(
|
|
442
|
-
'Mine this project\'s stored memories into reusable patterns (snapshots first; reversible)',
|
|
443
|
-
'distill-project.mjs',
|
|
444
|
-
),
|
|
424
|
+
// The explicit distill-project command snapshots first, but --restore refuses unsafe
|
|
425
|
+
// database replacement. Keep diagnosis visible without promising an available inverse.
|
|
426
|
+
turnOn: null,
|
|
445
427
|
detect({ project = process.cwd() } = {}) {
|
|
446
428
|
const db = path.join(project, '.swarm/memory.db');
|
|
447
429
|
if (!fs.existsSync(db)) return row(STATE.ABSENT, `no memory store exists for this project yet (${path.join(path.basename(project), '.swarm/memory.db')} is not present)`);
|
|
@@ -893,66 +875,11 @@ export const CAPABILITIES = [
|
|
|
893
875
|
// Loading a launchd job is machine mutation with no single verified command; global Rule 10.
|
|
894
876
|
turnOn: null,
|
|
895
877
|
detect() {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
let out;
|
|
902
|
-
try { out = execFileSync('launchctl', ['list'], { encoding: 'utf8', timeout: 15_000 }); }
|
|
903
|
-
catch (e) { return row(STATE.UNKNOWN, `could not list scheduled jobs (${String(e?.message || e).split('\n')[0].slice(0, 60)}) — nightly state not checked`); }
|
|
904
|
-
|
|
905
|
-
// THIS ROW IS ABOUT THE NIGHTLY KNOWLEDGE-BASE REFRESH, so it counts the nightly refresh — not
|
|
906
|
-
// every launchd job whose label happens to start com.ruvnet. MEASURED on this machine: that
|
|
907
|
-
// prefix match reported "11 refresh jobs are loaded and every one last exited cleanly" while
|
|
908
|
-
// sweeping in goldie-weekly, npx-witness, issue-fix, npm-token-renew, issue-watch,
|
|
909
|
-
// routing-flywheel, brain-gists, npx-72h-verdict and nightly-watchdog. Exactly ONE of the
|
|
910
|
-
// eleven (brain-nightly) was the thing the sentence claimed to describe. Ten unrelated jobs
|
|
911
|
-
// were being offered as evidence for a capability none of them implements.
|
|
912
|
-
//
|
|
913
|
-
// AND THE UNDER-COUNTING TWIN, which cost more (issue #113). The pattern below is a guess at
|
|
914
|
-
// what a refresh job is CALLED, and the one job this row is actually about is not called that:
|
|
915
|
-
// the installer loads `com.ruvnet.brain-update`, which contains neither "nightly" nor
|
|
916
|
-
// "refresh". So the console reported "no nightly refresh job is loaded" about a job that was
|
|
917
|
-
// loaded, scheduled for 03:47 and running nightly — a detector blind to its own installer.
|
|
918
|
-
// The label is now taken from nightly-controller.mjs, the module the console already uses to
|
|
919
|
-
// turn this job on and off, instead of being described a second time as a pattern here.
|
|
920
|
-
const NIGHTLY = /^com\.ruvnet\.[\w.-]*(nightly|refresh)/i;
|
|
921
|
-
const all = out.split('\n')
|
|
922
|
-
.map((l) => l.split('\t'))
|
|
923
|
-
.filter((c) => c.length >= 3 && /^com\.ruvnet\./.test(c[2] || ''))
|
|
924
|
-
.map((c) => ({ label: c[2].trim(), exit: c[1] }));
|
|
925
|
-
// The watchdog watches the refresh; it is not the refresh, and counting it inflates the answer.
|
|
926
|
-
const jobs = all.filter((j) => j.label === NIGHTLY_LABEL
|
|
927
|
-
|| (NIGHTLY.test(j.label) && !/watchdog/i.test(j.label)));
|
|
928
|
-
if (!jobs.length) {
|
|
929
|
-
return row(STATE.ABSENT, all.length
|
|
930
|
-
? `no nightly refresh job is loaded on this machine (${all.length} other RuvNet job${all.length === 1 ? '' : 's'} are scheduled, but none of them is the knowledge-base refresh)`
|
|
931
|
-
: 'no scheduled refresh jobs are loaded on this machine');
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const name = (j) => j.label.replace('com.ruvnet.', '');
|
|
935
|
-
// FAILING IS NOT DORMANT. REJECTED by both duelists 2026-07-24: a job that is loaded, scheduled and
|
|
936
|
-
// has RUN is installed and IN USE — a non-zero exit is a HEALTH problem belonging to the alarm
|
|
937
|
-
// channel, never a "you should switch this on" offer. Reporting it OFF is a category error, and it
|
|
938
|
-
// fired here for the worst possible reason: brain-nightly exited non-zero because the publish guard
|
|
939
|
-
// CORRECTLY refused to release from a non-main branch. A working safety guard was being reported as
|
|
940
|
-
// a dormant capability the user should go turn on.
|
|
941
|
-
const failing = jobs.filter((j) => j.exit !== '0' && j.exit !== '-');
|
|
942
|
-
if (failing.length) return row(STATE.ON, `${jobs.length} nightly refresh job${jobs.length === 1 ? '' : 's'} loaded and running, but ${failing.length} last exited non-zero (${failing.slice(0, 3).map((j) => `${name(j)}=${j.exit}`).join(', ')}) — installed and in use, so this is a health problem to look into, not a capability to switch on`);
|
|
943
|
-
|
|
944
|
-
// "-" IS NOT "0". launchd prints "-" for a job that has never run in this boot, and the old
|
|
945
|
-
// check lumped it in with success — so "every one last exited cleanly" could describe a job
|
|
946
|
-
// that has never executed once. That is the silence-reads-as-health failure the positive-
|
|
947
|
-
// confirmation standing order exists to kill, stated on the surface that is supposed to enforce it.
|
|
948
|
-
const neverRan = jobs.filter((j) => j.exit === '-');
|
|
949
|
-
if (neverRan.length === jobs.length) {
|
|
950
|
-
return row(STATE.UNKNOWN, `${jobs.length} nightly refresh job${jobs.length === 1 ? ' is' : 's are'} loaded (${jobs.map(name).slice(0, 3).join(', ')}) but ${jobs.length === 1 ? 'it has' : 'none has'} run since this machine last booted, so whether the refresh actually works here has not been demonstrated`);
|
|
951
|
-
}
|
|
952
|
-
if (neverRan.length) {
|
|
953
|
-
return row(STATE.ON, `${jobs.length} nightly refresh jobs are loaded; ${jobs.length - neverRan.length} last exited cleanly and ${neverRan.length} (${neverRan.map(name).slice(0, 3).join(', ')}) have not run since boot`);
|
|
954
|
-
}
|
|
955
|
-
return row(STATE.ON, `${jobs.length} nightly refresh job${jobs.length === 1 ? '' : 's'} loaded (${jobs.map(name).slice(0, 3).join(', ')}), and every one last exited cleanly`);
|
|
878
|
+
const status = nightlyStatus();
|
|
879
|
+
if (status.state === 'on') return row(STATE.ON, status.evidence);
|
|
880
|
+
if (status.state === 'degraded') return row(STATE.ON, `${status.evidence} — installed, but operationally degraded`);
|
|
881
|
+
if (status.state === 'off') return row(STATE.ABSENT, status.evidence);
|
|
882
|
+
return row(STATE.UNKNOWN, status.evidence);
|
|
956
883
|
},
|
|
957
884
|
},
|
|
958
885
|
];
|
|
@@ -32,10 +32,14 @@ import path from 'node:path';
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { CONTEXT_EVENTS } from './codex-hook-events.mjs';
|
|
35
|
+
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
36
|
+
|
|
37
|
+
if (developmentHooksSuspended()) process.exit(0);
|
|
35
38
|
|
|
36
39
|
const raw = fs.readFileSync(0, 'utf8');
|
|
37
40
|
let input = {};
|
|
38
41
|
try { input = raw ? JSON.parse(raw) : {}; } catch { /* the shared hook bodies already fail soft */ }
|
|
42
|
+
if (typeof input.cwd === 'string' && developmentHooksSuspended(input.cwd)) process.exit(0);
|
|
39
43
|
|
|
40
44
|
const hookId = process.argv[2] || '';
|
|
41
45
|
const event = String(input.hook_event_name || '');
|
|
@@ -49,10 +53,18 @@ const codexToolName = String(input.tool_name).toLowerCase();
|
|
|
49
53
|
|
|
50
54
|
/** Every file an apply_patch touches, in patch order. Codex patches are routinely multi-file. */
|
|
51
55
|
export function patchFiles(patch) {
|
|
56
|
+
return [...new Set(patchOperations(patch).flatMap((op) => op.move ? [op.file, op.move] : [op.file]))];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function patchOperations(patch) {
|
|
52
60
|
const out = [];
|
|
53
|
-
for (const
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
61
|
+
for (const line of String(patch || '').split(/\r?\n/)) {
|
|
62
|
+
const header = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
|
|
63
|
+
if (header) out.push({ operation: header[1], file: header[2].trim() });
|
|
64
|
+
else {
|
|
65
|
+
const move = /^\*\*\* Move to: (.+)$/.exec(line);
|
|
66
|
+
if (move && out.at(-1)?.operation === 'Update') out.at(-1).move = move[1].trim();
|
|
67
|
+
}
|
|
56
68
|
}
|
|
57
69
|
return out;
|
|
58
70
|
}
|
|
@@ -60,6 +72,8 @@ export function patchFiles(patch) {
|
|
|
60
72
|
// Codex names these tools differently from the shared Claude hook contracts. Normalize at the
|
|
61
73
|
// host boundary once so every existing safety/learning body sees the same typed event.
|
|
62
74
|
let files = [];
|
|
75
|
+
let operations = [];
|
|
76
|
+
const patchTool = ['apply_patch', 'functions.apply_patch', 'functions__apply_patch'].includes(codexToolName);
|
|
63
77
|
if (['exec_command', 'functions.exec_command', 'functions__exec_command'].includes(codexToolName)) {
|
|
64
78
|
input.tool_name = 'Bash';
|
|
65
79
|
input.tool_input = {
|
|
@@ -67,12 +81,17 @@ if (['exec_command', 'functions.exec_command', 'functions__exec_command'].includ
|
|
|
67
81
|
command: input.tool_input?.command || input.tool_input?.cmd || '',
|
|
68
82
|
};
|
|
69
83
|
adapted = true;
|
|
70
|
-
} else if (
|
|
71
|
-
|
|
84
|
+
} else if (patchTool) {
|
|
85
|
+
// Codex 0.153.4's installed hook schema declares tool_input as arbitrary JSON. Its installed
|
|
86
|
+
// apply_patch grammar is FREEFORM (raw string); object.command is the existing compatibility
|
|
87
|
+
// contract. Do not spread a raw string into numbered object properties or guess other fields.
|
|
88
|
+
const patch = typeof input.tool_input === 'string' ? input.tool_input
|
|
89
|
+
: typeof input.tool_input?.command === 'string' ? input.tool_input.command : '';
|
|
90
|
+
operations = patchOperations(patch);
|
|
72
91
|
files = patchFiles(patch);
|
|
73
92
|
input.tool_name = 'Edit';
|
|
74
93
|
input.tool_input = {
|
|
75
|
-
...(input.tool_input
|
|
94
|
+
...(input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}),
|
|
76
95
|
...(files[0] ? { file_path: files[0] } : {}),
|
|
77
96
|
new_string: patch,
|
|
78
97
|
};
|
|
@@ -89,16 +108,30 @@ if (['exec_command', 'functions.exec_command', 'functions__exec_command'].includ
|
|
|
89
108
|
|
|
90
109
|
const hookInput = adapted ? JSON.stringify(input) : raw;
|
|
91
110
|
const shim = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hook-shim.mjs');
|
|
111
|
+
const projectDir = String(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
92
112
|
const env = {
|
|
93
113
|
...process.env,
|
|
94
114
|
CLAUDE_SESSION_ID: String(input.session_id || process.env.CLAUDE_SESSION_ID || ''),
|
|
95
115
|
CLAUDE_PLUGIN_ROOT: String(process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || ''),
|
|
96
|
-
CLAUDE_PROJECT_DIR:
|
|
116
|
+
CLAUDE_PROJECT_DIR: projectDir,
|
|
97
117
|
RUVNET_HOOK_HOST: 'codex',
|
|
98
118
|
};
|
|
99
119
|
|
|
120
|
+
// Dream Cycle 2026-09-05. Codex's own dispatch trampoline (codex-hooks.json) never passes `cwd:`
|
|
121
|
+
// when it spawns codex-hook.mjs, and codex-hook-wrapper.mjs never passes it when it spawns THIS
|
|
122
|
+
// process either — so the real OS $PWD this process (and every child below it) inherits is wherever
|
|
123
|
+
// Codex happened to launch the trampoline from, architecturally independent of the payload's own
|
|
124
|
+
// `cwd` field used for CLAUDE_PROJECT_DIR above. project-identity.mjs's projectDirectory() and
|
|
125
|
+
// learn-capture.sh's containment check both trust CLAUDE_PROJECT_DIR only when $PWD actually lies
|
|
126
|
+
// inside it (#85/#107) — so whenever the dispatcher's real cwd and the payload's declared cwd
|
|
127
|
+
// diverge, that check silently REJECTS the correct root and falls back to the dispatcher's own
|
|
128
|
+
// directory, which this plugin does not own (ADR-058 D5). Fall back to the current cwd if the
|
|
129
|
+
// declared one no longer exists, rather than handing spawnSync a cwd it will ENOENT on.
|
|
130
|
+
let shimCwd = process.cwd();
|
|
131
|
+
try { if (fs.statSync(projectDir).isDirectory()) shimCwd = projectDir; } catch { /* keep the default */ }
|
|
132
|
+
|
|
100
133
|
const runShim = (payload) => spawnSync(process.execPath, [shim, hookId, ...process.argv.slice(3)], {
|
|
101
|
-
input: payload, encoding: 'utf8', env,
|
|
134
|
+
input: payload, encoding: 'utf8', env, cwd: shimCwd,
|
|
102
135
|
});
|
|
103
136
|
|
|
104
137
|
/**
|
|
@@ -119,13 +152,33 @@ const BUDGET_MS = Number(process.env.RUVNET_CODEX_BUDGET_MS) || 0;
|
|
|
119
152
|
const started = Date.now();
|
|
120
153
|
const spent = () => Date.now() - started;
|
|
121
154
|
|
|
122
|
-
|
|
155
|
+
let payloads = files.length > 1
|
|
123
156
|
? files.map((file) => JSON.stringify({
|
|
124
157
|
...input,
|
|
125
158
|
tool_input: { ...input.tool_input, file_path: file },
|
|
126
159
|
}))
|
|
127
160
|
: [hookInput];
|
|
128
161
|
|
|
162
|
+
if (patchTool && hookId === 'md-stamp') {
|
|
163
|
+
// Installed apply_patch reports this exact success banner plus A/M/D path records. A raw patch
|
|
164
|
+
// describes intent; only the successful result authorizes stamping or supplies Add provenance.
|
|
165
|
+
const response = input.tool_response;
|
|
166
|
+
if (event !== 'PostToolUse' || typeof response !== 'string'
|
|
167
|
+
|| !/^Success\. Updated the following files:\r?\n/.test(response)) process.exit(0);
|
|
168
|
+
const succeeded = new Map([...response.matchAll(/^([AMD]) (.+)$/gm)]
|
|
169
|
+
.map((m) => [path.resolve(projectDir, m[2].trim()), m[1]]));
|
|
170
|
+
payloads = operations.filter((op) => op.operation !== 'Delete').flatMap((op) => {
|
|
171
|
+
const file = op.move || op.file;
|
|
172
|
+
const status = succeeded.get(path.resolve(projectDir, file));
|
|
173
|
+
if (status !== 'A' && status !== 'M') return [];
|
|
174
|
+
return [JSON.stringify({
|
|
175
|
+
...input,
|
|
176
|
+
tool_input: { ...input.tool_input, file_path: file },
|
|
177
|
+
tool_response: op.operation === 'Add' && status === 'A' ? { type: 'create' } : response,
|
|
178
|
+
})];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
129
182
|
const stdouts = [];
|
|
130
183
|
for (const payload of payloads) {
|
|
131
184
|
const r = runShim(payload);
|
|
@@ -4,6 +4,13 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
6
|
|
|
7
|
+
// New installs colocate the helper. Older standalone fixtures/installations can omit it;
|
|
8
|
+
// they retain their prior behavior until the installer upgrades both files.
|
|
9
|
+
let developmentHooksSuspended = () => false;
|
|
10
|
+
try { ({ developmentHooksSuspended } = await import('./development-maintenance.mjs')); }
|
|
11
|
+
catch (error) { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; }
|
|
12
|
+
if (developmentHooksSuspended()) process.exit(0);
|
|
13
|
+
|
|
7
14
|
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
8
15
|
const brainHome = process.env.RUVNET_BRAIN_HOME
|
|
9
16
|
|| path.join(path.dirname(codexHome), '.cache', 'ruvnet-brain');
|
|
@@ -14,7 +21,15 @@ const blockingHooks = new Set([
|
|
|
14
21
|
// blocking reason to stderr"). Absent from this set it would be wired and toothless: the exit 2
|
|
15
22
|
// would be swallowed here and every refusal would silently become an allow.
|
|
16
23
|
'decision-gate',
|
|
17
|
-
'route-dispatch',
|
|
24
|
+
// NOT 'route-dispatch' (removed Dream Cycle 2026-09-05, MEASURED drift): hook-shim.mjs's own
|
|
25
|
+
// TABLE has declared it `mode: 'advisory'` since issue #84 — Claude Code consumes its
|
|
26
|
+
// PreToolUse:Agent/Task result ~140ms after tool_dispatch_end, so a refusal there would arrive
|
|
27
|
+
// too late to be enforcement (hook-shim.mjs's own comment, in the past tense, about this exact
|
|
28
|
+
// membership: "This comment used to cite 'route-dispatch's exit-2 wall' as the example. That was
|
|
29
|
+
// FALSE and had to go"). hook-shim.mjs's own dispatch already coerces its exit code to 0
|
|
30
|
+
// unconditionally, so this membership could never fire — harmless today, but exactly the
|
|
31
|
+
// false-confidence gap tests/unit/codex-blocking-hooks-parity.test.mjs now holds closed: this
|
|
32
|
+
// Set must name only ids hook-shim.mjs's TABLE actually calls 'blocking'.
|
|
18
33
|
'hijack-ruvnet', // ADR-063: opt-in managed-memory refusal; exits 0 at the default
|
|
19
34
|
'ground-before-write',
|
|
20
35
|
'protect-state',
|
|
@@ -147,6 +162,9 @@ function readHookInput(limit = 1024 * 1024) {
|
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
const input = await readHookInput();
|
|
165
|
+
let projectCwd;
|
|
166
|
+
try { projectCwd = JSON.parse(input.toString('utf8')).cwd; } catch { /* malformed host payload */ }
|
|
167
|
+
if (typeof projectCwd === 'string' && developmentHooksSuspended(projectCwd)) process.exit(0);
|
|
150
168
|
const root = activeRoot();
|
|
151
169
|
const adapter = root && path.join(root, 'scripts', 'codex-hook-adapter.mjs');
|
|
152
170
|
if (!adapter || !fs.existsSync(adapter)) process.exit(0);
|
|
@@ -25,11 +25,14 @@
|
|
|
25
25
|
* committed-to items with a done state — and if authorized work remains unfinished, it says so, in
|
|
26
26
|
* the last place the model looks before going quiet.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* CURRENT AUTHORITY: only explicitly scoped nonauthoritative continuation preferences request
|
|
29
|
+
* objective work. Historical incident notes below describe the former backlog-forcing behavior;
|
|
30
|
+
* repository observations are now stderr advisories, never permission to act. Answer-integrity
|
|
31
|
+
* correction remains independent, but cancellation/interruption and the host loop guard win.
|
|
32
|
+
*
|
|
33
|
+
* This file emits a Stop request envelope. Unit/subprocess tests prove that output, NOT native host
|
|
34
|
+
* continuation. No daemon, automatic preference writer, completion authority, or re-engagement
|
|
35
|
+
* bridge exists here. The existing stop_hook_active guard remains unchanged.
|
|
33
36
|
*
|
|
34
37
|
* FAILS OPEN ALWAYS. Exit 0 unconditionally. A gate that breaks a turn's completion because it
|
|
35
38
|
* could not read a JSON file would be disabled within a day, and a disabled gate protects nothing.
|
|
@@ -43,11 +46,11 @@ import {
|
|
|
43
46
|
buildCapabilityInventoryReceipt,
|
|
44
47
|
} from './capability-inventory-receipt.mjs';
|
|
45
48
|
import { auditCurrentCapabilityEvidence } from './capability-claim-evidence.mjs';
|
|
49
|
+
import { continuationProjectIdentity, authorizedContinuationObjective } from './continuation-objective.mjs';
|
|
46
50
|
|
|
47
51
|
const HOME = os.homedir();
|
|
48
52
|
|
|
49
|
-
//
|
|
50
|
-
// end; this gate informs and never refuses, so every path below returns exactly this.
|
|
53
|
+
// Always exit zero. Any host continuation request is expressed in the envelope, not exit status.
|
|
51
54
|
const EXIT_ALLOW = 0;
|
|
52
55
|
/**
|
|
53
56
|
* PROJECT-SCOPED, because this runs machine-wide.
|
|
@@ -59,20 +62,10 @@ const EXIT_ALLOW = 0;
|
|
|
59
62
|
* project so the three never see each other's work.
|
|
60
63
|
*/
|
|
61
64
|
function projectKey() {
|
|
62
|
-
|
|
63
|
-
// Walk up to the git root — the stable identity of a project, regardless of which subdirectory
|
|
64
|
-
// a hook happens to fire from. (A CWD-derived key was exactly the bug that scattered ledgers
|
|
65
|
-
// through users' project trees in issue #36.)
|
|
66
|
-
for (let i = 0; i < 12; i++) {
|
|
67
|
-
if (fs.existsSync(path.join(dir, '.git'))) break;
|
|
68
|
-
const up = path.dirname(dir);
|
|
69
|
-
if (up === dir) { dir = process.cwd(); break; }
|
|
70
|
-
dir = up;
|
|
71
|
-
}
|
|
72
|
-
return path.basename(dir).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
65
|
+
return continuationProjectIdentity(process.cwd())?.projectId.replace(':', '-') || 'unknown-project';
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
|
|
68
|
+
let LEDGER = process.env.RUVNET_WORK_LEDGER
|
|
76
69
|
|| path.join(HOME, '.config', 'ruvnet-brain', 'work-ledgers', `${projectKey()}.json`);
|
|
77
70
|
|
|
78
71
|
/**
|
|
@@ -248,8 +241,16 @@ if (hookInput.__source !== 'stdin') process.exit(EXIT_ALLOW);
|
|
|
248
241
|
*/
|
|
249
242
|
if (hookInput.stop_hook_active) process.exit(EXIT_ALLOW);
|
|
250
243
|
|
|
244
|
+
if (hookInput.hook_event_name !== 'Stop' || hookInput.interrupted || hookInput.cancelled) process.exit(EXIT_ALLOW);
|
|
245
|
+
const projectIdentity = continuationProjectIdentity(hookInput.cwd);
|
|
246
|
+
if (!projectIdentity) process.exit(EXIT_ALLOW);
|
|
247
|
+
LEDGER = process.env.RUVNET_WORK_LEDGER
|
|
248
|
+
|| path.join(HOME, '.config', 'ruvnet-brain', 'work-ledgers', `${projectIdentity.projectId.replace(':', '-')}.json`);
|
|
251
249
|
const led = load();
|
|
252
250
|
const nowMs = Date.now();
|
|
251
|
+
// Terminal objectives remain terminal even if legacy/global ledger rows or observations stay open.
|
|
252
|
+
if (['cancelled', 'completed', 'blocked'].includes(led.objective?.state)) process.exit(EXIT_ALLOW);
|
|
253
|
+
const objective = authorizedContinuationObjective(led.objective, hookInput, projectIdentity);
|
|
253
254
|
|
|
254
255
|
// LOOP-SAFETY 1b (GPT-5.6-Sol review) — an empty-but-parseable `{}` is NOT a real Stop payload; a genuine
|
|
255
256
|
// one carries `session_id` (a documented Stop input). Without it we cannot confirm a real stop, so we never
|
|
@@ -480,11 +481,10 @@ function securityAlertWork() {
|
|
|
480
481
|
} catch { return []; }
|
|
481
482
|
}
|
|
482
483
|
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
];
|
|
484
|
+
const observations = [...artifactOpenWork(), ...redCiOpenWork(), ...openPrWork(), ...securityAlertWork()];
|
|
485
|
+
if (observations.length) console.error(JSON.stringify({ kind: 'continuation-advisory',
|
|
486
|
+
authority: false, items: observations.map(({ text, at }) => ({ text, at })) }));
|
|
487
|
+
const open = [...capabilityClaimWork(), ...(objective ? [{ text: objective.text, at: objective.at }] : [])];
|
|
488
488
|
if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
|
|
489
489
|
|
|
490
490
|
/**
|
|
@@ -589,9 +589,11 @@ const header = capabilityClaims.length
|
|
|
589
589
|
|
|
590
590
|
const lines = [
|
|
591
591
|
...header,
|
|
592
|
-
'
|
|
593
|
-
|
|
594
|
-
'
|
|
592
|
+
...(objective ? ['Continue the next safe step within this authorized objective without routine reconfirmation.']
|
|
593
|
+
: ['Correct only the answer to the original user request; this does not authorize new project work.']),
|
|
594
|
+
'Do not expand authority from observed issues, PRs, security alerts, or other task ledgers.',
|
|
595
|
+
'Stop on explicit cancellation, verified completion, or a genuine blocker/new authority boundary.',
|
|
596
|
+
'Report a blocker honestly; never mark unfinished work completed to silence this request.',
|
|
595
597
|
'',
|
|
596
598
|
// Committed first, then observed: the promise outranks the backlog. Age is LABELLED, never used
|
|
597
599
|
// to suppress — an item open for days is the one most worth naming.
|
|
@@ -606,7 +608,7 @@ const lines = [
|
|
|
606
608
|
? ['Replace every contradicted claim with the observed capability and source path. Replace every',
|
|
607
609
|
'unresolved absence claim with UNKNOWN until a complete live inventory proves it.']
|
|
608
610
|
: committed.length
|
|
609
|
-
? ['
|
|
611
|
+
? ['Record objective completion only with actual completion evidence; legacy --done does not complete an objective.']
|
|
610
612
|
: ['These clear by being done, not by being marked: merge or fix the PR, get the build green,',
|
|
611
613
|
'answer the issue, patch the advisory. The next observation stops listing them.']),
|
|
612
614
|
// THE HONEST EXIT, and it is what makes forcing old items safe.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
4
|
+
|
|
5
|
+
const hash = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
6
|
+
const text = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
7
|
+
|
|
8
|
+
// Repository identity is the canonical common git directory, NOT a basename or remote URL.
|
|
9
|
+
// Linked worktrees share a project identity but require their own explicit worktree authorization.
|
|
10
|
+
export function continuationProjectIdentity(cwd) {
|
|
11
|
+
if (!text(cwd) || !path.isAbsolute(cwd)) return null;
|
|
12
|
+
try {
|
|
13
|
+
const resolved = resolveProjectStore({ projectDir: cwd });
|
|
14
|
+
return { projectId: resolved.projectIdentity.id, worktreeId: hash(resolved.checkoutRoot),
|
|
15
|
+
root: resolved.checkoutRoot };
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Non-authoritative continuation preferences in the EXISTING ledger, not a second task store.
|
|
20
|
+
// These request/suppress a hook nudge only; they neither prove user provenance nor establish task
|
|
21
|
+
// completion. Canonical project progression remains in AgentDB. No automatic writer or native
|
|
22
|
+
// continuation bridge is supplied here. Configuration must cite an actual user authorization.
|
|
23
|
+
export function authorizedContinuationObjective(objective, input, identity) {
|
|
24
|
+
if (!identity || input?.hook_event_name !== 'Stop' || !text(input.session_id)
|
|
25
|
+
|| input.interrupted || input.cancelled || input.stop_hook_active) return null;
|
|
26
|
+
if (objective?.schemaVersion !== 1 || objective.kind !== 'continuation-preferences'
|
|
27
|
+
|| objective.authoritative !== false || objective.state !== 'active'
|
|
28
|
+
|| !text(objective.id) || !text(objective.text) || !Number.isFinite(Date.parse(objective.at))
|
|
29
|
+
|| objective.authorization?.kind !== 'user' || !text(objective.authorization.reference)
|
|
30
|
+
|| objective.projectId !== identity.projectId
|
|
31
|
+
|| !Array.isArray(objective.sessionIds) || !objective.sessionIds.includes(input.session_id)
|
|
32
|
+
|| !Array.isArray(objective.worktreeIds) || !objective.worktreeIds.includes(identity.worktreeId)) return null;
|
|
33
|
+
return objective;
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Local configuration only: no Git subprocess, network, hook bodies, or writes on the read path.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function repositoryScope(cwd = process.cwd()) {
|
|
6
|
+
let dir = fs.realpathSync(cwd);
|
|
7
|
+
for (;;) {
|
|
8
|
+
const dotgit = path.join(dir, '.git');
|
|
9
|
+
let stat;
|
|
10
|
+
try { stat = fs.lstatSync(dotgit); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
11
|
+
if (stat) {
|
|
12
|
+
if (stat.isSymbolicLink()) throw new Error('Refusing symlink .git');
|
|
13
|
+
let gitdir = dotgit;
|
|
14
|
+
if (stat.isFile()) {
|
|
15
|
+
const match = /^gitdir: (.+)\r?\n?$/.exec(fs.readFileSync(dotgit, 'utf8'));
|
|
16
|
+
if (!match) throw new Error('Invalid worktree .git file');
|
|
17
|
+
gitdir = path.resolve(dir, match[1].trim());
|
|
18
|
+
} else if (!stat.isDirectory()) throw new Error('Invalid .git metadata');
|
|
19
|
+
let common = gitdir;
|
|
20
|
+
try { common = path.resolve(gitdir, fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim()); }
|
|
21
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
22
|
+
common = fs.realpathSync(common);
|
|
23
|
+
const owner = fs.statSync(common);
|
|
24
|
+
if (!owner.isDirectory() || (process.getuid && owner.uid !== process.getuid())) throw new Error('Repository metadata is not owned by this user');
|
|
25
|
+
return { project: dir, commonDir: common, statePath: path.join(common, 'ruvnet-brain-maintenance.json') };
|
|
26
|
+
}
|
|
27
|
+
const parent = path.dirname(dir);
|
|
28
|
+
if (parent === dir) return null;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function maintenanceStatus(cwd = process.cwd()) {
|
|
34
|
+
const scope = repositoryScope(cwd);
|
|
35
|
+
if (!scope) return { project: null, suspended: false };
|
|
36
|
+
let stat;
|
|
37
|
+
try { stat = fs.lstatSync(scope.statePath); }
|
|
38
|
+
catch (error) { if (error.code === 'ENOENT') return { ...scope, suspended: false }; throw error; }
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) throw new Error('Maintenance state must be a user-owned regular file');
|
|
40
|
+
if (process.platform !== 'win32' && (stat.mode & 0o022)) throw new Error('Maintenance state is writable by other users');
|
|
41
|
+
const state = JSON.parse(fs.readFileSync(scope.statePath, 'utf8'));
|
|
42
|
+
if (state.schema !== 1 || state.commonDir !== scope.commonDir || state.suspended !== true) throw new Error('Invalid maintenance state for this repository');
|
|
43
|
+
return { ...scope, ...state };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function developmentHooksSuspended(cwd = process.cwd()) {
|
|
47
|
+
// A malformed local configuration is not authority to bypass a hook.
|
|
48
|
+
try { return maintenanceStatus(cwd).suspended; } catch { return false; }
|
|
49
|
+
}
|
|
@@ -33,6 +33,9 @@ import os from 'node:os';
|
|
|
33
33
|
import { spawnSync } from 'node:child_process';
|
|
34
34
|
import { fileURLToPath } from 'node:url';
|
|
35
35
|
import { resolveBash, skipNoBash } from './hook-shim-bash.mjs';
|
|
36
|
+
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
37
|
+
|
|
38
|
+
if (developmentHooksSuspended()) process.exit(0);
|
|
36
39
|
|
|
37
40
|
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
38
41
|
const ACTIVE = path.join(BRAIN_HOME, 'active.json');
|
|
@@ -161,7 +161,12 @@ if (failures.length) {
|
|
|
161
161
|
const distinct = [...new Set(failures)];
|
|
162
162
|
warn(`${failures.length}/${actions.length} feed call(s) FAILED via ${RUFLO}`
|
|
163
163
|
+ ` — ${distinct.slice(0, 2).join(' | ')}${distinct.length > 2 ? ` (+${distinct.length - 2} more kind(s))` : ''}`
|
|
164
|
-
+ (fed === 0 ? '. Nothing was learned; the queue is KEPT for retry.'
|
|
164
|
+
+ (fed === 0 ? '. Nothing was learned; the queue is KEPT for retry.'
|
|
165
|
+
: `. ${fed} succeeded; the entire queue is KEPT for retry (successful actions may replay).`));
|
|
166
|
+
// Preserve the original bytes on ANY failed attempt. Rewriting only the deferred tail would
|
|
167
|
+
// discard failed actions whenever a sibling succeeded. This is at-least-once retry, not an
|
|
168
|
+
// exactly-once or concurrent-capture protocol; successful actions may be fed again.
|
|
169
|
+
process.exit(0);
|
|
165
170
|
}
|
|
166
171
|
// Whatever the deadline cut off is WORK, not waste: it goes back on the front of the queue so the
|
|
167
172
|
// next flush continues from there. Dropping it would turn a time limit into the same silent data
|
|
@@ -329,7 +329,7 @@ const presentation = buildLessonPresentation({
|
|
|
329
329
|
maxShows: MAX_SHOWS,
|
|
330
330
|
nudgeBudget: NUDGE_CHAR_BUDGET,
|
|
331
331
|
});
|
|
332
|
-
const { inForce, blocking, blockCapable, body: renderedBody, advisoryContext } = presentation;
|
|
332
|
+
const { inForce, blocking, blockCapable, shown: shownThisCall, body: renderedBody, advisoryContext } = presentation;
|
|
333
333
|
const renderBody = () => renderedBody;
|
|
334
334
|
|
|
335
335
|
// ── Emit ─────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -356,7 +356,10 @@ if (event) {
|
|
|
356
356
|
st.sessions = st.sessions && typeof st.sessions === 'object' ? st.sessions : {};
|
|
357
357
|
const prev = st.sessions[SID] && typeof st.sessions[SID] === 'object' ? st.sessions[SID] : {};
|
|
358
358
|
const shown = prev.shown && typeof prev.shown === 'object' ? { ...prev.shown } : {};
|
|
359
|
-
|
|
359
|
+
// Charge every lesson actually rendered — full (inForce) or compact (compactExtras, folded into
|
|
360
|
+
// `shownThisCall` by buildLessonPresentation) — or a lesson permanently trimmed to the compact
|
|
361
|
+
// line by budget competition nags every qualifying event forever, uncounted.
|
|
362
|
+
for (const l of shownThisCall) {
|
|
360
363
|
if (capExempt(l)) continue;
|
|
361
364
|
shown[l.id] = (Number.isInteger(shown[l.id]) && shown[l.id] > 0 ? shown[l.id] : 0) + 1;
|
|
362
365
|
}
|
|
@@ -93,6 +93,11 @@ export function buildLessonPresentation({
|
|
|
93
93
|
inForce,
|
|
94
94
|
blocking,
|
|
95
95
|
blockCapable,
|
|
96
|
+
// Every lesson actually rendered to the user/model this call, full or compact — the frequency
|
|
97
|
+
// cap must charge both, or a lesson permanently trimmed to the compact line (e.g. it never wins
|
|
98
|
+
// the nudge budget against a higher-priority or cap-exempt lesson) nags on every qualifying event
|
|
99
|
+
// forever, uncounted, defeating RUVNET_LESSON_MAX_SHOWS the same way an uncapped full render would.
|
|
100
|
+
shown: [...inForce, ...compactExtras],
|
|
96
101
|
body,
|
|
97
102
|
advisoryContext: [...ADVISORY_APPLICATION_CONTRACT, body].join('\n'),
|
|
98
103
|
};
|