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.
- package/README.md +2 -2
- package/bin/install.mjs +32 -9
- package/kb/forge-update.mjs +1867 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +6 -1
- package/plugin/hooks/hook-contracts.json +11 -9
- package/plugin/scripts/capability-claim-evidence.mjs +11 -2
- package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
- package/plugin/scripts/completion-claim-evidence.mjs +262 -0
- package/plugin/scripts/continuation-gate.mjs +105 -21
- package/plugin/scripts/continuation-objective.mjs +15 -0
- package/plugin/scripts/continuity-hook-policy.mjs +10 -3
- package/plugin/scripts/decision-gate.mjs +22 -2
- package/plugin/scripts/duplicate-gate.mjs +503 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
- package/plugin/scripts/grounding-turn-gate.mjs +77 -25
- package/plugin/scripts/grounding-turn-mark.mjs +61 -14
- package/plugin/scripts/hook-input.mjs +15 -0
- package/plugin/scripts/hook-shim.mjs +3 -1
- package/plugin/scripts/host-update.mjs +45 -0
- package/plugin/scripts/nightly-scheduler.mjs +34 -0
- package/plugin/scripts/session-snapshot-hook.mjs +11 -1
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +15 -2
- package/plugin/scripts/session-start-health.mjs +101 -1
- package/plugin/scripts/session-start-update-plane.mjs +71 -1
- package/plugin/scripts/turn-outcome-capture.mjs +292 -0
- package/scripts/completion-claim-replay.mjs +114 -0
- package/scripts/corpus-canary.mjs +399 -0
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +49 -0
- package/scripts/corpus-reconcile.mjs +27 -3
- package/scripts/corpus-watchdog.mjs +45 -6
- package/scripts/derive-passage-content-map.mjs +71 -0
- package/scripts/duplicate-gate-replay.mjs +98 -0
- package/scripts/grounding-turn-replay.mjs +131 -0
- package/scripts/nightly-watchdog.mjs +3 -3
- package/scripts/public-verification-inputs.mjs +24 -2
- package/scripts/release-transaction-provider.mjs +29 -6
- package/scripts/release.mjs +177 -74
- package/scripts/retrieval-canary.mjs +41 -4
- package/scripts/retrieval-passage-identity.mjs +58 -0
- package/scripts/sync-census.mjs +0 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|