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.
Files changed (260) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +618 -104
  3. package/console/activity.js +122 -38
  4. package/kb/download-retry.mjs +93 -21
  5. package/kb/forge-guard-injection.mjs +277 -0
  6. package/kb/forge-update.mjs +166 -38
  7. package/kb/lifecycle-evidence-retention.mjs +151 -14
  8. package/kb/model-requirements.mjs +4 -1
  9. package/kb/refresh-run.mjs +175 -27
  10. package/kb/verify-citation.mjs +15 -2
  11. package/package.json +3 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  15. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  16. package/plugin/scripts/advisory-contract.mjs +21 -0
  17. package/plugin/scripts/advocacy-route.mjs +5 -4
  18. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  19. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  20. package/plugin/scripts/agentdb-recall.mjs +49 -17
  21. package/plugin/scripts/automatic-update.mjs +9 -1
  22. package/plugin/scripts/brain-confirmation.mjs +2 -1
  23. package/plugin/scripts/brain-footprint.mjs +72 -11
  24. package/plugin/scripts/capability-registry.mjs +1 -1
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  26. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  27. package/plugin/scripts/completion-claim-evidence.mjs +241 -23
  28. package/plugin/scripts/continuation-gate.mjs +75 -11
  29. package/plugin/scripts/decision-gate.mjs +13 -63
  30. package/plugin/scripts/decision-outcomes.mjs +36 -0
  31. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  32. package/plugin/scripts/decision-record.mjs +8 -3
  33. package/plugin/scripts/degradation-watch.mjs +2 -1
  34. package/plugin/scripts/developer-update-lock.mjs +172 -33
  35. package/plugin/scripts/developer-update-owner.mjs +8 -5
  36. package/plugin/scripts/developer-update-policy.mjs +4 -1
  37. package/plugin/scripts/developer-update.mjs +155 -53
  38. package/plugin/scripts/doc-currency.mjs +2 -16
  39. package/plugin/scripts/duplicate-gate.mjs +2 -1
  40. package/plugin/scripts/entrypoint.mjs +34 -0
  41. package/plugin/scripts/footprint-io.mjs +135 -8
  42. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  43. package/plugin/scripts/ground-ruvnet.sh +6 -3
  44. package/plugin/scripts/grounding-answer.mjs +2 -1
  45. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  46. package/plugin/scripts/grounding-substance.mjs +2 -1
  47. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  48. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  49. package/plugin/scripts/hook-context-budget.mjs +2 -1
  50. package/plugin/scripts/hook-input.mjs +2 -2
  51. package/plugin/scripts/hook-shim.mjs +9 -2
  52. package/plugin/scripts/host-update.mjs +29 -5
  53. package/plugin/scripts/identifier-preflight.mjs +2 -1
  54. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  55. package/plugin/scripts/learn-capture.mjs +2 -1
  56. package/plugin/scripts/learning-store.mjs +62 -16
  57. package/plugin/scripts/lesson-bridge.mjs +2 -2
  58. package/plugin/scripts/lesson-gate.mjs +7 -39
  59. package/plugin/scripts/lesson-promote.mjs +83 -22
  60. package/plugin/scripts/lesson-scope.mjs +51 -0
  61. package/plugin/scripts/lesson-store.mjs +1 -1
  62. package/plugin/scripts/md-stamp.mjs +2 -1
  63. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  64. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  65. package/plugin/scripts/package-recommender.mjs +2 -2
  66. package/plugin/scripts/project-store-resolver.mjs +19 -0
  67. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  68. package/plugin/scripts/ruflo-bin.mjs +14 -6
  69. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  70. package/plugin/scripts/session-snapshot-hook.mjs +25 -5
  71. package/plugin/scripts/session-start-core.mjs +7 -16
  72. package/plugin/scripts/session-start-health.mjs +98 -10
  73. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  74. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  75. package/plugin/scripts/signal-watch.mjs +2 -1
  76. package/plugin/scripts/spend-guard.mjs +2 -1
  77. package/plugin/scripts/substitution-wall.mjs +2 -1
  78. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  79. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  80. package/plugin/scripts/update-apply.mjs +186 -15
  81. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  82. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  83. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  84. package/scripts/adr-072-completion.mjs +2 -1
  85. package/scripts/agentdb-context.mjs +2 -1
  86. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  87. package/scripts/approved-runtime.mjs +2 -1
  88. package/scripts/architecture-review-lock.mjs +2 -1
  89. package/scripts/ascii-drift.mjs +2 -1
  90. package/scripts/brain-latency-50.mjs +2 -1
  91. package/scripts/brain-novice-50.mjs +2 -16
  92. package/scripts/brain-score.mjs +13 -2
  93. package/scripts/build-bundle.mjs +2 -1
  94. package/scripts/build-concepts.mjs +2 -1
  95. package/scripts/candidate-ci-receipt.mjs +2 -1
  96. package/scripts/candidate-host-evidence.mjs +2 -1
  97. package/scripts/card-from-source.mjs +2 -1
  98. package/scripts/ci/warm-brain-models.mjs +2 -1
  99. package/scripts/claims-verify.mjs +7 -3
  100. package/scripts/claude-controlled-terminal.mjs +2 -1
  101. package/scripts/claude-terminal-mod.mjs +2 -1
  102. package/scripts/code-release-corpus.mjs +2 -1
  103. package/scripts/codex-boot-surface-proof.mjs +208 -0
  104. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  105. package/scripts/codex-hook-trust.mjs +40 -1
  106. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  107. package/scripts/codex-host-execution-proof.mjs +2 -1
  108. package/scripts/completion-claim-replay.mjs +2 -1
  109. package/scripts/console-engine.mjs +8 -3
  110. package/scripts/corpus-canary.mjs +2 -1
  111. package/scripts/corpus-candidate.mjs +2 -2
  112. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  113. package/scripts/corpus-dispatch-decision.mjs +2 -1
  114. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  115. package/scripts/corpus-next-seed.mjs +2 -1
  116. package/scripts/corpus-qa.mjs +2 -1
  117. package/scripts/corpus-reconcile.mjs +2 -2
  118. package/scripts/corpus-watchdog.mjs +2 -1
  119. package/scripts/count-chunks.mjs +2 -1
  120. package/scripts/customer-state-matrix.mjs +2 -1
  121. package/scripts/derive-passage-content-map.mjs +2 -1
  122. package/scripts/development-push-check.mjs +2 -1
  123. package/scripts/dispatch-receipt.mjs +2 -1
  124. package/scripts/doc-currency.mjs +2 -1
  125. package/scripts/dream-issue-gate.mjs +2 -1
  126. package/scripts/dual-host-deliberation.mjs +2 -16
  127. package/scripts/dual-host-suggest.mjs +2 -16
  128. package/scripts/eval-brain.mjs +7 -3
  129. package/scripts/execution-policy.mjs +2 -1
  130. package/scripts/execution-preflight.mjs +2 -1
  131. package/scripts/falsify.mjs +2 -1
  132. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  133. package/scripts/fix-workstream.mjs +2 -1
  134. package/scripts/full-hints.mjs +2 -1
  135. package/scripts/full-suite-gate.mjs +2 -1
  136. package/scripts/grok-host.mjs +68 -0
  137. package/scripts/hook-qualify-core.mjs +3 -1
  138. package/scripts/hook-qualify.mjs +2 -1
  139. package/scripts/hook-retirement-check.mjs +2 -1
  140. package/scripts/host-registry.mjs +2 -1
  141. package/scripts/independent-review-receipt.mjs +2 -1
  142. package/scripts/ingest-new-repos.mjs +2 -1
  143. package/scripts/ingest-repo.mjs +12 -2
  144. package/scripts/integration-evidence.mjs +2 -1
  145. package/scripts/issue-fix.mjs +7 -24
  146. package/scripts/issue-watch.mjs +116 -40
  147. package/scripts/job-heartbeat.sh +54 -19
  148. package/scripts/key-canary.mjs +9 -3
  149. package/scripts/latency-to-surface.mjs +2 -1
  150. package/scripts/learning-replay-cli.mjs +97 -19
  151. package/scripts/learning-replay-execution.mjs +2 -0
  152. package/scripts/learning-replay-fixture.mjs +3 -0
  153. package/scripts/learning-replay-proof.mjs +14 -13
  154. package/scripts/learning-replay.mjs +3 -3
  155. package/scripts/learnings.mjs +2 -1
  156. package/scripts/lesson-ratify.mjs +88 -40
  157. package/scripts/loop-checkpoint.mjs +2 -1
  158. package/scripts/metaharness-receipts.mjs +2 -1
  159. package/scripts/model-catalog.mjs +2 -1
  160. package/scripts/model-currency-evidence.mjs +6 -4
  161. package/scripts/model-currency.mjs +33 -3
  162. package/scripts/model-native-catalog.mjs +2 -1
  163. package/scripts/model-router-agent-hook.mjs +2 -1
  164. package/scripts/model-router-dispatch.mjs +2 -1
  165. package/scripts/model-router-engine.mjs +2 -1
  166. package/scripts/model-router-outcome.mjs +2 -1
  167. package/scripts/model-router-setup.mjs +2 -1
  168. package/scripts/model-routing-eval.mjs +2 -1
  169. package/scripts/model-routing-gateway.mjs +2 -1
  170. package/scripts/model-routing-launchers.mjs +4 -1
  171. package/scripts/model-terminal-gateway.mjs +2 -1
  172. package/scripts/model-terminal-launchers.mjs +2 -1
  173. package/scripts/model-weekly-analyst.mjs +2 -1
  174. package/scripts/model-weekly-cycle.mjs +2 -1
  175. package/scripts/model-weekly-qualification.mjs +2 -1
  176. package/scripts/nightly-gists.sh +26 -20
  177. package/scripts/nightly-two-run-proof.mjs +2 -1
  178. package/scripts/nightly-watchdog.mjs +158 -47
  179. package/scripts/no-silent-substitution.mjs +2 -1
  180. package/scripts/notify.sh +11 -6
  181. package/scripts/ntfy-inbox.mjs +41 -18
  182. package/scripts/onboarding-console.mjs +123 -23
  183. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  184. package/scripts/oracle/abstain-trace.mjs +2 -1
  185. package/scripts/oracle/doc2query-generate.mjs +2 -1
  186. package/scripts/oracle/doc2query-reach.mjs +2 -1
  187. package/scripts/oracle/judge-train.mjs +2 -1
  188. package/scripts/oracle/need-set-split.mjs +2 -1
  189. package/scripts/oracle/produce-questions.mjs +2 -16
  190. package/scripts/oracle/repo-recall.mjs +2 -2
  191. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  192. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  193. package/scripts/oracle/source-tree.mjs +2 -15
  194. package/scripts/oracle/source-units.mjs +2 -16
  195. package/scripts/oracle/spike-run.mjs +2 -16
  196. package/scripts/oracle/validate-labels.mjs +2 -16
  197. package/scripts/org-repo-count.mjs +2 -1
  198. package/scripts/package-cards.mjs +2 -1
  199. package/scripts/prepublication-evidence.mjs +2 -1
  200. package/scripts/private-overlay.mjs +2 -1
  201. package/scripts/product-integrity-contract.mjs +2 -1
  202. package/scripts/protected-release-invocation.mjs +2 -1
  203. package/scripts/public-verification-abandon.mjs +2 -1
  204. package/scripts/public-verification-aggregate.mjs +2 -1
  205. package/scripts/public-verification-finalizer.mjs +2 -1
  206. package/scripts/public-verification-inputs.mjs +2 -1
  207. package/scripts/public-verification-lane.mjs +2 -1
  208. package/scripts/publication-receipt.mjs +2 -1
  209. package/scripts/published-surface-probe.mjs +2 -1
  210. package/scripts/qe/card-lane-gate.mjs +2 -16
  211. package/scripts/qe/session-start-gate.mjs +2 -16
  212. package/scripts/qe/ux-suite.mjs +2 -1
  213. package/scripts/qualified-candidate-check.mjs +2 -1
  214. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  215. package/scripts/recall-latency-probe.mjs +48 -0
  216. package/scripts/recommendation-eval.mjs +2 -1
  217. package/scripts/recommendation-floor.mjs +2 -1
  218. package/scripts/recommendation-judge-score.mjs +2 -1
  219. package/scripts/recommendation-real-host-score.mjs +2 -1
  220. package/scripts/recommendation-real-host.mjs +2 -1
  221. package/scripts/refresh-capability-only-store.mjs +2 -1
  222. package/scripts/refresh-model-catalog.mjs +2 -1
  223. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  224. package/scripts/release-authority.mjs +2 -1
  225. package/scripts/release-evidence-aggregate.mjs +2 -1
  226. package/scripts/release-payload.mjs +2 -1
  227. package/scripts/release-projection.mjs +2 -1
  228. package/scripts/release-proof.mjs +2 -1
  229. package/scripts/release-qualification-contract.mjs +82 -28
  230. package/scripts/release-qualification.mjs +8 -1
  231. package/scripts/release-vector.mjs +2 -1
  232. package/scripts/restore-local-ingests.mjs +2 -1
  233. package/scripts/retrieval-canary.mjs +2 -1
  234. package/scripts/route-cheap.mjs +2 -1
  235. package/scripts/route-gold-rank.mjs +2 -1
  236. package/scripts/route-index-memory.mjs +2 -1
  237. package/scripts/route-latency-warm.mjs +2 -1
  238. package/scripts/router-utilization.mjs +2 -1
  239. package/scripts/routing-flywheel.mjs +2 -1
  240. package/scripts/run-operational-benchmark.mjs +2 -1
  241. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  242. package/scripts/rvf-index-audit.mjs +2 -16
  243. package/scripts/seal-gist-receipt.mjs +2 -1
  244. package/scripts/security-guidance-codex-compat.mjs +2 -1
  245. package/scripts/selfcheck.mjs +2 -1
  246. package/scripts/signal-watch.mjs +2 -1
  247. package/scripts/source-coverage.mjs +2 -1
  248. package/scripts/stabilization-receipt.mjs +2 -1
  249. package/scripts/stamp-sweep.mjs +2 -1
  250. package/scripts/status-honesty.mjs +2 -1
  251. package/scripts/sync-commands.mjs +2 -2
  252. package/scripts/top100-benchmark.mjs +2 -1
  253. package/scripts/top100-corpus.mjs +2 -1
  254. package/scripts/user-model-hook-install.mjs +2 -1
  255. package/scripts/user-model-prompt-hook.mjs +2 -1
  256. package/scripts/verify-bundle.mjs +3 -1
  257. package/scripts/verify-model-catalog.mjs +2 -1
  258. package/scripts/version.mjs +2 -1
  259. package/scripts/wired-check.mjs +13 -5
  260. 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, claimClosesPromise,
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
- fs.writeFileSync(LEDGER, JSON.stringify({ ...led, updated: new Date().toISOString() }, null, 2) + '\n');
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 are parsed; Codex's rollout format is not
370
- * parsed anywhere in this repo, so on Codex only the answer-side half is enforced and the transcript
371
- * half is reported UNKNOWN (completion-claim-evidence.mjs header). Fails open: a throw is NONE.
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 = HOST === 'claude' ? readClaudeTurn(hookInput.transcript_path) : null;
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
- return auditCompletionClaims(message, { turn, host: HOST });
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.claims.find((c) => claimClosesPromise(c.text, item.text));
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
- const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) ? [{
719
- 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.`,
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
- try { fs.writeFileSync(LOCK, new Date(now).toISOString(), { flag: 'wx' }); return true; }
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, and ONE stderr line says the allow was a
97
- * timeout, not a verdict (reportBudget below).
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 `if (isMain())`
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 `if (isMain())` block below runs DURING module
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
- if (isMain()) {
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: 400 }); } catch { return { code: ALLOW }; }
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 { fs.writeFileSync(file, JSON.stringify(acks)); } catch { /* the ledger never blocks a write */ }
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 }); fs.writeFileSync(file, JSON.stringify(acks), { mode: 0o600 }); } catch { return { code: ALLOW }; }
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 Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
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 Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(SCRIPT); } catch { return false; }
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 process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
268
+ try { return isDirectInvocation(import.meta.url); }
268
269
  catch { return false; }
269
270
  })();
270
271