ruvnet-brain 4.3.9 → 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.
Files changed (83) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +11 -2
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +472 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. 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 { NIGHTLY_LABEL } from './nightly-controller.mjs';
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 get mined into reusable patterns, so your AI recalls the lesson instead of re-reading every old note to find it.',
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 offer points at scripts/distill-project.mjs, NOT at bare `ruflo memory distill run`, and the
425
- // difference is the whole reason ADR-047 was rejected. Both duelists found the same hole: the
426
- // registry offers `turnOn` commands whose promised undo lives on a DIFFERENT execution path than
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
- // launchd is macOS-only. On any other platform this is UNCHECKABLE, not off — this repo has
897
- // already shipped a macOS-only assumption that went red the moment it met the Linux CI runner,
898
- // and reporting "your nightly job is off" to a Linux user would be that same bug with worse
899
- // consequences, because it reads as an actionable fault rather than a test failure.
900
- if (process.platform !== 'darwin') return row(STATE.UNKNOWN, `scheduled jobs are managed by launchd, which does not exist on ${process.platform} — this cannot be checked here`);
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 m of String(patch || '').matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) {
54
- const f = m[1].trim();
55
- if (f && !out.includes(f)) out.push(f);
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 (codexToolName === 'apply_patch') {
71
- const patch = typeof input.tool_input?.command === 'string' ? input.tool_input.command : '';
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: String(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd()),
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
- const payloads = files.length > 1
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
- * WHAT IT DOES, verified against code.claude.com/docs/en/hooks.md (2026-07-23, not recalled, ADR-043):
29
- * a Stop hook's `additionalContext` at exit 0 DOES force a continuation — under the same loop
30
- * protections as decision:block (the `stop_hook_active` input + the 8-consecutive-continuation cap). An
31
- * earlier version of this header claimed "a Stop hook cannot force another turn"; that was wrong. The
32
- * gate still exits 0 always — continuation is driven by the envelope, never by a non-zero exit code.
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
- // The only exit code this file may ever use. A Stop hook that exits non-zero refuses to let the turn
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
- let dir = process.cwd();
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
- const LEDGER = process.env.RUVNET_WORK_LEDGER
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 open = [
484
- ...capabilityClaimWork(),
485
- ...led.items.filter((i) => !i.done),
486
- ...artifactOpenWork(), ...redCiOpenWork(), ...openPrWork(), ...securityAlertWork(),
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
- 'Pick the highest-leverage open item below and make real progress on it this turn. Stop only when',
593
- 'EVERY item is genuinely done or blocked; if one is blocked, say why in a single line and move to',
594
- 'the next — never stop on the first obstacle, and never manufacture a reason to go quiet.',
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
- ? ['Mark each item done as you complete it: node plugin/scripts/continuation-gate.mjs --done "<exact item text>"']
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.' : `. ${fed} succeeded.`));
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
- for (const l of inForce) {
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
  };