ruvnet-brain 4.7.20 → 4.7.22
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 +618 -104
- package/console/activity.js +122 -38
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +49 -17
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +241 -23
- package/plugin/scripts/continuation-gate.mjs +75 -11
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-gate.mjs +7 -39
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +25 -5
- package/plugin/scripts/session-start-core.mjs +7 -16
- package/plugin/scripts/session-start-health.mjs +98 -10
- package/plugin/scripts/session-start-issue-alert.mjs +63 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-boot-surface-proof.mjs +208 -0
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +116 -40
- package/scripts/job-heartbeat.sh +54 -19
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +26 -20
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +41 -18
- package/scripts/onboarding-console.mjs +123 -23
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recall-latency-probe.mjs +48 -0
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +82 -28
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +13 -5
- package/scripts/worktree-integrity.mjs +2 -2
|
@@ -53,9 +53,11 @@ import { deferralAtStop } from './deferral-evidence.mjs';
|
|
|
53
53
|
import { readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
54
54
|
import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems, assistantCommitmentOwned, setAssistantCommitmentState } from './continuation-objective.mjs';
|
|
55
55
|
import {
|
|
56
|
-
auditCompletionClaims, readClaudeTurn, extractCommitments,
|
|
56
|
+
auditCompletionClaims, readClaudeTurn, extractCommitments, promiseClosingClaim,
|
|
57
|
+
promiseEvidence, completionCorrectionText, outcomeAdvisoryText, completionCategory,
|
|
57
58
|
PROMISE_KIND, PROMISE_CAP_OPEN,
|
|
58
59
|
} from './completion-claim-evidence.mjs';
|
|
60
|
+
import { writeAtomic } from './user-settings.mjs';
|
|
59
61
|
|
|
60
62
|
const HOME = os.homedir();
|
|
61
63
|
|
|
@@ -181,7 +183,12 @@ function load() {
|
|
|
181
183
|
function save(led) {
|
|
182
184
|
try {
|
|
183
185
|
fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
|
|
184
|
-
|
|
186
|
+
// writeAtomic (not a bare writeFileSync): writeFileSync truncates the target before writing the
|
|
187
|
+
// new bytes, so a kill -9 / ENOSPC / EIO between those two steps leaves LEDGER empty — and load()'s
|
|
188
|
+
// catch-all JSON.parse failure then reads that as "nothing was ever committed", silently discarding
|
|
189
|
+
// every item a prior turn agreed to do, not just the one being added now. Same house pattern as
|
|
190
|
+
// user-settings.mjs's own writeAtomic(): temp-in-the-same-directory + fsync + rename.
|
|
191
|
+
writeAtomic(LEDGER, JSON.stringify({ ...led, updated: new Date().toISOString() }, null, 2) + '\n');
|
|
185
192
|
return true;
|
|
186
193
|
} catch { return false; /* the ledger is advisory — never break a turn over it */ }
|
|
187
194
|
}
|
|
@@ -366,19 +373,28 @@ const HOST = process.env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : 'claude';
|
|
|
366
373
|
* COMPLETION CLAIMS (ADR-074 class `completion`, owner 2026-09-30: "You can never ever tell me
|
|
367
374
|
* something is done and implemented without having tested it end to end"). Audited ONCE per Stop:
|
|
368
375
|
* the verdict both requests a correction (below, under every loop guard) and is the only evidence
|
|
369
|
-
* that may close a captured promise. Claude transcripts
|
|
370
|
-
*
|
|
371
|
-
*
|
|
376
|
+
* that may close a captured promise. Claude transcripts and Codex rollouts (through the shared
|
|
377
|
+
* nativeTurnLines reader, #423) are parsed; a Codex rollout that cannot be read or has no current-turn
|
|
378
|
+
* boundary stays UNKNOWN (completion-claim-evidence.mjs header). Fails open: a throw is NONE.
|
|
372
379
|
*/
|
|
380
|
+
let completionTurnKey = null;
|
|
373
381
|
const completion = (() => {
|
|
374
382
|
const message = String(hookInput.last_assistant_message || '');
|
|
375
383
|
if (!message || !hookInput.session_id) return { verdict: 'NONE', claims: [] };
|
|
376
384
|
try {
|
|
377
|
-
const turn =
|
|
385
|
+
const turn = readClaudeTurn(hookInput.transcript_path, { host: HOST });
|
|
378
386
|
// Claude always sends a JSONL transcript; one we cannot read is OUR failure, and a Stop hook
|
|
379
387
|
// fails open on its own machinery (ADR-074 failure semantics) — never a correction request.
|
|
380
388
|
// Missing native evidence stays UNKNOWN; it cannot silently validate a completion claim.
|
|
381
|
-
|
|
389
|
+
const audit = auditCompletionClaims(message, { turn, host: HOST });
|
|
390
|
+
// Turn identity for correction de-duplication (#425): the host's native id (Claude prompt_id, Codex
|
|
391
|
+
// turn_id), else the transcript record that opened this turn (uuid or position, never its text: a repeated
|
|
392
|
+
// "continue" is a new turn). Never a clock.
|
|
393
|
+
const native = HOST === 'codex' ? hookInput.turn_id : hookInput.prompt_id;
|
|
394
|
+
const opener = typeof native === 'string' && native.trim() ? `native:${native}`
|
|
395
|
+
: turn?.boundaryFound && turn.openerId ? `opener:${turn.openerId}` : '';
|
|
396
|
+
if (opener) completionTurnKey = crypto.createHash('sha256').update(JSON.stringify([hookInput.session_id, opener])).digest('hex').slice(0, 24);
|
|
397
|
+
return audit;
|
|
382
398
|
} catch { return { verdict: 'NONE', claims: [] }; }
|
|
383
399
|
})();
|
|
384
400
|
|
|
@@ -401,7 +417,7 @@ function promiseBookkeeping() {
|
|
|
401
417
|
if (completion.verdict === 'PASS') {
|
|
402
418
|
for (const item of led.items) {
|
|
403
419
|
if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity)) continue;
|
|
404
|
-
const claim = completion
|
|
420
|
+
const claim = promiseClosingClaim(completion, hookInput.last_assistant_message, item.text);
|
|
405
421
|
if (!claim) continue;
|
|
406
422
|
const verifiedCompletion = completion.verdict === 'PASS' && Boolean(claim);
|
|
407
423
|
Object.assign(item, { done: verifiedCompletion, state: verifiedCompletion ? 'completed' : item.state, doneAt: at, completionEvidence: { claim: claim.text,
|
|
@@ -410,6 +426,12 @@ function promiseBookkeeping() {
|
|
|
410
426
|
changed = true;
|
|
411
427
|
}
|
|
412
428
|
}
|
|
429
|
+
// #369: observed evidence moves a promise to `evidenced` (NOT done, never forced again); same session only.
|
|
430
|
+
for (const item of led.items) {
|
|
431
|
+
if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity) || !(item.state === undefined || item.state === 'active')) continue;
|
|
432
|
+
const closureEvidence = promiseEvidence(completion, hookInput.last_assistant_message, item);
|
|
433
|
+
if (closureEvidence) { Object.assign(item, { state: 'evidenced', stateAt: at, closureEvidence }); changed = true; }
|
|
434
|
+
}
|
|
413
435
|
const owned = led.items.filter((i) => assistantCommitmentOwned(i, hookInput.session_id, projectIdentity));
|
|
414
436
|
const openKeys = new Set(owned.map((i) => i.key));
|
|
415
437
|
let activeCount = owned.filter((i) => i.state === undefined || i.state === 'active').length;
|
|
@@ -422,6 +444,7 @@ function promiseBookkeeping() {
|
|
|
422
444
|
led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, state: 'active', at,
|
|
423
445
|
projectId: pid, worktreeIds: [projectIdentity.worktreeId], sessionIds: [hookInput.session_id],
|
|
424
446
|
capturedFrom: { sessionId: hookInput.session_id },
|
|
447
|
+
...(promise.evidenceCommands ? { evidenceCommands: promise.evidenceCommands } : {}),
|
|
425
448
|
authorization: { kind: 'owner-mandate', reference: 'i-will-is-a-contract-2026-09-15' } });
|
|
426
449
|
changed = true;
|
|
427
450
|
}
|
|
@@ -715,8 +738,18 @@ if (observations.length && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind
|
|
|
715
738
|
authority: false, items: observations.map(({ text, at }) => ({ text, at })) }));
|
|
716
739
|
// ONE correction per turn: a single item however many claims the answer made; the stop_hook_active
|
|
717
740
|
// exit and the cooldown lock below bound it to one request per stop episode.
|
|
718
|
-
|
|
719
|
-
|
|
741
|
+
// #425 (owner decision): an UNKNOWN whose ONLY problem is task scope — a check observed after the last
|
|
742
|
+
// change, named in the answer, with what is NOT verified disclosed — is not a correction. The audit
|
|
743
|
+
// recognizes check-scoped sentences only; it has no representation for a scoped task outcome, so
|
|
744
|
+
// re-running checks or rewording cannot satisfy it. The verdict stays UNKNOWN (never PASS: it closes
|
|
745
|
+
// no promise and grants no completion authority); under `full` the limitation is reported, not enforced.
|
|
746
|
+
const scopeOnlyUnknown = completion.verdict === 'UNKNOWN' && completion.namesCheck === true
|
|
747
|
+
&& completion.disclosesGaps === true
|
|
748
|
+
&& ((completion.verification?.checks?.length || 0) + (completion.verification?.maskedChecks?.length || 0)) > 0;
|
|
749
|
+
if (scopeOnlyUnknown && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind: 'completion-scope-advisory',
|
|
750
|
+
authority: false, verdict: 'UNKNOWN', outcomes: completion.outcomes, text: outcomeAdvisoryText(completion) }));
|
|
751
|
+
const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) && !scopeOnlyUnknown ? [{
|
|
752
|
+
text: completionCorrectionText(completion),
|
|
720
753
|
at: new Date(nowMs).toISOString(), derived: true, kind: 'completion-claim-integrity',
|
|
721
754
|
}] : [];
|
|
722
755
|
// DECISION RECORDS (ADR-105 §8, ADR-106): a turn that CREATED an ADR in an adopted project must also
|
|
@@ -815,7 +848,8 @@ function claimCooldown(now, windowMs) {
|
|
|
815
848
|
if (Number.isFinite(prev) && (now - prev) < windowMs) return false; // fresh lock: someone forced recently
|
|
816
849
|
fs.unlinkSync(LOCK); // stale: clear it so we can re-claim
|
|
817
850
|
} catch { /* no lock yet */ }
|
|
818
|
-
|
|
851
|
+
// A fresh install has no work-ledgers/ folder until the first save(); ENOENT here is not a lost race.
|
|
852
|
+
try { fs.mkdirSync(path.dirname(LOCK), { recursive: true }); fs.writeFileSync(LOCK, new Date(now).toISOString(), { flag: 'wx' }); return true; }
|
|
819
853
|
catch { return false; } // lost the race / cannot persist → fail closed
|
|
820
854
|
}
|
|
821
855
|
if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
|
|
@@ -861,6 +895,36 @@ try {
|
|
|
861
895
|
if (touched) fs.writeFileSync(FORCES, JSON.stringify(counts));
|
|
862
896
|
if (retired.length) { save(led); forceable = forceable.filter((i) => !retired.includes(i)); }
|
|
863
897
|
} catch { /* bookkeeping only: never break a turn */ }
|
|
898
|
+
/**
|
|
899
|
+
* A completion correction is delivered at most once per native session + user turn + audit category (#425,
|
|
900
|
+
* sparkling). The marker is an exclusive-create file (the same primitive as the cooldown lock), so two racing
|
|
901
|
+
* Stop hooks cannot both deliver. Rewording or elapsed time does not rearm it; a new user turn does. With no
|
|
902
|
+
* turn identity the correction degrades to a non-blocking advisory carrying the ORIGINAL verdict (never PASS).
|
|
903
|
+
* This only ever removes a blocking item.
|
|
904
|
+
*/
|
|
905
|
+
try {
|
|
906
|
+
const item = forceable.find((i) => i.kind === 'completion-claim-integrity');
|
|
907
|
+
if (item) {
|
|
908
|
+
const category = completionCategory(completion);
|
|
909
|
+
let deliver = false;
|
|
910
|
+
if (!completionTurnKey) {
|
|
911
|
+
console.error(JSON.stringify({ kind: 'completion-correction-advisory', authority: false, verdict: completion.verdict,
|
|
912
|
+
category, reason: 'turn identity unavailable; a correction cannot be de-duplicated, so it is not enforced', text: item.text }));
|
|
913
|
+
} else {
|
|
914
|
+
const id = crypto.createHash('sha256').update(JSON.stringify([hookInput.session_id, completionTurnKey, category])).digest('hex').slice(0, 24);
|
|
915
|
+
const dir = `${LEDGER}.completion-delivered`;
|
|
916
|
+
try {
|
|
917
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
918
|
+
fs.writeFileSync(path.join(dir, id), new Date(nowMs).toISOString(), { flag: 'wx' });
|
|
919
|
+
deliver = true;
|
|
920
|
+
for (const f of fs.readdirSync(dir)) { // markers are only needed for the turn; keep a week
|
|
921
|
+
try { if (nowMs - fs.statSync(path.join(dir, f)).mtimeMs > 7 * 86_400_000) fs.rmSync(path.join(dir, f), { force: true }); } catch { /* raced */ }
|
|
922
|
+
}
|
|
923
|
+
} catch { /* already delivered this turn/category, or cannot persist: do not block */ }
|
|
924
|
+
}
|
|
925
|
+
if (!deliver) forceable = forceable.filter((i) => i !== item);
|
|
926
|
+
}
|
|
927
|
+
} catch { forceable = forceable.filter((i) => i.kind !== 'completion-claim-integrity'); }
|
|
864
928
|
if (!forceable.length) process.exit(EXIT_ALLOW);
|
|
865
929
|
if (deferralState && forceable.some((i) => i.kind === 'no-deferral')) {
|
|
866
930
|
try { for (const [k, v] of Object.entries(deferralState.counts)) if (nowMs - Date.parse(v?.at) > 7 * 86_400_000) delete deferralState.counts[k];
|
|
@@ -25,13 +25,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
25
25
|
// an install hint into the middle of a refusal reason, or manufactured stderr on an allow. Read the
|
|
26
26
|
// signature, do not infer it from the name.
|
|
27
27
|
import { resolveBash } from './hook-shim-bash.mjs';
|
|
28
|
-
import { append as appendOutcome, actionKey, recordRefusal, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
|
|
28
|
+
import { append as appendOutcome, actionKey, recordRefusal, reportBudget, reportSelfSkipped, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
|
|
29
29
|
// Grok sends its own tool names (`write`) and a snake_case event; every policy below reads Claude's
|
|
30
30
|
// shape. Normalised ONCE here, so the policies (four of them bash, matching tool_name textually) never
|
|
31
31
|
// see a host-native spelling — the 4.4 measurement was a Grok write silently allowed. hook-input.mjs.
|
|
32
32
|
import { normalizePayloadText } from './hook-input.mjs';
|
|
33
|
-
|
|
34
|
-
const QUIET_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
|
|
33
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
35
34
|
|
|
36
35
|
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
37
36
|
const EVENT = process.argv[2] || '';
|
|
@@ -93,8 +92,8 @@ export const MIN_HEADROOM_MS = 3000;
|
|
|
93
92
|
*
|
|
94
93
|
* 1. ALLOW ON TIMEOUT. A policy still running at the budget is killed and the write is ALLOWED — a gate
|
|
95
94
|
* that blocks because it is slow is one users switch off. Never silent: the outcome ledger records
|
|
96
|
-
* `budget-exceeded` with the policies that did not vote
|
|
97
|
-
*
|
|
95
|
+
* `budget-exceeded` with the policies that did not vote; a stderr line saying the allow was a timeout,
|
|
96
|
+
* not a verdict, is printed only on a refusal or under RUVNET_STOP_NUDGES=full (decision-outcomes.mjs).
|
|
98
97
|
*
|
|
99
98
|
* 2. OVERSIZE: ONLY THE FIRST 64 KiB IS CHECKED. The host hands this gate the payload through
|
|
100
99
|
* hook-shim.mjs, which reads at most TRANSPORT_CAP_BYTES (its TABLE entry), and the bash policies cap
|
|
@@ -206,7 +205,7 @@ export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES)
|
|
|
206
205
|
/**
|
|
207
206
|
* Resolved once per invocation by the runtime block below; null means no bash on this host.
|
|
208
207
|
*
|
|
209
|
-
* Declared HERE, and not next to runPolicy() where it reads more naturally: the
|
|
208
|
+
* Declared HERE, and not next to runPolicy() where it reads more naturally: the runtime
|
|
210
209
|
* block runs during module evaluation, so a `let` declared after it sits in the temporal dead zone
|
|
211
210
|
* and the assignment throws — the identical mistake `speechEventFor` was already a hoisted
|
|
212
211
|
* `function` to avoid, recorded a few lines further down.
|
|
@@ -285,13 +284,14 @@ function payloadInput(payload) { return parsed(payload).tool_input || {}; }
|
|
|
285
284
|
/**
|
|
286
285
|
* The sub-event token unprompted-runtime switches on.
|
|
287
286
|
*
|
|
288
|
-
* A hoisted `function`, not a `const` arrow: the
|
|
287
|
+
* A hoisted `function`, not a `const` arrow: the runtime block below runs DURING module
|
|
289
288
|
* evaluation, so an arrow declared after it sits in the temporal dead zone — it threw
|
|
290
289
|
* `Cannot access 'speechEventFor' before initialization` on this gate's first live refusal.
|
|
291
290
|
*/
|
|
292
291
|
function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
|
|
293
292
|
// ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
|
|
294
|
-
|
|
293
|
+
// isDirectInvocation never throws (entrypoint.mjs), so an importer cannot be crashed by this guard.
|
|
294
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
295
295
|
const started = Date.now();
|
|
296
296
|
const payload = normalizePayloadText(readPayload());
|
|
297
297
|
const shape = classifyPayload(payload);
|
|
@@ -348,14 +348,14 @@ if (isMain()) {
|
|
|
348
348
|
}
|
|
349
349
|
} catch { /* a ledger must never break a tool call */ }
|
|
350
350
|
};
|
|
351
|
-
reportSelfSkipped({ session, selfSkipped });
|
|
351
|
+
reportSelfSkipped({ event: EVENT, session, selfSkipped });
|
|
352
352
|
if (decision.allow && shape.truncated) {
|
|
353
353
|
// OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
|
|
354
354
|
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 */ }
|
|
355
355
|
}
|
|
356
356
|
if (!decision.allow) {
|
|
357
357
|
measureFinalDecision(false);
|
|
358
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
|
|
358
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs, refused: true });
|
|
359
359
|
process.stderr.write(`${decision.reason}\n`);
|
|
360
360
|
process.exit(REFUSE);
|
|
361
361
|
}
|
|
@@ -373,65 +373,22 @@ if (isMain()) {
|
|
|
373
373
|
if (speech.skipped === 'budget') unconsulted.push(SPEECH.id);
|
|
374
374
|
if (speech.code === REFUSE) {
|
|
375
375
|
measureFinalDecision(false, [...decision.refusals, SPEECH.id]);
|
|
376
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
|
|
376
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs, refused: true });
|
|
377
377
|
process.stderr.write(`${(speech.stderr || '').trim()}\n`);
|
|
378
378
|
process.exit(REFUSE);
|
|
379
379
|
}
|
|
380
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
380
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs });
|
|
381
381
|
measureFinalDecision(true);
|
|
382
382
|
const context = policyContext(results, speech.stdout || '');
|
|
383
383
|
if (context) process.stdout.write(context);
|
|
384
384
|
process.exit(ALLOW);
|
|
385
385
|
}
|
|
386
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
386
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs });
|
|
387
387
|
measureFinalDecision(true);
|
|
388
388
|
const context = policyContext([...results, ...unconsulted.filter((id) => !results.some((r) => r.id === id && r.skipped)).map((id) => ({ id, skipped: 'budget' }))]);
|
|
389
389
|
if (context) process.stdout.write(context);
|
|
390
390
|
process.exit(ALLOW);
|
|
391
391
|
}
|
|
392
|
-
/**
|
|
393
|
-
* ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO ──────────────────────────
|
|
394
|
-
*
|
|
395
|
-
* The old loop did `break` when the budget ran out. Every remaining policy AND the speech chokepoint
|
|
396
|
-
* were then skipped with no record anywhere — the gate allowed, and nothing distinguished "five
|
|
397
|
-
* policies agreed this was fine" from "we ran out of time and stopped asking". That is this repo's
|
|
398
|
-
* signature defect wearing a different hat: silence standing in for a measurement. degradation-watch
|
|
399
|
-
* exists because a warning was printed and skimmed; this exists because nothing was printed at all.
|
|
400
|
-
*
|
|
401
|
-
* TWO CHANNELS, because each fails differently:
|
|
402
|
-
* · the outcome ledger (~/.config/ruvnet-brain/, survives `--update`) so a trip COMPOUNDS into
|
|
403
|
-
* evidence instead of scrolling past. `kind: 'budget-exceeded'` is inert in report()'s buckets —
|
|
404
|
-
* it counts neither as a refusal nor as a resolution, so it cannot move the obedience rate.
|
|
405
|
-
* · one stderr line, unconditionally, allow or refuse. Yes, that puts bytes on stderr during an
|
|
406
|
-
* exit-0 allow, which tests/unit/decision-gate.test.mjs asserts never happens on an ordinary
|
|
407
|
-
* write. That assertion is now a SECOND tripwire and is meant to be: after the parallel batch and
|
|
408
|
-
* the applicability skip, an ordinary write measures ~360ms against a 4000ms budget, so a trip
|
|
409
|
-
* there is not noise to be tolerated — it is the defect, and the suite should go red for it.
|
|
410
|
-
*/
|
|
411
|
-
function reportBudget({ session, unconsulted, trace, started, budgetMs, refused = false }) {
|
|
412
|
-
const elapsed = Date.now() - started;
|
|
413
|
-
if (process.env.RUVNET_DECISION_TRACE === '1') {
|
|
414
|
-
process.stderr.write(`[decision-gate] ${EVENT} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
|
|
415
|
-
}
|
|
416
|
-
if (!unconsulted.length) return;
|
|
417
|
-
try {
|
|
418
|
-
appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
|
|
419
|
-
} catch { /* a ledger must never break a tool call */ }
|
|
420
|
-
// Quiet by default (2026-10-15): the outcome is already in the ledger above. On an ALLOW, a stderr line only
|
|
421
|
-
// makes the host show a hook "error" for a decision that was made. It is kept when the action was refused
|
|
422
|
-
// and under RUVNET_STOP_NUDGES=full.
|
|
423
|
-
if (refused || QUIET_FULL) process.stderr.write(
|
|
424
|
-
`[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
|
|
425
|
-
+ `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
/** Record and print each policy that skipped itself (exit 3), with its own one-line reason. */
|
|
429
|
-
function reportSelfSkipped({ session, selfSkipped }) {
|
|
430
|
-
for (const r of selfSkipped) {
|
|
431
|
-
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 */ }
|
|
432
|
-
if (QUIET_FULL) process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
392
|
/**
|
|
436
393
|
* Run one policy as a CAPTURED child. Never lets its bytes touch the real streams.
|
|
437
394
|
*
|
|
@@ -494,10 +451,3 @@ function readPayload() {
|
|
|
494
451
|
if (process.stdin.isTTY) return '';
|
|
495
452
|
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
496
453
|
}
|
|
497
|
-
/** Never able to crash a caller that merely imported this (see tests/unit/entrypoint-guard-safety). */
|
|
498
|
-
function isMain() {
|
|
499
|
-
try {
|
|
500
|
-
if (!process.argv[1]) return false;
|
|
501
|
-
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
502
|
-
} catch { return false; }
|
|
503
|
-
}
|
|
@@ -118,6 +118,42 @@ function appendLocked(record, file, owns) {
|
|
|
118
118
|
export function append(record, file = LEDGER, budget = {}) {
|
|
119
119
|
return locked([file], owns => { appendLocked(record, file, owns); return true; }, false, budget);
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO (decision-gate.mjs) ──────
|
|
123
|
+
*
|
|
124
|
+
* The gate's old loop did `break` when the budget ran out: every remaining policy AND the speech
|
|
125
|
+
* chokepoint were skipped with no record anywhere, and nothing distinguished "five policies agreed"
|
|
126
|
+
* from "we ran out of time and stopped asking" — silence standing in for a measurement.
|
|
127
|
+
*
|
|
128
|
+
* TWO CHANNELS, because each fails differently:
|
|
129
|
+
* · this ledger (survives `--update`), ALWAYS, so a trip COMPOUNDS into evidence. `budget-exceeded`
|
|
130
|
+
* and `policy-skipped` are inert in report()'s buckets, so they cannot move the obedience rate.
|
|
131
|
+
* · one stderr line, ONLY when the action was refused or under RUVNET_STOP_NUDGES=full (quiet by
|
|
132
|
+
* default since 2026-10-15): on an ALLOW a stderr line only makes the host show a hook "error" for
|
|
133
|
+
* a decision that was made. tests/unit/write-gate-policy.test.mjs pins both modes.
|
|
134
|
+
*/
|
|
135
|
+
const STOP_NUDGES_FULL = () => String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
|
|
136
|
+
export function reportBudget({ event, session, unconsulted, trace, started, budgetMs, refused = false }) {
|
|
137
|
+
const elapsed = Date.now() - started;
|
|
138
|
+
if (process.env.RUVNET_DECISION_TRACE === '1') {
|
|
139
|
+
process.stderr.write(`[decision-gate] ${event} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (!unconsulted.length) return;
|
|
142
|
+
try {
|
|
143
|
+
append({ kind: 'budget-exceeded', event, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
|
|
144
|
+
} catch { /* a ledger must never break a tool call */ }
|
|
145
|
+
if (refused || STOP_NUDGES_FULL()) process.stderr.write(
|
|
146
|
+
`[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
|
|
147
|
+
+ `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
/** Record each policy that skipped itself (exit 3) with its own one-line reason; print it only under full nudges. */
|
|
151
|
+
export function reportSelfSkipped({ event, session, selfSkipped }) {
|
|
152
|
+
for (const r of selfSkipped) {
|
|
153
|
+
try { append({ kind: 'policy-skipped', event, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
|
|
154
|
+
if (STOP_NUDGES_FULL()) process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
121
157
|
const stateFiles = files => ({ pending: files.pending || PENDING, ledger: files.ledger || LEDGER });
|
|
122
158
|
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
159
|
const validDebt = value => value && typeof value.session === 'string' && typeof value.key === 'string'
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* AT MOST ONE REFUSAL PER PATH PER SESSION (the duplicate-gate shape): the retry is allowed and
|
|
17
17
|
* recorded. Only in an adopted project (canonical `.swarm/memory.db` exists). Opt-outs win
|
|
18
18
|
* (ADR-105 §7): RUVNET_DECISION_RECALL_GATE=off, RUVNET_AGENTDB_FIRST=off, the brain-off sentinel.
|
|
19
|
-
* Fails open on its own errors
|
|
19
|
+
* Fails open on its own errors, except a Git identity timeout, which exits 3 (skipped, with the reason) so the
|
|
20
|
+
* gate's ledger records that this ADR decision was not checked (D8, 4.7.21).
|
|
20
21
|
*/
|
|
21
22
|
import fs from 'node:fs';
|
|
22
23
|
import os from 'node:os';
|
|
@@ -25,9 +26,13 @@ import crypto from 'node:crypto';
|
|
|
25
26
|
import { fileURLToPath } from 'node:url';
|
|
26
27
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
27
28
|
import { isAdrPath, freshConsultReceipt, brainHomeOf } from './decision-record.mjs';
|
|
29
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
28
30
|
|
|
29
31
|
const ALLOW = 0;
|
|
30
32
|
const REFUSE = 2;
|
|
33
|
+
/** decision-gate.mjs's code for "this policy could not decide, and says why on stderr". */
|
|
34
|
+
const SKIPPED_SELF = 3;
|
|
35
|
+
const GIT_TIMEOUT_MS = 400;
|
|
31
36
|
const STATUS_LINE = /^\s*(?:status:\s*\S.*|\*\*Status\*\*\s*:.*)$/im;
|
|
32
37
|
/** The document's status line, ignoring fenced code (a YAML example is not the ADR's status). */
|
|
33
38
|
const STATUS_LINES = new RegExp(STATUS_LINE.source, 'gim');
|
|
@@ -87,6 +92,29 @@ function brainOff(env) {
|
|
|
87
92
|
const ackFile = (brainHome) => path.join(brainHome, 'decision-recall-acks.json');
|
|
88
93
|
function readAcks(file) { try { const o = JSON.parse(fs.readFileSync(file, 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Publish the ledger the way decision-record.mjs's own sibling (writeConsultReceipt) already does:
|
|
97
|
+
* temp file in the same directory, fsync, then rename. fs.writeFileSync on the real path truncates
|
|
98
|
+
* before it writes — a process killed between those two steps leaves an empty ledger, discarding
|
|
99
|
+
* every acknowledgment ever recorded, not just the one being added. rename() is atomic on POSIX, so
|
|
100
|
+
* a reader (or a crash) only ever sees the old complete ledger or the new complete one.
|
|
101
|
+
*/
|
|
102
|
+
function writeAcksAtomic(file, acks) {
|
|
103
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
104
|
+
let fd;
|
|
105
|
+
try {
|
|
106
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
107
|
+
fs.writeSync(fd, JSON.stringify(acks));
|
|
108
|
+
try { fs.fsyncSync(fd); } catch { /* filesystem without fsync (some network mounts) — proceed */ }
|
|
109
|
+
fs.closeSync(fd);
|
|
110
|
+
fd = null;
|
|
111
|
+
fs.renameSync(tmp, file);
|
|
112
|
+
} finally {
|
|
113
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* nothing to close */ } }
|
|
114
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* leave the temp rather than throw over it */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
/** The verdict. Returns { code, stderr?, stdout? }. */
|
|
91
119
|
export function evaluate(payload, { env = process.env, now = Date.now() } = {}) {
|
|
92
120
|
if (['off'].includes(String(env.RUVNET_DECISION_RECALL_GATE || '').toLowerCase())
|
|
@@ -94,7 +122,14 @@ export function evaluate(payload, { env = process.env, now = Date.now() } = {})
|
|
|
94
122
|
const decision = adrDecisionIn(payload);
|
|
95
123
|
if (!decision) return { code: ALLOW };
|
|
96
124
|
let resolution;
|
|
97
|
-
try { resolution = resolveProjectStore({ projectDir: payload.cwd || process.cwd(), gitTimeoutMs:
|
|
125
|
+
try { resolution = resolveProjectStore({ projectDir: payload.cwd || process.cwd(), gitTimeoutMs: GIT_TIMEOUT_MS }); } catch (error) {
|
|
126
|
+
// D8 (4.7.21): a Git timeout is not a verdict. Exit SKIPPED_SELF with the reason, so decision-gate records a
|
|
127
|
+
// `policy-skipped` ledger row for this ADR decision instead of the silent allow it used to be.
|
|
128
|
+
if (/timed out/i.test(String(error?.message))) {
|
|
129
|
+
return { code: SKIPPED_SELF, stderr: `decision-recall: Git project identity timed out after ${GIT_TIMEOUT_MS}ms; the ${decision.kind} ${decision.path} was not checked for an AgentDB consult.` };
|
|
130
|
+
}
|
|
131
|
+
return { code: ALLOW };
|
|
132
|
+
}
|
|
98
133
|
try { if (!fs.statSync(resolution.canonicalAgentDbPath).isFile()) return { code: ALLOW }; } catch { return { code: ALLOW }; }
|
|
99
134
|
const brainHome = brainHomeOf(env);
|
|
100
135
|
const receipt = freshConsultReceipt({ projectRoot: resolution.projectRoot, brainHome, now });
|
|
@@ -108,13 +143,13 @@ export function evaluate(payload, { env = process.env, now = Date.now() } = {})
|
|
|
108
143
|
const acks = readAcks(file);
|
|
109
144
|
if (acks[key]) {
|
|
110
145
|
acks[key].retriedAt = new Date(now).toISOString();
|
|
111
|
-
try {
|
|
146
|
+
try { writeAcksAtomic(file, acks); } catch { /* the ledger never blocks a write */ }
|
|
112
147
|
return { code: ALLOW, stdout: `This ${decision.kind} was written without an AgentDB consult (retry after one refusal). Record it afterwards with decision-record.mjs --record.` };
|
|
113
148
|
}
|
|
114
149
|
// Keep the ledger bounded: drop acknowledgements older than a week.
|
|
115
150
|
for (const [k, v] of Object.entries(acks)) if (now - Date.parse(v?.at) > 7 * 86_400_000) delete acks[k];
|
|
116
151
|
acks[key] = { at: new Date(now).toISOString(), kind: decision.kind };
|
|
117
|
-
try { fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
152
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); writeAcksAtomic(file, acks); } catch { return { code: ALLOW }; }
|
|
118
153
|
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'decision-record.mjs');
|
|
119
154
|
return { code: REFUSE, stderr: [
|
|
120
155
|
`⛔ BLOCKED (once) — ${decision.kind} ${decision.path}: run decision-record.mjs --consult "<topic>" first (AgentDB decisions unread).`,
|
|
@@ -127,7 +162,7 @@ export function evaluate(payload, { env = process.env, now = Date.now() } = {})
|
|
|
127
162
|
}
|
|
128
163
|
|
|
129
164
|
function isMain() {
|
|
130
|
-
try { return
|
|
165
|
+
try { return isDirectInvocation(import.meta.url); } catch { return false; }
|
|
131
166
|
}
|
|
132
167
|
if (isMain()) {
|
|
133
168
|
let verdict = { code: ALLOW };
|
|
@@ -26,11 +26,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
26
26
|
import { redactText } from './continuity-events.mjs';
|
|
27
27
|
import { maskExcludedPaths } from './turn-capture-privacy.mjs';
|
|
28
28
|
import { resolveTurnDb, readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
29
|
-
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
29
|
+
import { resolveProjectStore, storeEncryptedAtRest, ENCRYPTED_STORE_REASON } from './project-store-resolver.mjs';
|
|
30
30
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
31
31
|
import { rufloRunDir } from './ruflo-bin.mjs';
|
|
32
32
|
import { currentTurnRecords } from './completion-claim-evidence.mjs';
|
|
33
33
|
import { recordHookFailure } from './hook-failures.mjs';
|
|
34
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
34
35
|
|
|
35
36
|
export const DECISION_SCHEMA = 'ruvnet-brain.decision-record';
|
|
36
37
|
export const STATUSES = Object.freeze(['ratified', 'detected-unconfirmed']);
|
|
@@ -160,6 +161,10 @@ export function storeDecision({ ruflo, db, namespace, key, value, store = defaul
|
|
|
160
161
|
const prior = readBack({ ruflo, db, namespace, key });
|
|
161
162
|
if (typeof prior.content === 'string' && prior.content === value) return { state: 'already-stored', key };
|
|
162
163
|
if (typeof prior.content === 'string' && prior.content && prior.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
|
|
164
|
+
if (storeEncryptedAtRest(db)) { // #439: the native-only store below is refused on every attempt; say why instead
|
|
165
|
+
recordHookFailure({ hook: 'decision-record', event: 'record', error: `not recorded: ${ENCRYPTED_STORE_REASON}`.slice(0, 160) });
|
|
166
|
+
return { state: 'refused', key, reason: ENCRYPTED_STORE_REASON };
|
|
167
|
+
}
|
|
163
168
|
const result = store({ ruflo, db, namespace, key, value });
|
|
164
169
|
const back = readBack({ ruflo, db, namespace, key });
|
|
165
170
|
if (result.status === 0 && back.content === value) return { state: 'committed', key };
|
|
@@ -300,7 +305,7 @@ export async function main(argv = process.argv.slice(2), { env = process.env, ou
|
|
|
300
305
|
out.write(`decision-record: supersedes ${key} — ${String(row.what).slice(0, 120)}\n`);
|
|
301
306
|
}
|
|
302
307
|
const r = storeDecision({ ruflo, db: target.db, namespace, key: built.key, value: built.value, ...(store ? { store } : {}), ...(readBack ? { readBack } : {}) });
|
|
303
|
-
out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.state === 'refused' ? ` — NOT recorded (logged to hook-failures). A store that committed but did not read back may still hold it: run \`ruflo memory retrieve -k ${r.key} -n ${namespace} --path ${target.db}\` before re-running --record` : ''}\n`);
|
|
308
|
+
out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.state === 'refused' && r.reason === ENCRYPTED_STORE_REASON ? ' — NOT recorded (logged to hook-failures). Keep this project\'s store unencrypted to record decisions here' : ''}${r.state === 'refused' && r.reason !== ENCRYPTED_STORE_REASON ? ` — NOT recorded (logged to hook-failures). A store that committed but did not read back may still hold it: run \`ruflo memory retrieve -k ${r.key} -n ${namespace} --path ${target.db}\` before re-running --record` : ''}\n`);
|
|
304
309
|
if (!['committed', 'already-stored'].includes(r.state)) return 3;
|
|
305
310
|
let ok = true;
|
|
306
311
|
for (const oldKey of built.record.supersedes) {
|
|
@@ -319,6 +324,6 @@ export async function main(argv = process.argv.slice(2), { env = process.env, ou
|
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
function isMain() {
|
|
322
|
-
try { return
|
|
327
|
+
try { return isDirectInvocation(import.meta.url); } catch { return false; }
|
|
323
328
|
}
|
|
324
329
|
if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { process.stderr.write(`decision-record: ${e.message}\n`); process.exitCode = 1; });
|
|
@@ -53,6 +53,7 @@ import path from 'node:path';
|
|
|
53
53
|
// reading the code and believing it.
|
|
54
54
|
import { fileURLToPath } from 'node:url';
|
|
55
55
|
import { rufloRunDir } from './ruflo-bin.mjs';
|
|
56
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
59
|
* THE PROBE TARGET IS A SCRATCH DB, NOT THE PROJECT'S.
|
|
@@ -264,7 +265,7 @@ export function cachedDegradations(opts = {}) {
|
|
|
264
265
|
* mechanism; the invariant this repo already paid for is that ONE process refuses a given tool call.
|
|
265
266
|
*/
|
|
266
267
|
const isMain = (() => {
|
|
267
|
-
try { return
|
|
268
|
+
try { return isDirectInvocation(import.meta.url); }
|
|
268
269
|
catch { return false; }
|
|
269
270
|
})();
|
|
270
271
|
|