ruvnet-brain 4.5.16 → 4.6.0

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 (86) hide show
  1. package/README.md +15 -19
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +15 -3
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/config/practical-rule-catalog.json +111 -0
  6. package/console/CONTRACT.md +47 -3
  7. package/console/app.js +112 -20
  8. package/package.json +5 -2
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/commands/brain-console.md +13 -1
  12. package/plugin/commands/configure.md +2 -2
  13. package/plugin/commands/rnb.md +13 -1
  14. package/plugin/commands/rnbc.md +13 -1
  15. package/plugin/commands/rvbc.md +13 -1
  16. package/plugin/commands/rvcb.md +13 -1
  17. package/plugin/hooks/codex-hooks.json +13 -1
  18. package/plugin/hooks/hook-contracts.json +20 -4
  19. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  20. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  21. package/plugin/scripts/agentdb-recall.mjs +161 -26
  22. package/plugin/scripts/automatic-update.mjs +11 -2
  23. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  24. package/plugin/scripts/capability-registry.mjs +55 -114
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  26. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  27. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  28. package/plugin/scripts/continuation-gate.mjs +2 -1
  29. package/plugin/scripts/continuity-brief.mjs +55 -22
  30. package/plugin/scripts/continuity-events.mjs +93 -17
  31. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  32. package/plugin/scripts/continuity-journal.mjs +77 -29
  33. package/plugin/scripts/decision-gate.mjs +62 -37
  34. package/plugin/scripts/decision-outcomes.mjs +192 -199
  35. package/plugin/scripts/detach.mjs +53 -13
  36. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  37. package/plugin/scripts/developer-update-lock.mjs +30 -0
  38. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  39. package/plugin/scripts/developer-update-owner.mjs +24 -0
  40. package/plugin/scripts/developer-update-policy.mjs +48 -0
  41. package/plugin/scripts/developer-update.mjs +435 -0
  42. package/plugin/scripts/doc-currency.mjs +993 -0
  43. package/plugin/scripts/ground-ruvnet.sh +16 -3
  44. package/plugin/scripts/grounding-answer.mjs +18 -16
  45. package/plugin/scripts/grounding-stamp.sh +3 -0
  46. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  47. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  48. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  49. package/plugin/scripts/hook-context-budget.mjs +90 -0
  50. package/plugin/scripts/hook-registry.mjs +129 -1
  51. package/plugin/scripts/hook-shim.mjs +20 -2
  52. package/plugin/scripts/host-update.mjs +6 -3
  53. package/plugin/scripts/lesson-gate.mjs +1 -1
  54. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  55. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  56. package/plugin/scripts/project-capture-queue.mjs +22 -15
  57. package/plugin/scripts/project-progression-producer.mjs +15 -8
  58. package/plugin/scripts/project-progression-reader.mjs +14 -4
  59. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  60. package/plugin/scripts/project-progression-sources.mjs +24 -10
  61. package/plugin/scripts/project-progression-store.mjs +7 -2
  62. package/plugin/scripts/project-store-resolver.mjs +10 -7
  63. package/plugin/scripts/project-transition-hook.mjs +20 -12
  64. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  65. package/plugin/scripts/session-start-budget.mjs +18 -0
  66. package/plugin/scripts/session-start-core.mjs +32 -6
  67. package/plugin/scripts/session-start-trace.mjs +2 -1
  68. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  69. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  70. package/plugin/scripts/user-settings.mjs +7 -1
  71. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  72. package/scripts/codex-managed-terminal.mjs +1 -1
  73. package/scripts/console-runtime-identity.mjs +2 -0
  74. package/scripts/console-suite-update.mjs +117 -0
  75. package/scripts/doc-currency.mjs +6 -990
  76. package/scripts/hook-qualify-core.mjs +30 -1
  77. package/scripts/model-managed-prompt.mjs +1 -1
  78. package/scripts/model-managed-workflow-service.mjs +113 -14
  79. package/scripts/model-routing-controller.mjs +112 -21
  80. package/scripts/onboarding-console.mjs +41 -12
  81. package/scripts/practical-rule-selector.mjs +51 -0
  82. package/scripts/product-integrity-contract.mjs +11 -4
  83. package/scripts/release-qualification-contract.mjs +14 -0
  84. package/scripts/remedy-registry.mjs +1 -0
  85. package/scripts/source-scope-receipt.mjs +9 -4
  86. package/scripts/stack-sync.mjs +19 -132
@@ -1,71 +1,27 @@
1
1
  /**
2
- * decision-outcomes.mjs — did the refusal TEACH, or just stop them?
2
+ * Decision refusal observations, not repair-success proof. An allowed PreToolUse retry is an
3
+ * admission; no PostToolUse completion is observed here. Historical corrected records remain
4
+ * untouched and are labeled as legacy admissions in the report. Fresh foreign-session debts are
5
+ * retained. Only explicit session closure is abandonment; old observations may expire by age.
3
6
  *
4
- * ─────────────────────────────────────────────────────────────────────────────────────────────────
5
- * THE GAP THIS CLOSES. ADR-066's own honesty boundary says it plainly: "Delivery is proven;
6
- * *obedience is not measured*." The brain could show a lesson, refuse an action, and never once know
7
- * whether either changed anything. And `lesson-stamps-prove-ceremony-not-obedience` — itself one of
8
- * the bridged lessons — names exactly that failure: a stamp proves the ritual ran, not that its
9
- * answer was obeyed.
10
- *
11
- * A system that cannot see its own outcomes cannot improve, and one that reports a number it cannot
12
- * source is doing the fabrication this repo has a CI gate against.
13
- *
14
- * ─────────────────────────────────────────────────────────────────────────────────────────────────
15
- * WHAT IS ACTUALLY OBSERVABLE, and this is the honest part.
16
- *
17
- * "Did the model obey an advisory?" is NOT observable from a hook. The advisory reaches the model's
18
- * context and what it does next is unconstrained prose. Claiming to measure that would be the
19
- * inflated-score failure mode.
20
- *
21
- * What IS observable, exactly, from the one gate that sees every Write/Edit/Bash: **what happened
22
- * after a REFUSAL**. Three outcomes, mutually exclusive and jointly exhaustive:
23
- *
24
- * corrected the same target was attempted again and was ALLOWED — the reason landed, the user
25
- * or model fixed the thing and proceeded. This is the outcome the gate exists to cause.
26
- * repeated the same target was attempted again and refused again — the reason did NOT land.
27
- * A guard that produces this is teaching nothing and is on its way to being switched off.
28
- * abandoned never attempted again in this session. Ambiguous ON PURPOSE and never counted as a
29
- * win: it is equally "they understood and stopped" and "they gave up and worked around
30
- * us", and this ledger must not pick the flattering reading.
31
- *
32
- * THE ONE WAY TO FABRICATE THIS, named so a reviewer can check for it: record only `corrected`. The
33
- * rate is corrected ÷ (corrected + repeated + abandoned), so a caller that forgets the other two
34
- * reports a perfect score. The invariant is therefore NOT "record outcomes" but **every refusal
35
- * produces exactly one record**, and `abandoned` is what an unresolved refusal becomes at session
36
- * end. Same invariant, same reasoning, as advocacy-outcomes.mjs — one pattern, not two.
37
- *
38
- * SCOPE, stated rather than implied: this measures the BLOCKING path only. Advisory lessons are
39
- * counted as `surfaced` for coverage and are NEVER scored, because their effect is not observable
40
- * here. A coverage number that hides what it cannot see is the same lie as a truncated list.
41
- *
42
- * STORAGE: ~/.config/ruvnet-brain/, user-level, deliberately OUTSIDE ~/.cache/ruvnet-brain/ which
43
- * `--update` replaces wholesale — an outcome destroyed by the next release never compounds.
44
- * Append-only JSONL, bounded, node builtins only, and every write is best-effort: a ledger that
45
- * could break a tool call would be worse than no ledger.
7
+ * The existing JSON/JSONL paths remain intact. Owned exclusive locks serialize pending snapshots
8
+ * and ledger appends; atomic/fsynced publication follows the repository's existing write protocol.
9
+ * Malformed, linked, busy or full state is unavailable, never replaced, truncated or written through.
10
+ * Logging remains best-effort and cannot alter the gate's verdict.
46
11
  */
47
12
  import fs from 'node:fs';
48
13
  import os from 'node:os';
49
14
  import path from 'node:path';
15
+ import { createHash, randomUUID } from 'node:crypto';
50
16
 
51
- export const CONFIG_ROOT = process.env.RUVNET_CONFIG_ROOT
52
- || path.join(os.homedir(), '.config', 'ruvnet-brain');
53
- export const LEDGER = process.env.RUVNET_DECISION_LEDGER
54
- || path.join(CONFIG_ROOT, 'decision-outcomes.jsonl');
55
- /** Pending refusals awaiting an outcome. Separate from the ledger so a resolve is one small write. */
56
- export const PENDING = process.env.RUVNET_DECISION_PENDING
57
- || path.join(CONFIG_ROOT, 'decision-pending.json');
58
-
59
- const MAX_LEDGER_BYTES = 1 << 20; // ~1MB, then the oldest half is dropped
17
+ export const CONFIG_ROOT = process.env.RUVNET_CONFIG_ROOT || path.join(os.homedir(), '.config', 'ruvnet-brain');
18
+ export const LEDGER = process.env.RUVNET_DECISION_LEDGER || path.join(CONFIG_ROOT, 'decision-outcomes.jsonl');
19
+ export const PENDING = process.env.RUVNET_DECISION_PENDING || path.join(CONFIG_ROOT, 'decision-pending.json');
20
+ const MAX_LEDGER_BYTES = 1 << 20;
60
21
  const MAX_PENDING = 200;
22
+ const LOCK_WAIT_MS = 1000;
23
+ const TERMINAL = new Set(['admitted-retry', 'corrected', 'repeated', 'abandoned', 'expired']);
61
24
 
62
- /**
63
- * The identity of "the same action being retried".
64
- *
65
- * Deliberately COARSE: tool + target, never the full input. A model that fixes a refusal usually
66
- * changes the content while keeping the target, and keying on content would score every correction
67
- * as a brand-new action and make `repeated` unreachable — a metric that can only produce good news.
68
- */
69
25
  export function actionKey(toolName, toolInput) {
70
26
  const t = String(toolName || '').toLowerCase();
71
27
  const i = toolInput || {};
@@ -76,156 +32,193 @@ export function actionKey(toolName, toolInput) {
76
32
  return `${t}:${String(i.file_path || i.path || '').trim()}`;
77
33
  }
78
34
 
79
- const readJson = (file, fallback) => {
80
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
81
- };
82
- const writeJson = (file, value) => {
83
- try {
84
- fs.mkdirSync(path.dirname(file), { recursive: true });
85
- fs.writeFileSync(file, JSON.stringify(value));
86
- return true;
87
- } catch { return false; }
88
- };
89
35
 
90
- /** Append one outcome. Never throws; a full disk must not break a tool call. */
91
- export function append(record, file = LEDGER) {
36
+ function regular(file) {
92
37
  try {
93
- fs.mkdirSync(path.dirname(file), { recursive: true });
94
- // Bound the file BEFORE writing, so it can never grow without limit on a long-lived machine.
95
- try {
96
- if (fs.statSync(file).size > MAX_LEDGER_BYTES) {
97
- const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
98
- fs.writeFileSync(file, `${lines.slice(Math.floor(lines.length / 2)).join('\n')}\n`);
38
+ const st = fs.lstatSync(file);
39
+ if (!st.isFile() || st.isSymbolicLink() || st.nlink > 1
40
+ || (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw Error('unmanaged decision state');
41
+ if (st.size > MAX_LEDGER_BYTES) throw Error('decision state capacity exhausted');
42
+ return st;
43
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
44
+ }
45
+ function readPending(file) {
46
+ if (!regular(file)) return {};
47
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
48
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('pending state unavailable');
49
+ return value;
50
+ }
51
+ function readLedger(file) {
52
+ if (!regular(file)) return [];
53
+ return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => {
54
+ const row = JSON.parse(line);
55
+ if (row?.kind === 'refused' || TERMINAL.has(row?.kind)) {
56
+ if (!validDebt(row) || (row.host != null && typeof row.host !== 'string')
57
+ || (row.projectId != null && !/^[a-f0-9]{64}$/.test(row.projectId))) throw Error('malformed decision observation');
58
+ if (row.kind === 'admitted-retry' && (typeof row.refusalId !== 'string' || !row.refusalId
59
+ || row.evidence !== 'pretool-decision-observation' || row.repairVerified !== false)) throw Error('malformed admitted observation');
60
+ }
61
+ return row;
62
+ });
63
+ }
64
+ /** Same owned wx/token protocol as the existing grounding and continuity handlers. */
65
+ function locked(files, work, fallback, { deadlineAt = Infinity, nonBlocking = true } = {}) {
66
+ const paths = [...new Set(files)].sort(), held = [], token = randomUUID();
67
+ const owns = () => held.every(({ file }) => {
68
+ try { return !fs.lstatSync(file).isSymbolicLink() && fs.readFileSync(file, 'utf8') === token; } catch { return false; }
69
+ });
70
+ try {
71
+ if (!(Number.isFinite(deadlineAt) || deadlineAt === Infinity) || Date.now() >= deadlineAt) return fallback;
72
+ const deadline = Math.min(Date.now() + LOCK_WAIT_MS, deadlineAt);
73
+ for (const target of paths) {
74
+ if (Date.now() >= deadline) return fallback;
75
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
76
+ regular(target);
77
+ const file = target + '.lock';
78
+ for (;;) {
79
+ try {
80
+ const fd = fs.openSync(file, 'wx', 0o600); held.push({ file, fd }); fs.writeSync(fd, token); break;
81
+ } catch (error) {
82
+ if (error.code !== 'EEXIST' || nonBlocking || Date.now() >= deadline) return fallback;
83
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
84
+ }
99
85
  }
100
- } catch { /* no file yet */ }
101
- fs.appendFileSync(file, `${JSON.stringify(record)}\n`);
86
+ }
87
+ const permitted = () => Date.now() < deadline && owns();
88
+ if (!permitted()) return fallback;
89
+ return work(permitted);
90
+ } catch { return fallback; }
91
+ finally {
92
+ for (const { file, fd } of held.reverse()) {
93
+ try { fs.closeSync(fd); } catch {}
94
+ try { if (!fs.lstatSync(file).isSymbolicLink() && fs.readFileSync(file, 'utf8') === token) fs.unlinkSync(file); } catch {}
95
+ }
96
+ }
97
+ }
98
+ function writePending(file, value, owns) {
99
+ const previous = regular(file), temp = `${file}.tmp-${randomUUID()}`;
100
+ const fd = fs.openSync(temp, 'wx', previous ? previous.mode & 0o777 : 0o600);
101
+ try {
102
+ const text = JSON.stringify(value);
103
+ if (fs.writeSync(fd, text) !== Buffer.byteLength(text)) throw Error('incomplete pending state write');
104
+ fs.fsyncSync(fd);
105
+ if (!owns()) throw Error('decision state lock changed');
106
+ fs.renameSync(temp, file);
107
+ } finally { fs.closeSync(fd); try { fs.unlinkSync(temp); } catch {} }
108
+ }
109
+ function appendLocked(record, file, owns) {
110
+ const st = regular(file), text = JSON.stringify(record) + '\n';
111
+ if ((st?.size || 0) + Buffer.byteLength(text) > MAX_LEDGER_BYTES || !owns()) throw Error('decision ledger unavailable');
112
+ const fd = fs.openSync(file, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0), 0o600);
113
+ try {
114
+ if (fs.writeSync(fd, text) !== Buffer.byteLength(text)) throw Error('incomplete decision ledger write');
115
+ fs.fsyncSync(fd);
116
+ } finally { fs.closeSync(fd); }
117
+ }
118
+ export function append(record, file = LEDGER, budget = {}) {
119
+ return locked([file], owns => { appendLocked(record, file, owns); return true; }, false, budget);
120
+ }
121
+ const stateFiles = files => ({ pending: files.pending || PENDING, ledger: files.ledger || LEDGER });
122
+ const debtId = debt => debt.refusalId || createHash('sha256').update(JSON.stringify([debt.session, debt.key, debt.ts, debt.host || null, debt.projectId || null])).digest('hex');
123
+ const validDebt = value => value && typeof value.session === 'string' && typeof value.key === 'string'
124
+ && value.session.length > 0 && value.key.length > 0 && Number.isFinite(value.ts) && Array.isArray(value.policies)
125
+ && value.policies.every(policy => typeof policy === 'string');
126
+ function scopeOf(host, project) {
127
+ if (host !== undefined && host !== null && typeof host !== 'string') throw Error('host scope unavailable');
128
+ const projectId = project ? createHash('sha256').update(fs.realpathSync(project)).digest('hex') : null;
129
+ return { host: host || null, projectId };
130
+ }
131
+ const sameScope = (left, right) => (left.host || null) === (right.host || null) && (left.projectId || null) === (right.projectId || null);
132
+ const pendingId = debt => `${debt.session}\u0000${debt.key}${debt.host || debt.projectId ? `\u0000${debt.host || ''}\u0000${debt.projectId || ''}` : ''}`;
133
+ const terminals = rows => new Set(rows.filter(row => TERMINAL.has(row?.kind) && validDebt(row)).map(debtId));
134
+ function debts(pending, rows) {
135
+ const out = new Map(), closed = terminals(rows);
136
+ for (const value of Object.values(pending)) if (validDebt(value) && !closed.has(debtId(value))) out.set(debtId(value), value);
137
+ // Recover the crash window after a refused append was fsynced but before its pending rename.
138
+ for (const row of rows) if (row?.kind === 'refused' && validDebt(row) && !closed.has(debtId(row))) out.set(debtId(row), row);
139
+ return [...out.values()];
140
+ }
141
+ function transaction(files, callback, fallback, budget = {}) {
142
+ const f = stateFiles(files);
143
+ return locked([f.pending, f.ledger], owns => callback(f, readPending(f.pending), readLedger(f.ledger), owns), fallback, budget);
144
+ }
145
+ export function recordRefusal({ session, key, policies, ts, host, project, deadlineAt, nonBlocking }, files = {}) {
146
+ if (!validDebt({ session, key, policies, ts })) return false;
147
+ return transaction(files, (f, pending, rows, owns) => {
148
+ const scope = scopeOf(host, project), id = pendingId({ session, key, ...scope });
149
+ if (pending[id] && !validDebt(pending[id])) return false;
150
+ const open = debts(pending, rows);
151
+ const prior = open.find(value => value.session === session && value.key === key && sameScope(value, scope));
152
+ if (open.length >= MAX_PENDING && !prior) return false; // defer; never abandon another owner to make room
153
+ if (prior) appendLocked({ ...prior, kind: 'repeated', ts, refusalId: debtId(prior) }, f.ledger, owns);
154
+ const debt = { session, key, policies, ts, ...scope, refusalId: randomUUID() };
155
+ appendLocked({ ...debt, kind: 'refused' }, f.ledger, owns);
156
+ pending[id] = debt; writePending(f.pending, pending, owns);
102
157
  return true;
103
- } catch { return false; }
158
+ }, false, { deadlineAt, nonBlocking });
104
159
  }
105
-
106
- /**
107
- * Record that a refusal happened, and open a debt that must resolve to exactly one outcome.
108
- * `ts` is passed in rather than read from the clock so the caller owns time and tests are hermetic.
109
- */
110
- export function recordRefusal({ session, key, policies, ts }, files = {}) {
111
- const pendingFile = files.pending || PENDING;
112
- const ledger = files.ledger || LEDGER;
113
- append({ kind: 'refused', session, key, policies, ts }, ledger);
114
- const pending = readJson(pendingFile, {});
115
- pending[`${session}\u0000${key}`] = { session, key, policies, ts };
116
- // Bound it: on overflow the OLDEST debts are resolved as `abandoned` rather than silently dropped,
117
- // because a dropped debt is a miss that never appears in the denominator.
118
- const entries = Object.entries(pending);
119
- if (entries.length > MAX_PENDING) {
120
- const sorted = entries.sort((a, b) => (a[1].ts || 0) - (b[1].ts || 0));
121
- for (const [k, v] of sorted.slice(0, entries.length - MAX_PENDING)) {
122
- append({ kind: 'abandoned', session: v.session, key: v.key, policies: v.policies, ts }, ledger);
123
- delete pending[k];
160
+ export function resolve({ session, key, allowed, ts, host, project, deadlineAt, nonBlocking }, files = {}) {
161
+ if (typeof allowed !== 'boolean' || typeof session !== 'string' || typeof key !== 'string' || !Number.isFinite(ts)) return null;
162
+ return transaction(files, (f, pending, rows, owns) => {
163
+ const scope = scopeOf(host, project), id = pendingId({ session, key, ...scope });
164
+ const debt = debts(pending, rows).find(value => value.session === session && value.key === key && sameScope(value, scope));
165
+ if (!debt) {
166
+ if (validDebt(pending[id]) && terminals(rows).has(debtId(pending[id]))) { delete pending[id]; writePending(f.pending, pending, owns); }
167
+ return null;
124
168
  }
125
- }
126
- writeJson(pendingFile, pending);
169
+ const kind = allowed === true ? 'admitted-retry' : 'repeated';
170
+ appendLocked({ kind, session, key, ...scope, policies: debt.policies, ts, refusalId: debtId(debt), afterMs: ts - debt.ts,
171
+ evidence: 'pretool-decision-observation', repairVerified: false }, f.ledger, owns);
172
+ if (validDebt(pending[id])) delete pending[id];
173
+ writePending(f.pending, pending, owns);
174
+ return kind;
175
+ }, null, { deadlineAt, nonBlocking });
127
176
  }
128
-
129
- /**
130
- * A tool call is happening. If it retries something we refused, close that debt.
131
- * Called by the gate BEFORE it decides, so `allowed` is the verdict it is about to return.
132
- */
133
- export function resolve({ session, key, allowed, ts }, files = {}) {
134
- const pendingFile = files.pending || PENDING;
135
- const ledger = files.ledger || LEDGER;
136
- const pending = readJson(pendingFile, {});
137
- const id = `${session}\u0000${key}`;
138
- const debt = pending[id];
139
- if (!debt) return null;
140
- const kind = allowed ? 'corrected' : 'repeated';
141
- append({ kind, session, key, policies: debt.policies, ts, afterMs: ts - (debt.ts || ts) }, ledger);
142
- delete pending[id];
143
- writeJson(pendingFile, pending);
144
- return kind;
177
+ function closeDebts(files, ts, predicate, kind, budget = {}) {
178
+ return transaction(files, (f, pending, rows, owns) => {
179
+ const selected = debts(pending, rows).filter(predicate);
180
+ for (const debt of selected) {
181
+ appendLocked({ kind, session: debt.session, key: debt.key, host: debt.host || null, projectId: debt.projectId || null,
182
+ policies: debt.policies, ts, refusalId: debtId(debt) }, f.ledger, owns);
183
+ const id = pendingId(debt); if (validDebt(pending[id])) delete pending[id];
184
+ }
185
+ if (selected.length) writePending(f.pending, pending, owns);
186
+ return selected.length;
187
+ }, 0, budget);
145
188
  }
146
-
147
- /**
148
- * Close debts that will never resolve, as `abandoned`.
149
- *
150
- * WITHOUT THIS THE METRIC IS A LIE BY OMISSION. A refusal the model simply walked away from is the
151
- * single most likely outcome — and an unresolved debt sits in `open`, outside the denominator, so
152
- * `correctedRate` would be computed only over the actions someone bothered to retry. That is the
153
- * "record only the wins" fabrication this file's header names, arriving through the back door.
154
- *
155
- * Swept from the gate itself rather than a SessionEnd hook: SessionEnd is not guaranteed to fire (a
156
- * crash, a kill, a compact all skip it), and this repo has already paid for a queue that only drained
157
- * on a graceful exit — ADR-027's heartbeat flush, where 1,884 events accumulated over days. Activity
158
- * is the trigger, not politeness.
159
- */
160
- export function sweepStale({ session, ts, maxAgeMs = 6 * 60 * 60_000 }, files = {}) {
161
- const pendingFile = files.pending || PENDING;
162
- const ledger = files.ledger || LEDGER;
163
- const pending = readJson(pendingFile, {});
164
- let closed = 0;
165
- for (const [k, v] of Object.entries(pending)) {
166
- // A different session is over as far as this process can tell; the current session's debts stay
167
- // open until they resolve or age out, because a retry three calls later is still a real outcome.
168
- const stale = v.session !== session || (ts - (v.ts || ts)) > maxAgeMs;
169
- if (!stale) continue;
170
- append({ kind: 'abandoned', session: v.session, key: v.key, policies: v.policies, ts }, ledger);
171
- delete pending[k];
172
- closed += 1;
173
- }
174
- if (closed) writeJson(pendingFile, pending);
175
- return closed;
189
+ /** A differing session is not evidence of abandonment; only proven observation age expires debt. */
190
+ export function sweepStale({ session, ts, maxAgeMs = 6 * 60 * 60_000, deadlineAt, nonBlocking }, files = {}) {
191
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0 || !Number.isFinite(ts)) return 0;
192
+ return closeDebts(files, ts, debt => ts - debt.ts > maxAgeMs, 'expired', { deadlineAt, nonBlocking });
176
193
  }
177
-
178
- /** Close every open debt for a session as `abandoned`. Called at SessionEnd. */
179
- export function abandonSession(session, ts, files = {}) {
180
- const pendingFile = files.pending || PENDING;
181
- const ledger = files.ledger || LEDGER;
182
- const pending = readJson(pendingFile, {});
183
- let closed = 0;
184
- for (const [k, v] of Object.entries(pending)) {
185
- if (v.session !== session) continue;
186
- append({ kind: 'abandoned', session, key: v.key, policies: v.policies, ts }, ledger);
187
- delete pending[k];
188
- closed += 1;
189
- }
190
- if (closed) writeJson(pendingFile, pending);
191
- return closed;
194
+ /** Explicit session closure only affects that named session; not an automatic lifecycle delivery claim. */
195
+ export function abandonSession(session, ts, files = {}, scopeInput = {}) {
196
+ if (!Number.isFinite(ts)) return 0;
197
+ let scope; try { scope = scopeOf(scopeInput.host, scopeInput.project); } catch { return 0; }
198
+ return closeDebts(files, ts, debt => debt.session === session && sameScope(debt, scope), 'abandoned', scopeInput);
192
199
  }
193
-
194
- /**
195
- * The report. Every refusal must appear in exactly one terminal bucket, and `open` is stated
196
- * separately so a half-finished session cannot inflate the rate by shrinking the denominator.
197
- */
198
200
  export function report(files = {}) {
199
- const ledger = files.ledger || LEDGER;
200
- const pendingFile = files.pending || PENDING;
201
- let lines = [];
202
- try { lines = fs.readFileSync(ledger, 'utf8').split('\n').filter(Boolean); } catch { /* none yet */ }
203
- const rows = lines.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
204
- const count = (k) => rows.filter((r) => r.kind === k).length;
205
- const refused = count('refused');
206
- const corrected = count('corrected');
207
- const repeated = count('repeated');
208
- const abandoned = count('abandoned');
209
- const resolved = corrected + repeated + abandoned;
210
- const open = Object.keys(readJson(pendingFile, {})).length;
211
- const byPolicy = {};
212
- for (const r of rows) {
213
- if (!['corrected', 'repeated', 'abandoned'].includes(r.kind)) continue;
214
- for (const p of r.policies || []) {
215
- byPolicy[p] = byPolicy[p] || { corrected: 0, repeated: 0, abandoned: 0 };
216
- byPolicy[p][r.kind] += 1;
201
+ const unavailable = { available: false, metricBasis: 'pretool-admission-only', admittedRetryRate: null,
202
+ correctedRate: null, repairSuccessRate: null, reason: 'decision measurement busy, full or unreadable' };
203
+ return transaction(files, (f, pending, rows) => {
204
+ const count = kind => rows.filter(row => row?.kind === kind).length;
205
+ const legacyCorrectedRecords = count('corrected');
206
+ const admittedRetries = count('admitted-retry') + legacyCorrectedRecords;
207
+ const repeated = count('repeated'), abandoned = count('abandoned'), expired = count('expired');
208
+ const resolved = admittedRetries + repeated + abandoned + expired, open = debts(pending, rows).length;
209
+ const byPolicy = Object.create(null);
210
+ for (const row of rows) {
211
+ if (!TERMINAL.has(row?.kind)) continue;
212
+ const field = ['corrected', 'admitted-retry'].includes(row.kind) ? 'admittedRetries' : row.kind;
213
+ for (const policy of Array.isArray(row.policies) ? row.policies : []) {
214
+ byPolicy[policy] ||= { admittedRetries: 0, repeated: 0, abandoned: 0, expired: 0 }; byPolicy[policy][field]++;
215
+ }
217
216
  }
218
- }
219
- return {
220
- refused,
221
- resolved,
222
- open,
223
- corrected,
224
- repeated,
225
- abandoned,
226
- // NULL, not 0, when nothing has resolved. A rate printed as 0% on an empty ledger is a claim the
227
- // guards are failing; the truth is that nothing has been measured yet.
228
- correctedRate: resolved ? +(corrected / resolved).toFixed(3) : null,
229
- byPolicy,
230
- };
217
+ const atCapacity = open >= MAX_PENDING || (regular(f.ledger)?.size || 0) >= MAX_LEDGER_BYTES;
218
+ return { available: !atCapacity, completeCoverage: false, metricBasis: 'pretool-admission-only', refused: count('refused'), resolved, open,
219
+ admittedRetries, legacyCorrectedRecords, repeated, abandoned, expired, byPolicy, atCapacity,
220
+ foreignPendingRecords: Object.values(pending).filter(value => !validDebt(value)).length,
221
+ admittedRetryRate: !atCapacity && resolved ? +(admittedRetries / resolved).toFixed(3) : null,
222
+ correctedRate: null, repairSuccessRate: null };
223
+ }, unavailable);
231
224
  }
@@ -41,7 +41,7 @@
41
41
  import fs from 'node:fs';
42
42
  import os from 'node:os';
43
43
  import path from 'node:path';
44
- import { spawn } from 'node:child_process';
44
+ import { spawn, spawnSync } from 'node:child_process';
45
45
  import { fileURLToPath } from 'node:url';
46
46
 
47
47
  const SELF = fileURLToPath(import.meta.url);
@@ -73,7 +73,8 @@ function receipt(row) {
73
73
  const p = receiptPath();
74
74
  fs.mkdirSync(path.dirname(p), { recursive: true });
75
75
  fs.appendFileSync(p, `${JSON.stringify({ ts: new Date().toISOString(), ...row })}\n`);
76
- } catch { /* a maintenance job must not die because its log directory is read-only */ }
76
+ return true;
77
+ } catch { return false; }
77
78
  }
78
79
 
79
80
  /** Open the job's log, or /dev/null. Never throws — an unwritable log is not a reason to skip work. */
@@ -149,6 +150,8 @@ if (!SUPERVISOR) {
149
150
  // ── SUPERVISOR HALF (already outside the session's process group). Runs the real job, holds the
150
151
  // clock, and is the only thing that can end it early.
151
152
  const out = openLog();
153
+ // Keep an unresolved retirement requirement if any later receipt cannot be written.
154
+ if (!receipt({ state: 'launching', supervisorPid: process.pid, ttlSec, cmd, retirementRequired: true })) process.exit(0);
152
155
  const job = spawn(cmd[0], cmd.slice(1), {
153
156
  detached: true, // its own group again, so the TTL kill reaches ITS children too (npm, git, node)
154
157
  stdio: ['ignore', out, out],
@@ -157,26 +160,63 @@ const job = spawn(cmd[0], cmd.slice(1), {
157
160
  });
158
161
 
159
162
  let killedAtTtl = false;
160
- receipt({ state: 'started', pid: job.pid ?? null, ttlSec, cmd });
163
+ let finished = false;
164
+ let grace;
165
+ const probe = (group = process.platform !== 'win32') => {
166
+ if (!job.pid) return { state: 'unknown', error: 'missing-job-pid' };
167
+ try { process.kill(group ? -job.pid : job.pid, 0); return { state: 'alive' }; }
168
+ catch (error) { return error.code === 'ESRCH' ? { state: 'gone' } : { state: 'unknown', error: error.code || 'probe-failed' }; }
169
+ };
170
+ const finish = (state, detail = {}) => {
171
+ if (finished) return;
172
+ finished = true; clearTimeout(deadline); clearTimeout(grace);
173
+ // Group absence cannot certify descendants that escaped into another group. Keep that fence.
174
+ receipt({ state, pid: job.pid ?? null, supervisorPid: process.pid, ttlSec, cmd,
175
+ retirementRequired: true, retirementConfirmed: false, retirementState: 'UNKNOWN',
176
+ scope: process.platform === 'win32' ? 'windows-taskkill-tree-attempt' : 'posix-process-group', ...detail });
177
+ process.exit(0);
178
+ };
179
+ receipt({ state: 'started', pid: job.pid ?? null, supervisorPid: process.pid, ttlSec, cmd, retirementRequired: true });
161
180
 
162
181
  const deadline = setTimeout(() => {
163
182
  killedAtTtl = true;
164
- try { process.kill(-job.pid, 'SIGTERM'); } catch { /* already gone */ }
165
- setTimeout(() => {
166
- try { process.kill(-job.pid, 'SIGKILL'); } catch { /* already gone */ }
167
- receipt({ state: 'killed-at-ttl', pid: job.pid ?? null, ttlSec, cmd });
168
- process.exit(0);
169
- }, GRACE_MS).unref();
183
+ if (process.platform === 'win32') {
184
+ if (job.exitCode !== null || job.signalCode !== null) return finish('retirement-unknown', { reason: 'parent-exited; descendant identity unavailable', rootObservation: probe(false) });
185
+ const killed = spawnSync('taskkill', ['/PID', String(job.pid), '/T', '/F'], {
186
+ timeout: 1000, windowsHide: true, encoding: 'utf8', maxBuffer: 16 * 1024 });
187
+ return finish('retirement-unknown', { reason: 'complete Windows descendant retirement not observed',
188
+ taskkill: { status: killed.status, error: killed.error?.code || null }, rootObservation: probe(false) });
189
+ }
190
+ const errors = [];
191
+ const signal = name => {
192
+ try { process.kill(-job.pid, name); }
193
+ catch (error) { if (error.code !== 'ESRCH') errors.push({ signal: name, error: error.code || 'kill-failed' }); }
194
+ };
195
+ signal('SIGTERM');
196
+ grace = setTimeout(() => {
197
+ if (probe().state !== 'gone') signal('SIGKILL');
198
+ // Ref'd timers retain supervision after the direct parent exits; observe native reaping too.
199
+ grace = setTimeout(() => {
200
+ const groupObservation = probe();
201
+ finish(groupObservation.state === 'gone' && !errors.length ? 'group-retired-at-ttl' : 'retirement-unknown',
202
+ { groupObservation, groupRetirementConfirmed: groupObservation.state === 'gone', killErrors: errors,
203
+ reason: 'escaped or unobserved descendants are not certified' });
204
+ }, 100);
205
+ }, GRACE_MS);
170
206
  }, ttlSec * 1000);
171
207
 
172
208
  job.on('error', (e) => {
173
209
  clearTimeout(deadline);
174
- receipt({ state: 'spawn-failed', pid: null, ttlSec, cmd, detail: e.message });
210
+ receipt({ state: 'spawn-failed', pid: null, ttlSec, cmd, detail: e.message, retirementRequired: false, launched: false });
175
211
  process.exit(0);
176
212
  });
177
213
  job.on('exit', (code, signal) => {
178
- clearTimeout(deadline);
179
214
  if (killedAtTtl) return; // the TTL path writes its own, more specific receipt
180
- receipt({ state: 'exited', pid: job.pid ?? null, code, signal, cmd });
181
- process.exit(0);
215
+ receipt({ state: 'parent-exited', pid: job.pid ?? null, code, signal, cmd, retirementRequired: true, retirementConfirmed: false });
216
+ if (process.platform === 'win32') return finish('retirement-unknown', { reason: 'parent exit does not establish descendant retirement', rootObservation: probe(false) });
217
+ const groupObservation = probe();
218
+ if (groupObservation.state === 'gone') finish('group-retired', { code, signal, groupObservation, groupRetirementConfirmed: true,
219
+ reason: 'escaped or unobserved descendants are not certified' });
220
+ else if (groupObservation.state === 'unknown') finish('retirement-unknown', { code, signal, groupObservation });
221
+ // A surviving owned group stays supervised until the original TTL.
182
222
  });
@@ -0,0 +1,38 @@
1
+ // Only closed, attributed npx installer entries qualify. Unknown/project/app/runtime trees never do.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ const INSTALLERS = new Set(['ruvnet-brain', 'ruflo']);
5
+ export function cleanupNpxDuplicates({ home, globalRoot, enabled = false, run, active = null }) {
6
+ const root = path.join(home, '.npm/_npx'), result = { enabled, removed: [], retained: [] };
7
+ if (!fs.existsSync(root)) return result;
8
+ if (fs.realpathSync(root) !== path.resolve(root)) { result.retained.push({ directory: root, reason: 'cache root alias is unclassified' }); return result; }
9
+ for (const name of fs.readdirSync(root)) {
10
+ const directory = path.join(root, name);
11
+ if (!/^[a-f0-9]{16,64}$/.test(name) || !fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) continue;
12
+ if (fs.readdirSync(directory).some(entry => !['package.json', 'package-lock.json', 'node_modules'].includes(entry))) { result.retained.push({ directory, reason: 'unknown cache content' }); continue; }
13
+ let manifest;
14
+ try { manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')); } catch { result.retained.push({ directory, reason: 'unknown installer identity' }); continue; }
15
+ const dependencies = Object.keys(manifest.dependencies || {});
16
+ if (!dependencies.length || dependencies.some(pkg => !INSTALLERS.has(pkg) || !fs.existsSync(path.join(globalRoot, pkg, 'package.json')))) continue;
17
+ if (!enabled) { result.retained.push({ directory, reason: 'cleanup disabled' }); continue; }
18
+ let busy = true;
19
+ try {
20
+ if (active) busy = active(directory);
21
+ else {
22
+ const processes = run('ps', ['-axo', 'command='], { timeout: 15_000 });
23
+ if (processes.includes(directory)) busy = true;
24
+ else {
25
+ // lsof exit 1 means no files; any other failure is unknown and retains the tree.
26
+ const opened = run('lsof', ['-nP', '+D', directory], { timeout: 15_000, allowed: [0, 1] });
27
+ busy = opened.trim().length > 0;
28
+ }
29
+ }
30
+ } catch { busy = true; }
31
+ if (busy) { result.retained.push({ directory, reason: 'live or unknown process/open-file owner' }); continue; }
32
+ // Recheck exact directory type and manifest bytes at the removal boundary.
33
+ const second = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
34
+ if (JSON.stringify(second) !== JSON.stringify(manifest) || fs.lstatSync(directory).isSymbolicLink()) throw Error('npx cleanup identity changed');
35
+ fs.rmSync(directory, { recursive: true }); result.removed.push({ directory, installers: dependencies });
36
+ }
37
+ return result;
38
+ }
@@ -0,0 +1,30 @@
1
+ // Directory ownership protocol shared with agentic-kit. Inherited children do not release the parent.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ const brainRoot = brainHome => brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache/ruvnet-brain');
7
+ export function sharedLockStatus({ brainHome, alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } } = {}) {
8
+ const directory = path.join(brainRoot(brainHome), 'developer-update.lock');
9
+ try {
10
+ if (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) throw Error('not an owned directory');
11
+ const owner = JSON.parse(fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'));
12
+ if (!Number.isInteger(owner.pid) || owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length < 16) throw Error('invalid owner');
13
+ return { state: alive(owner.pid) ? 'running' : 'stale', directory, owner };
14
+ } catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message }; }
15
+ }
16
+ export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive } = {}) {
17
+ const status = sharedLockStatus({ brainHome, alive });
18
+ if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
19
+ if (status.state !== 'idle') throw Error(`developer update lock ${status.state}; ${status.directory}`);
20
+ fs.mkdirSync(path.dirname(status.directory), { recursive: true });
21
+ fs.mkdirSync(status.directory, { mode: 0o700 }); // atomic claim; racing owners get EEXIST
22
+ const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString() };
23
+ try { fs.writeFileSync(path.join(status.directory, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 }); }
24
+ catch (error) { fs.rmdirSync(status.directory); throw error; }
25
+ return { token: owner.token, ownerPid: pid, inherited: false, release() {
26
+ const current = sharedLockStatus({ brainHome, alive });
27
+ if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
28
+ fs.unlinkSync(path.join(status.directory, 'owner.json')); fs.rmdirSync(status.directory);
29
+ } };
30
+ }