ruvnet-brain 4.5.16 → 4.5.17

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 (57) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +1 -1
  3. package/config/practical-rule-catalog.json +111 -0
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/hooks/codex-hooks.json +13 -1
  8. package/plugin/hooks/hook-contracts.json +20 -4
  9. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  10. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  11. package/plugin/scripts/agentdb-recall.mjs +161 -26
  12. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  13. package/plugin/scripts/capability-registry.mjs +55 -114
  14. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  15. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  16. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  17. package/plugin/scripts/continuity-brief.mjs +55 -22
  18. package/plugin/scripts/continuity-events.mjs +93 -17
  19. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  20. package/plugin/scripts/continuity-journal.mjs +77 -29
  21. package/plugin/scripts/decision-gate.mjs +63 -22
  22. package/plugin/scripts/decision-outcomes.mjs +192 -199
  23. package/plugin/scripts/detach.mjs +53 -13
  24. package/plugin/scripts/doc-currency.mjs +993 -0
  25. package/plugin/scripts/ground-ruvnet.sh +16 -3
  26. package/plugin/scripts/grounding-answer.mjs +18 -16
  27. package/plugin/scripts/grounding-stamp.sh +3 -0
  28. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  29. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  30. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  31. package/plugin/scripts/hook-context-budget.mjs +90 -0
  32. package/plugin/scripts/hook-registry.mjs +129 -1
  33. package/plugin/scripts/hook-shim.mjs +20 -2
  34. package/plugin/scripts/lesson-gate.mjs +1 -1
  35. package/plugin/scripts/project-capture-queue.mjs +22 -15
  36. package/plugin/scripts/project-progression-producer.mjs +15 -8
  37. package/plugin/scripts/project-progression-reader.mjs +14 -4
  38. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  39. package/plugin/scripts/project-progression-sources.mjs +24 -10
  40. package/plugin/scripts/project-progression-store.mjs +7 -2
  41. package/plugin/scripts/project-store-resolver.mjs +10 -7
  42. package/plugin/scripts/project-transition-hook.mjs +20 -12
  43. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  44. package/plugin/scripts/session-start-budget.mjs +18 -0
  45. package/plugin/scripts/session-start-core.mjs +32 -6
  46. package/plugin/scripts/session-start-trace.mjs +2 -1
  47. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  48. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  49. package/plugin/scripts/user-settings.mjs +7 -1
  50. package/scripts/doc-currency.mjs +6 -990
  51. package/scripts/hook-qualify-core.mjs +1 -1
  52. package/scripts/model-managed-prompt.mjs +1 -1
  53. package/scripts/model-managed-workflow-service.mjs +113 -14
  54. package/scripts/model-routing-controller.mjs +112 -21
  55. package/scripts/practical-rule-selector.mjs +51 -0
  56. package/scripts/product-integrity-contract.mjs +11 -4
  57. package/scripts/source-scope-receipt.mjs +9 -4
@@ -18,6 +18,7 @@
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { spawn } from 'node:child_process';
21
+ import { contextBlock, contextFrame, readContextFrame, emitOwnedContext } from './hook-context-budget.mjs';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  // resolveBash ONLY. `skipNoBash` is not a predicate — it is a one-time notice emitter that returns 0
23
24
  // and WRITES TO STDERR, which on this hot path is the refusal channel: calling it would have injected
@@ -240,6 +241,36 @@ export function decide(verdicts) {
240
241
  return { allow: false, refusals: refusals.map((v) => v.id), reason };
241
242
  }
242
243
 
244
+ /** Policy advice is context, separate from the winning refusal and the speech channel. */
245
+ export function policyContext(results, speechOutput = '') {
246
+ const contexts = [];
247
+ for (const result of results) {
248
+ if (result.skipped) contexts.push(`Policy ${result.id} did not obtain a verdict (${result.skipped}${result.reason ? `: ${String(result.reason).slice(0, 4096)}` : ''}).`);
249
+ if (result.code !== ALLOW || !result.stdout?.trim()) continue;
250
+ let parsed; try { parsed = JSON.parse(result.stdout); } catch {}
251
+ const text = typeof parsed?.hookSpecificOutput?.additionalContext === 'string'
252
+ ? parsed.hookSpecificOutput.additionalContext : result.stdout.trim();
253
+ if (text) contexts.push(text.slice(0, 4096));
254
+ }
255
+ if (process.env.RUVNET_HOOK_CONTEXT_BUDGET === '1') {
256
+ const speechFrame = readContextFrame(speechOutput);
257
+ const blocks = contexts.map((text) => contextBlock(text, { id: 'policy-diagnostic', critical: true }));
258
+ if (speechFrame) blocks.push(...speechFrame.blocks);
259
+ else if (speechOutput.trim()) { let value; try { value = JSON.parse(speechOutput); } catch {}
260
+ const text = value?.hookSpecificOutput?.additionalContext || speechOutput.trim();
261
+ blocks.push(contextBlock(text, { id: 'unframed-speech-preserved', critical: true })); }
262
+ if (!blocks.length) return '';
263
+ return emitOwnedContext(contextFrame('decision-gate', 'PreToolUse', blocks));
264
+ }
265
+ if (!contexts.length) return speechOutput;
266
+ let speech; try { speech = JSON.parse(speechOutput); } catch {}
267
+ const prior = typeof speech?.hookSpecificOutput?.additionalContext === 'string'
268
+ ? speech.hookSpecificOutput.additionalContext : speechOutput.trim();
269
+ return JSON.stringify({ ...(speech && typeof speech === 'object' && !Array.isArray(speech) ? speech : {}),
270
+ hookSpecificOutput: { ...(speech?.hookSpecificOutput || {}), hookEventName: 'PreToolUse',
271
+ additionalContext: [...new Set([prior, ...contexts].filter(Boolean))].join('\n') } });
272
+ }
273
+
243
274
  const firstLine = (s) => String(s || '').trim().split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
244
275
 
245
276
  /** Payload accessors — tolerant, because a malformed payload must degrade to "no measurement". */
@@ -263,6 +294,8 @@ function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : '
263
294
  if (isMain()) {
264
295
  const started = Date.now();
265
296
  const payload = normalizePayloadText(readPayload());
297
+ const shape = classifyPayload(payload);
298
+ const session = sessionOf(payload) || (shape.truncated ? shape.session : '');
266
299
  const selected = policiesFor(EVENT);
267
300
  // An unknown event is not an occasion to refuse anything. Same rule as unprompted-runtime's
268
301
  // "never speak on a guess", pointed at the other decision. 'write' is the only registered route
@@ -270,7 +303,7 @@ if (isMain()) {
270
303
  if (!selected.length && EVENT !== 'write') process.exit(ALLOW);
271
304
 
272
305
  const budgetMs = Number(process.env.RUVNET_DECISION_BUDGET_MS) || DEFAULT_BUDGET_MS;
273
- const deadline = started + budgetMs;
306
+ const deadline = Math.min(started + budgetMs, Number(process.env.RUVNET_DECISION_DEADLINE) || Infinity);
274
307
  // Resolved ONCE. On win32 resolveBash() can shell out to `where.exe`; four bash policies meant up
275
308
  // to four of those per tool call, for an answer that cannot change mid-invocation.
276
309
  BASH = resolveBash();
@@ -302,34 +335,34 @@ if (isMain()) {
302
335
 
303
336
  const decision = decide(verdicts);
304
337
 
305
- // ── OBEDIENCE MEASUREMENT (ADR-067 §outcomes) ──────────────────────────────────────────────────
306
- // This gate is the ONLY thing that sees every Write/Edit/Bash, so it can close the loop with no new
307
- // hook: resolve first (did this call retry something we refused?), then open a new debt if we are
308
- // about to refuse. Order matters — resolving after recording would close the debt we just opened.
309
- // Entirely best-effort: measurement may never affect the verdict, so it runs after `decide`.
310
- // An oversize payload is cut mid-JSON (see TRANSPORT_CAP_BYTES): recover session/tool/path from its
311
- // prefix so its refusals and allows are counted like any other.
312
- const shape = classifyPayload(payload);
313
- const session = sessionOf(payload) || (shape.truncated ? shape.session : '');
338
+ // Measurement follows the FINAL gate verdict; it must never delay a blocking producer.
339
+ const measureFinalDecision = (allowed, policies = decision.refusals) => {
340
+ const metricDeadline = Math.min(deadline, Date.now() + 25);
314
341
  try {
315
342
  const key = shape.truncated
316
343
  ? actionKey(shape.tool, { file_path: shape.filePath })
317
344
  : actionKey(payloadTool(payload), toolInput);
318
345
  const ts = Date.now();
319
346
  if (session && key) {
320
- sweepStale({ session, ts }); // debts from dead sessions become `abandoned`, never vanish
321
- resolveOutcome({ session, key, allowed: decision.allow, ts });
322
- if (!decision.allow) recordRefusal({ session, key, policies: decision.refusals, ts });
347
+ let observed = {}; try { observed = JSON.parse(payload); } catch { /* truncated payload: adapter/process scope */ }
348
+ const attribution = { host: observed.host || process.env.RUVNET_HOOK_HOST || 'claude',
349
+ project: observed.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd() };
350
+ sweepStale({ session, ts, deadlineAt: metricDeadline, nonBlocking: true }); // only proven observation age expires debt; foreign sessions are not abandoned
351
+ resolveOutcome({ session, key, allowed: allowed, ts, ...attribution, deadlineAt: metricDeadline, nonBlocking: true });
352
+ if (!allowed) recordRefusal({ session, key, policies: policies, ts, ...attribution, deadlineAt: metricDeadline, nonBlocking: true });
323
353
  }
324
354
  } catch { /* a ledger must never break a tool call */ }
325
355
 
356
+ };
357
+
326
358
  reportSelfSkipped({ session, selfSkipped });
327
359
  if (decision.allow && shape.truncated) {
328
360
  // OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
329
361
  try { appendOutcome({ kind: 'payload-oversize', event: EVENT, session, bytesSeen: shape.bytes, capBytes: TRANSPORT_CAP_BYTES, tool: shape.tool, filePath: shape.filePath, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
330
362
  }
331
363
  if (!decision.allow) {
332
- reportBudget({ session, unconsulted, trace, started, budgetMs });
364
+ measureFinalDecision(false);
365
+ reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
333
366
  process.stderr.write(`${decision.reason}\n`);
334
367
  process.exit(REFUSE);
335
368
  }
@@ -347,15 +380,21 @@ if (isMain()) {
347
380
  const speech = await runPolicy(SPEECH, payload, deadline, speechEventFor(EVENT), trace);
348
381
  if (speech.skipped === 'budget') unconsulted.push(SPEECH.id);
349
382
  if (speech.code === REFUSE) {
350
- reportBudget({ session, unconsulted, trace, started, budgetMs });
383
+ measureFinalDecision(false, [...decision.refusals, SPEECH.id]);
384
+ reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
351
385
  process.stderr.write(`${(speech.stderr || '').trim()}\n`);
352
386
  process.exit(REFUSE);
353
387
  }
354
388
  reportBudget({ session, unconsulted, trace, started, budgetMs });
355
- if (speech.stdout?.trim()) process.stdout.write(speech.stdout);
389
+ measureFinalDecision(true);
390
+ const context = policyContext(results, speech.stdout || '');
391
+ if (context) process.stdout.write(context);
356
392
  process.exit(ALLOW);
357
393
  }
358
394
  reportBudget({ session, unconsulted, trace, started, budgetMs });
395
+ measureFinalDecision(true);
396
+ const context = policyContext([...results, ...unconsulted.filter((id) => !results.some((r) => r.id === id && r.skipped)).map((id) => ({ id, skipped: 'budget' }))]);
397
+ if (context) process.stdout.write(context);
359
398
  process.exit(ALLOW);
360
399
  }
361
400
 
@@ -378,7 +417,7 @@ if (isMain()) {
378
417
  * the applicability skip, an ordinary write measures ~360ms against a 4000ms budget, so a trip
379
418
  * there is not noise to be tolerated — it is the defect, and the suite should go red for it.
380
419
  */
381
- function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
420
+ function reportBudget({ session, unconsulted, trace, started, budgetMs, refused = false }) {
382
421
  const elapsed = Date.now() - started;
383
422
  if (process.env.RUVNET_DECISION_TRACE === '1') {
384
423
  process.stderr.write(`[decision-gate] ${EVENT} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
@@ -388,8 +427,8 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
388
427
  appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
389
428
  } catch { /* a ledger must never break a tool call */ }
390
429
  process.stderr.write(
391
- `[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ALLOWED WITHOUT CONSULTING: `
392
- + `${unconsulted.join(', ')}. These policies did not vote; this allow is a timeout, not a verdict.\n`,
430
+ `[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
431
+ + `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
393
432
  );
394
433
  }
395
434
 
@@ -397,7 +436,7 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
397
436
  function reportSelfSkipped({ session, selfSkipped }) {
398
437
  for (const r of selfSkipped) {
399
438
  try { appendOutcome({ kind: 'policy-skipped', event: EVENT, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
400
- process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; this allow is not its verdict.\n`);
439
+ process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
401
440
  }
402
441
  }
403
442
 
@@ -434,12 +473,13 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
434
473
  const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolve(done(r)); };
435
474
  let child;
436
475
  try {
437
- child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1', RUVNET_DECISION_DEADLINE: String(deadline) } });
476
+ child = spawn(cmd, args, { detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1', RUVNET_DECISION_DEADLINE: String(deadline) } });
438
477
  } catch { return finish({ id: p.id, skipped: 'spawn' }); }
439
478
  // SIGKILL, not SIGTERM: a bash policy that has spawned its own child (jq, node, ruflo) can sit in
440
479
  // a TERM handler, and the host's own kill is what we are racing. The whole batch shares ONE
441
480
  // deadline, so a single slow policy cancels only the time it actually consumed.
442
- timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } finish({ id: p.id, skipped: 'budget' }); }, left);
481
+ const retireTree = () => { try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch { /* already gone */ } };
482
+ timer = setTimeout(() => { retireTree(); finish({ id: p.id, skipped: 'budget' }); }, Math.max(1, left - 100));
443
483
  let stdout = ''; let stderr = ''; let bytes = 0;
444
484
  const MAX = 1 << 20; // same ceiling spawnSync's maxBuffer enforced; a policy is not a data source
445
485
  child.stdout.on('data', (d) => { if (bytes < MAX) { stdout += d; bytes += d.length; } });
@@ -449,6 +489,7 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
449
489
  // Unhandled, that error event would take the whole gate down and turn an allow into a hook error.
450
490
  child.stdin.on('error', () => { /* the child did not want the payload; that is not a failure */ });
451
491
  child.on('close', (code) => {
492
+ retireTree();
452
493
  // A spawn failure, a timeout, or any code other than 0/2 is an ERROR — and an error here must
453
494
  // never be mistaken for a refusal. That distinction is the one lesson-gate.mjs had to learn twice.
454
495
  if (code === SKIPPED_SELF) return finish({ id: p.id, skipped: 'self', reason: firstLine(stderr) });
@@ -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
  }