ruvnet-brain 4.3.36 → 4.3.38

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 (46) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +32 -9
  3. package/kb/forge-update.mjs +1867 -0
  4. package/package.json +3 -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 +6 -1
  8. package/plugin/hooks/hook-contracts.json +11 -9
  9. package/plugin/scripts/capability-claim-evidence.mjs +11 -2
  10. package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
  11. package/plugin/scripts/completion-claim-evidence.mjs +262 -0
  12. package/plugin/scripts/continuation-gate.mjs +105 -21
  13. package/plugin/scripts/continuation-objective.mjs +15 -0
  14. package/plugin/scripts/continuity-hook-policy.mjs +10 -3
  15. package/plugin/scripts/decision-gate.mjs +22 -2
  16. package/plugin/scripts/duplicate-gate.mjs +503 -0
  17. package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
  18. package/plugin/scripts/grounding-turn-gate.mjs +77 -25
  19. package/plugin/scripts/grounding-turn-mark.mjs +61 -14
  20. package/plugin/scripts/hook-input.mjs +15 -0
  21. package/plugin/scripts/hook-shim.mjs +3 -1
  22. package/plugin/scripts/host-update.mjs +45 -0
  23. package/plugin/scripts/nightly-scheduler.mjs +34 -0
  24. package/plugin/scripts/session-snapshot-hook.mjs +11 -1
  25. package/plugin/scripts/session-start-budget.mjs +1 -0
  26. package/plugin/scripts/session-start-core.mjs +15 -2
  27. package/plugin/scripts/session-start-health.mjs +101 -1
  28. package/plugin/scripts/session-start-update-plane.mjs +71 -1
  29. package/plugin/scripts/turn-outcome-capture.mjs +292 -0
  30. package/scripts/completion-claim-replay.mjs +114 -0
  31. package/scripts/corpus-canary.mjs +399 -0
  32. package/scripts/corpus-dispatch-decision.mjs +2 -2
  33. package/scripts/corpus-promotion.mjs +49 -0
  34. package/scripts/corpus-reconcile.mjs +27 -3
  35. package/scripts/corpus-watchdog.mjs +45 -6
  36. package/scripts/derive-passage-content-map.mjs +71 -0
  37. package/scripts/duplicate-gate-replay.mjs +98 -0
  38. package/scripts/grounding-turn-replay.mjs +131 -0
  39. package/scripts/nightly-watchdog.mjs +3 -3
  40. package/scripts/public-verification-inputs.mjs +24 -2
  41. package/scripts/release-transaction-provider.mjs +29 -6
  42. package/scripts/release.mjs +177 -74
  43. package/scripts/retrieval-canary.mjs +41 -4
  44. package/scripts/retrieval-passage-identity.mjs +58 -0
  45. package/scripts/sync-census.mjs +0 -0
  46. package/scripts/wired-check.mjs +6 -0
@@ -41,13 +41,17 @@ import fs from 'node:fs';
41
41
  import path from 'node:path';
42
42
  import os from 'node:os';
43
43
  import crypto from 'node:crypto';
44
- import { readStdinBounded } from './hook-input.mjs';
44
+ import { readStopHookInput } from './hook-input.mjs';
45
45
  import {
46
46
  auditCapabilityClaims,
47
47
  buildCapabilityInventoryReceipt,
48
48
  } from './capability-inventory-receipt.mjs';
49
49
  import { auditCurrentCapabilityEvidence } from './capability-claim-evidence.mjs';
50
- import { continuationProjectIdentity, authorizedContinuationObjective } from './continuation-objective.mjs';
50
+ import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems } from './continuation-objective.mjs';
51
+ import {
52
+ auditCompletionClaims, readClaudeTurn, extractCommitments, claimClosesPromise,
53
+ PROMISE_KIND, PROMISE_CAP_OPEN,
54
+ } from './completion-claim-evidence.mjs';
51
55
 
52
56
  const HOME = os.homedir();
53
57
 
@@ -219,6 +223,12 @@ if (has('--done')) {
219
223
  // EXACT text match only (GPT-5.6-Sol review). The earlier "unambiguous substring" fallback could still
220
224
  // clear a SINGLETON open item via a fragment — a fake-completion valve under a gate that now applies real
221
225
  // continuation pressure. Marking done requires the item's exact text (copy it from the ledger line).
226
+ // A captured PROMISE never closes by being declared done — only a later answer whose completion
227
+ // claim passed the post-change verification rule closes it (see promiseBookkeeping below).
228
+ if (led.items.some((i) => !i.done && i.text === needle && i.kind === PROMISE_KIND)) {
229
+ console.error('refused: a captured promise closes only with verification evidence at Stop (a completion claim with a check run after the last change), never by --done');
230
+ process.exit(2);
231
+ }
222
232
  const targets = led.items.filter((i) => !i.done && i.text === needle);
223
233
  for (const i of targets) { i.done = true; i.doneAt = new Date().toISOString(); }
224
234
  save(led);
@@ -307,21 +317,7 @@ if (has('--clear')) { save({ items: [] }); console.log('ledger cleared'); proces
307
317
  * Never block waiting for stdin: the CLI paths (--commit-to / --done) are invoked from a terminal
308
318
  * with no piped input, and a gate that hangs is worse than a gate that is silent.
309
319
  */
310
- async function readHookInput() {
311
- // Three cases, treated DIFFERENTLY (ADR-043, Fable red-team #1):
312
- // - 'tty' : run bare in a terminal, not as a hook → never force.
313
- // - 'unreadable' : stdin present but read/parse FAILED. `fs.readFileSync(0)` throws EAGAIN
314
- // intermittently on macOS — a real footgun. The old code returned {} here, which
315
- // under a forcing gate LAUNDERS a read error into a fresh-stop verdict → a forced
316
- // loop. We must not force when we could not confirm the payload.
317
- // - 'stdin' : a payload we actually parsed → the only case allowed to force.
318
- if (process.stdin.isTTY) return { __source: 'tty' };
319
- try {
320
- const raw = (await readStdinBounded()).toString('utf8');
321
- return { ...JSON.parse(raw || '{}'), __source: 'stdin' };
322
- } catch { return { __source: 'unreadable' }; }
323
- }
324
- const hookInput = await readHookInput();
320
+ const hookInput = await readStopHookInput(); // shared with grounding-turn-gate: hook-input.mjs (ADR-043)
325
321
 
326
322
  // LOOP-SAFETY 1 (ADR-043 / Fable #1) — only an affirmatively-parsed hook payload may force. A 'tty' or
327
323
  // 'unreadable' source cannot be confirmed a fresh stop, so it never forces.
@@ -332,9 +328,8 @@ if (hookInput.__source !== 'stdin') process.exit(EXIT_ALLOW);
332
328
  * continuing because of a stop hook (verified against code.claude.com/docs/en/hooks.md, ADR-043).
333
329
  * Honouring it caps each natural-stop episode at EXACTLY ONE forced continuation. Truthy, not
334
330
  * `=== true`, so a future string/number drift ("true", 1) cannot slip past into a loop.
331
+ * (The check itself now sits just after promise bookkeeping, below — still before any request.)
335
332
  */
336
- if (hookInput.stop_hook_active) process.exit(EXIT_ALLOW);
337
-
338
333
  if (hookInput.hook_event_name !== 'Stop' || hookInput.interrupted || hookInput.cancelled) process.exit(EXIT_ALLOW);
339
334
  const projectIdentity = continuationProjectIdentity(hookInput.cwd);
340
335
  if (!projectIdentity) process.exit(EXIT_ALLOW);
@@ -342,6 +337,73 @@ LEDGER = process.env.RUVNET_WORK_LEDGER
342
337
  || path.join(HOME, '.config', 'ruvnet-brain', 'work-ledgers', `${projectIdentity.projectId.replace(':', '-')}.json`);
343
338
  const led = load();
344
339
  const nowMs = Date.now();
340
+ const HOST = process.env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : 'claude';
341
+
342
+ /**
343
+ * COMPLETION CLAIMS (ADR-074 class `completion`, owner 2026-09-30: "You can never ever tell me
344
+ * something is done and implemented without having tested it end to end"). Audited ONCE per Stop:
345
+ * the verdict both requests a correction (below, under every loop guard) and is the only evidence
346
+ * that may close a captured promise. Claude transcripts are parsed; Codex's rollout format is not
347
+ * parsed anywhere in this repo, so on Codex only the answer-side half is enforced and the transcript
348
+ * half is reported UNKNOWN (completion-claim-evidence.mjs header). Fails open: a throw is NONE.
349
+ */
350
+ const completion = (() => {
351
+ const message = String(hookInput.last_assistant_message || '');
352
+ if (!message || !hookInput.session_id) return { verdict: 'NONE', claims: [] };
353
+ try {
354
+ const turn = HOST === 'claude' ? readClaudeTurn(hookInput.transcript_path) : null;
355
+ // Claude always sends a JSONL transcript; one we cannot read is OUR failure, and a Stop hook
356
+ // fails open on its own machinery (ADR-074 failure semantics) — never a correction request.
357
+ if (HOST === 'claude' && !turn) return { verdict: 'NONE', claims: [], unreadable: true };
358
+ return auditCompletionClaims(message, { turn, host: HOST });
359
+ } catch { return { verdict: 'NONE', claims: [] }; }
360
+ })();
361
+
362
+ /**
363
+ * PROMISES ("I'll do X next") become ledger items, and a promise closes ONLY on evidence. This is
364
+ * BOOKKEEPING, not forcing, so it runs before the stop_hook_active exit: the turn's real final
365
+ * answer usually arrives on a continued stop, and a promise made there must not be lost. Claude
366
+ * only — closing needs the transcript receipt, which Codex does not have here, and an item that
367
+ * can never close must never be written. Per-turn and total caps; dedupe against open items by
368
+ * normalized text; project + worktree scoped exactly like --commit-to's objective. Fails open.
369
+ */
370
+ function promiseBookkeeping() {
371
+ if (HOST !== 'claude' || !hookInput.session_id) return;
372
+ try {
373
+ const pid = projectIdentity.projectId;
374
+ const at = new Date(nowMs).toISOString();
375
+ let changed = false;
376
+ if (completion.verdict === 'PASS') {
377
+ for (const item of led.items) {
378
+ if (item?.kind !== PROMISE_KIND || item.done || item.projectId !== pid) continue;
379
+ const claim = completion.claims.find((c) => claimClosesPromise(c.text, item.text));
380
+ if (!claim) continue;
381
+ Object.assign(item, { done: true, doneAt: at, completionEvidence: { claim: claim.text,
382
+ checks: completion.verification.checks.slice(-5).map((c) => c.what),
383
+ transcript: String(hookInput.transcript_path || ''), sessionId: hookInput.session_id } });
384
+ changed = true;
385
+ }
386
+ }
387
+ const openKeys = new Set(led.items.filter((i) => i?.kind === PROMISE_KIND && !i.done && i.projectId === pid).map((i) => i.key));
388
+ for (const promise of extractCommitments(hookInput.last_assistant_message)) {
389
+ if (openKeys.has(promise.key) || openKeys.size >= PROMISE_CAP_OPEN) continue;
390
+ openKeys.add(promise.key);
391
+ led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, at,
392
+ projectId: pid, worktreeIds: [projectIdentity.worktreeId], sessionIds: ['*'],
393
+ capturedFrom: { sessionId: hookInput.session_id },
394
+ authorization: { kind: 'owner-mandate', reference: 'i-will-is-a-contract-2026-09-15' } });
395
+ changed = true;
396
+ }
397
+ if (changed) save(led);
398
+ } catch { /* the ledger is advisory — never break a turn over it */ }
399
+ }
400
+ promiseBookkeeping();
401
+
402
+ /**
403
+ * LOOP-SAFETY 2 (moved below the bookkeeping above, unchanged in effect): no request is ever
404
+ * emitted on a stop that is already a continuation.
405
+ */
406
+ if (hookInput.stop_hook_active) process.exit(EXIT_ALLOW);
345
407
  // Terminal objectives remain terminal even if legacy/global ledger rows or observations stay open.
346
408
  if (['cancelled', 'completed', 'blocked'].includes(led.objective?.state)) process.exit(EXIT_ALLOW);
347
409
  const objective = authorizedContinuationObjective(led.objective, hookInput, projectIdentity);
@@ -578,7 +640,18 @@ function securityAlertWork() {
578
640
  const observations = [...artifactOpenWork(), ...redCiOpenWork(), ...openPrWork(), ...securityAlertWork()];
579
641
  if (observations.length) console.error(JSON.stringify({ kind: 'continuation-advisory',
580
642
  authority: false, items: observations.map(({ text, at }) => ({ text, at })) }));
581
- const open = [...capabilityClaimWork(), ...(objective ? [{ text: objective.text, at: objective.at }] : [])];
643
+ // ONE correction per turn: a single item however many claims the answer made; the stop_hook_active
644
+ // exit and the cooldown lock below bound it to one request per stop episode.
645
+ const completionWork = completion.verdict === 'FAIL' ? [{
646
+ text: `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}. Run the real consumer path, or restate it as UNVERIFIED.`,
647
+ at: new Date(nowMs).toISOString(), derived: true, kind: 'completion-claim-integrity',
648
+ }] : [];
649
+ const promiseWork = HOST === 'claude'
650
+ ? authorizedPromiseItems(led.items, hookInput, projectIdentity)
651
+ .map((i) => ({ text: `you said you would: ${i.text}`, at: i.at, kind: PROMISE_KIND }))
652
+ : [];
653
+ const open = [...capabilityClaimWork(), ...completionWork,
654
+ ...(objective ? [{ text: objective.text, at: objective.at }] : []), ...promiseWork];
582
655
  if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
583
656
 
584
657
  /**
@@ -665,6 +738,8 @@ if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
665
738
  const committed = forceable.filter((i) => !i.derived);
666
739
  const observed = forceable.filter((i) => i.derived);
667
740
  const capabilityClaims = forceable.filter((i) => i.kind === 'capability-claim-integrity');
741
+ const completionClaims = forceable.filter((i) => i.kind === 'completion-claim-integrity');
742
+ const promises = forceable.filter((i) => i.kind === PROMISE_KIND);
668
743
  // Every derived item names its own repo in its text; this is for the header, where the ONE repo
669
744
  // this tree points at is the honest thing to say.
670
745
  const repoLabel = [...OWNED_REPOS][0] || 'this repository';
@@ -672,6 +747,9 @@ const repoLabel = [...OWNED_REPOS][0] || 'this repository';
672
747
  const header = capabilityClaims.length
673
748
  ? ['Your proposed final answer contains a RuvNet capability claim that is contradicted or not provable.',
674
749
  'Do NOT deliver it unchanged — continue now and correct the claim from the sealed live-host inventory.']
750
+ : completionClaims.length
751
+ ? ['Your proposed final answer claims work is done without end-to-end evidence from this turn.',
752
+ 'Do NOT deliver it unchanged — run the real consumer path now, or restate the claim as UNVERIFIED.']
675
753
  : committed.length && observed.length
676
754
  ? [`You have unfinished work you committed to, and ${repoLabel} has open work of its own.`,
677
755
  'Do NOT end the turn — continue now.']
@@ -684,6 +762,7 @@ const header = capabilityClaims.length
684
762
  const lines = [
685
763
  ...header,
686
764
  ...(objective ? ['Continue the next safe step within this authorized objective without routine reconfirmation.']
765
+ : promises.length ? ['Do what you said you would do; that commitment is the only work this authorizes.']
687
766
  : ['Correct only the answer to the original user request; this does not authorize new project work.']),
688
767
  'Do not expand authority from observed issues, PRs, security alerts, or other task ledgers.',
689
768
  'Stop on explicit cancellation, verified completion, or a genuine blocker/new authority boundary.',
@@ -701,6 +780,11 @@ const lines = [
701
780
  ...(capabilityClaims.length
702
781
  ? ['Replace every contradicted claim with the observed capability and source path. Replace every',
703
782
  'unresolved absence claim with UNKNOWN until a complete live inventory proves it.']
783
+ : completionClaims.length
784
+ ? ['Name the check you ran (a "Verified:" line with the command or artifact) and say what is NOT verified.']
785
+ : promises.length
786
+ ? ['A promise closes only when a later answer claims it done with a check run after the last change —',
787
+ 'never by saying so, and never by --done. If you genuinely cannot do it, say so plainly and why.']
704
788
  : committed.length
705
789
  ? ['Record objective completion only with actual completion evidence; legacy --done does not complete an objective.']
706
790
  : ['These clear by being done, not by being marked: merge or fix the PR, get the build green,',
@@ -719,7 +803,7 @@ const lines = [
719
803
  // COMMITTED items only. A derived item is at most 6h old by construction (the freshness window),
720
804
  // and "clear it, that is a legitimate answer" is advice about a promise — you cannot clear a red
721
805
  // build by declaring it no longer real.
722
- ...(committed.some((i) => (nowMs - Date.parse(i.at)) > 24 * 3_600_000)
806
+ ...(committed.some((i) => i.kind !== PROMISE_KIND && (nowMs - Date.parse(i.at)) > 24 * 3_600_000)
723
807
  ? ['', 'Some of these are days old. If one is genuinely no longer real, say so and CLEAR it —',
724
808
  'that is a legitimate answer and the right one. What is never acceptable is marking it done',
725
809
  'without doing it, or letting it age quietly out of view.']
@@ -38,3 +38,18 @@ export function authorizedContinuationObjective(objective, input, identity) {
38
38
  || !Array.isArray(objective.worktreeIds) || !objective.worktreeIds.includes(identity.worktreeId)) return null;
39
39
  return objective;
40
40
  }
41
+
42
+ // Promises the assistant made in a final answer ("I'll do X next"), captured by continuation-gate
43
+ // into the SAME ledger (owner mandate 2026-09-15: "I will" is a contract). Same scoping discipline
44
+ // as the objective above — project AND worktree must match, a session wildcard only as the literal
45
+ // '*' — and the same loop guards. An item that is done, malformed, or foreign is never returned.
46
+ export function authorizedPromiseItems(items, input, identity) {
47
+ if (!identity || input?.hook_event_name !== 'Stop' || !text(input.session_id)
48
+ || input.interrupted || input.cancelled || input.stop_hook_active) return [];
49
+ return (Array.isArray(items) ? items : []).filter((item) => item?.kind === 'assistant-commitment'
50
+ && item.schemaVersion === 1 && item.done !== true && text(item.text) && Number.isFinite(Date.parse(item.at))
51
+ && item.authorization?.kind === 'owner-mandate' && text(item.authorization.reference)
52
+ && item.projectId === identity.projectId
53
+ && Array.isArray(item.worktreeIds) && item.worktreeIds.includes(identity.worktreeId)
54
+ && Array.isArray(item.sessionIds) && (item.sessionIds.includes('*') || item.sessionIds.includes(input.session_id)));
55
+ }
@@ -20,7 +20,7 @@
20
20
  * session-start continuity recovery at SessionStart claude, codex
21
21
  * unprompted-speech advisory delivery at UserPromptSubmit claude, codex
22
22
  * continuation-gate continuation nudge at turn end claude, codex
23
- * session-snapshot continuity capture at turn end claude
23
+ * session-snapshot continuity capture at turn end claude, codex
24
24
  * session-snapshot continuity capture at PreCompact claude
25
25
  * session-snapshot continuity capture at SessionEnd claude, codex
26
26
  * ground-ruvnet grounding injection at UserPromptSubmit claude, codex
@@ -95,7 +95,8 @@ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts:
95
95
  * struct, so this is "not proven", not "not supported".
96
96
  * PreCompact NOT OBSERVED — a one-line turn never approaches a compaction threshold.
97
97
  *
98
- * Therefore Codex capture is registered at SessionEnd ONLY. Stop keeps the pre-existing
98
+ * Therefore Codex capture was registered at SessionEnd ONLY (Stop added 2026-09-29 for turn
99
+ * outcomes — see the Stop registration below; its delivery is still unobserved). Stop keeps the pre-existing
99
100
  * continuation-gate registration (unchanged by this lane); no NEW handler is added to an event whose
100
101
  * delivery has not been seen. hook-contracts.json carries the same measurement and its date.
101
102
  *
@@ -154,7 +155,13 @@ export const CONTINUITY_EVENTS = Object.freeze({
154
155
  ]),
155
156
  Stop: Object.freeze([
156
157
  registration('continuation-gate', '*', ['claude', 'codex']),
157
- registration('session-snapshot', '*', ['claude']),
158
+ // Codex added 2026-09-29 (owner requirement: every turn's outcome recorded on BOTH hosts —
159
+ // turn-outcome-capture.mjs). Codex SessionEnd carries no last_assistant_message, so Stop is the
160
+ // only boundary that can record a Codex turn. Evidence: codex-cli 0.158.0's own
161
+ // stop.command.input schema (last_assistant_message, turn_id) and the two Codex Stop handlers
162
+ // already registered above/below. NOT yet live-observed firing — see the probe box: the capture
163
+ // fails open, so an unfired registration costs nothing but must not be read as proof.
164
+ registration('session-snapshot', '*', ['claude', 'codex']),
158
165
  // The "answered without searching" gate, half 2 of 2 (2026-09-12). Forces continuation
159
166
  // (hookSpecificOutput.additionalContext — the same contract continuation-gate.mjs already uses
160
167
  // and codex-hook-adapter.mjs already translates to Codex's decision:block on both hosts) when
@@ -32,6 +32,8 @@ const EVENT = process.argv[2] || '';
32
32
  /** Exit codes that mean something to the host. Anything else from a policy is an ERROR, not a refusal. */
33
33
  const ALLOW = 0;
34
34
  const REFUSE = 2;
35
+ /** A policy that could not decide and says why on stderr (e.g. duplicate-gate's index under overload). */
36
+ const SKIPPED_SELF = 3;
35
37
 
36
38
  /**
37
39
  * ── THE BUDGET AND THE HOST TIMEOUT ARE ONE NUMBER, NOT TWO ──────────────────────────────────────
@@ -124,12 +126,17 @@ const REFUSAL_POLICIES = [
124
126
  // DEBT, not change: you may edit governed code freely, but not while a document governing it is
125
127
  // still unreconciled from the last round.
126
128
  POLICY('adr-currency', 'adr-currency-gate.mjs', 'node'),
129
+ // duplicate-code (2026-09-30, owner: "is it the simplest version of the code that works, that doesn't
130
+ // create duplicates or replication across the project?"). Last: it is a question about craft, not a
131
+ // boundary, and it refuses at most once per path per session. Tuned on replayed history — the
132
+ // numbers and the method are in duplicate-gate.mjs and scripts/duplicate-gate-replay.mjs.
133
+ POLICY('duplicate-code', 'duplicate-gate.mjs', 'node'),
127
134
  ];
128
135
  const SPEECH = { id: 'unprompted-speech', file: 'unprompted-runtime.mjs', interpreter: 'node' };
129
136
 
130
137
  /** Which policies apply to which PreToolUse sub-event, mirroring the matchers they replaced. */
131
138
  const REGISTRY = {
132
- 'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'adr-currency'],
139
+ 'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'adr-currency', 'duplicate-code'],
133
140
  };
134
141
 
135
142
  export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES) {
@@ -245,6 +252,9 @@ if (isMain()) {
245
252
  const results = await Promise.all(consulted.map((p) => runPolicy(p, payload, deadline, undefined, trace)));
246
253
  const verdicts = results.filter((r) => typeof r.code === 'number');
247
254
  for (const r of results) if (r.skipped === 'budget') unconsulted.push(r.id);
255
+ // A policy that exits SKIPPED (3) did not vote and said why (duplicate-gate under overload). Same
256
+ // two channels as a blown budget, below — never silence standing in for a verdict.
257
+ const selfSkipped = results.filter((r) => r.skipped === 'self');
248
258
 
249
259
  const decision = decide(verdicts);
250
260
 
@@ -264,6 +274,7 @@ if (isMain()) {
264
274
  }
265
275
  } catch { /* a ledger must never break a tool call */ }
266
276
 
277
+ reportSelfSkipped({ session, selfSkipped });
267
278
  if (!decision.allow) {
268
279
  reportBudget({ session, unconsulted, trace, started, budgetMs });
269
280
  process.stderr.write(`${decision.reason}\n`);
@@ -329,6 +340,14 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
329
340
  );
330
341
  }
331
342
 
343
+ /** Record and print each policy that skipped itself (exit 3), with its own one-line reason. */
344
+ function reportSelfSkipped({ session, selfSkipped }) {
345
+ for (const r of selfSkipped) {
346
+ 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 */ }
347
+ process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; this allow is not its verdict.\n`);
348
+ }
349
+ }
350
+
332
351
  /**
333
352
  * Run one policy as a CAPTURED child. Never lets its bytes touch the real streams.
334
353
  *
@@ -362,7 +381,7 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
362
381
  const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolve(done(r)); };
363
382
  let child;
364
383
  try {
365
- child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1' } });
384
+ child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1', RUVNET_DECISION_DEADLINE: String(deadline) } });
366
385
  } catch { return finish({ id: p.id, skipped: 'spawn' }); }
367
386
  // SIGKILL, not SIGTERM: a bash policy that has spawned its own child (jq, node, ruflo) can sit in
368
387
  // a TERM handler, and the host's own kill is what we are racing. The whole batch shares ONE
@@ -379,6 +398,7 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
379
398
  child.on('close', (code) => {
380
399
  // A spawn failure, a timeout, or any code other than 0/2 is an ERROR — and an error here must
381
400
  // never be mistaken for a refusal. That distinction is the one lesson-gate.mjs had to learn twice.
401
+ if (code === SKIPPED_SELF) return finish({ id: p.id, skipped: 'self', reason: firstLine(stderr) });
382
402
  if (code !== ALLOW && code !== REFUSE) return finish({ id: p.id, skipped: `exit:${code}` });
383
403
  finish({ id: p.id, code, stderr, stdout });
384
404
  });