ruvnet-brain 4.7.20 → 4.7.21

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 (250) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +494 -97
  3. package/kb/download-retry.mjs +93 -21
  4. package/kb/forge-guard-injection.mjs +277 -0
  5. package/kb/forge-update.mjs +166 -38
  6. package/kb/lifecycle-evidence-retention.mjs +151 -14
  7. package/kb/model-requirements.mjs +4 -1
  8. package/kb/refresh-run.mjs +175 -27
  9. package/kb/verify-citation.mjs +15 -2
  10. package/package.json +3 -1
  11. package/plugin/.claude-plugin/plugin.json +1 -1
  12. package/plugin/.codex-plugin/plugin.json +1 -1
  13. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  14. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  15. package/plugin/scripts/advisory-contract.mjs +21 -0
  16. package/plugin/scripts/advocacy-route.mjs +5 -4
  17. package/plugin/scripts/agentdb-recall.mjs +15 -5
  18. package/plugin/scripts/automatic-update.mjs +9 -1
  19. package/plugin/scripts/brain-confirmation.mjs +2 -1
  20. package/plugin/scripts/brain-footprint.mjs +72 -11
  21. package/plugin/scripts/capability-registry.mjs +1 -1
  22. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  23. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  24. package/plugin/scripts/completion-claim-evidence.mjs +56 -12
  25. package/plugin/scripts/continuation-gate.mjs +28 -9
  26. package/plugin/scripts/decision-gate.mjs +13 -63
  27. package/plugin/scripts/decision-outcomes.mjs +36 -0
  28. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  29. package/plugin/scripts/decision-record.mjs +8 -3
  30. package/plugin/scripts/degradation-watch.mjs +2 -1
  31. package/plugin/scripts/developer-update-lock.mjs +172 -33
  32. package/plugin/scripts/developer-update-owner.mjs +8 -5
  33. package/plugin/scripts/developer-update-policy.mjs +4 -1
  34. package/plugin/scripts/developer-update.mjs +155 -53
  35. package/plugin/scripts/doc-currency.mjs +2 -16
  36. package/plugin/scripts/duplicate-gate.mjs +2 -1
  37. package/plugin/scripts/entrypoint.mjs +34 -0
  38. package/plugin/scripts/footprint-io.mjs +135 -8
  39. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  40. package/plugin/scripts/ground-ruvnet.sh +6 -3
  41. package/plugin/scripts/grounding-answer.mjs +2 -1
  42. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  43. package/plugin/scripts/grounding-substance.mjs +2 -1
  44. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  45. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  46. package/plugin/scripts/hook-context-budget.mjs +2 -1
  47. package/plugin/scripts/hook-input.mjs +2 -2
  48. package/plugin/scripts/hook-shim.mjs +9 -2
  49. package/plugin/scripts/host-update.mjs +29 -5
  50. package/plugin/scripts/identifier-preflight.mjs +2 -1
  51. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  52. package/plugin/scripts/learn-capture.mjs +2 -1
  53. package/plugin/scripts/learning-store.mjs +62 -16
  54. package/plugin/scripts/lesson-bridge.mjs +2 -2
  55. package/plugin/scripts/lesson-promote.mjs +83 -22
  56. package/plugin/scripts/md-stamp.mjs +2 -1
  57. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  58. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  59. package/plugin/scripts/package-recommender.mjs +2 -2
  60. package/plugin/scripts/project-store-resolver.mjs +19 -0
  61. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  62. package/plugin/scripts/session-snapshot-hook.mjs +24 -4
  63. package/plugin/scripts/session-start-core.mjs +5 -15
  64. package/plugin/scripts/session-start-health.mjs +97 -10
  65. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  66. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  67. package/plugin/scripts/signal-watch.mjs +2 -1
  68. package/plugin/scripts/spend-guard.mjs +2 -1
  69. package/plugin/scripts/substitution-wall.mjs +2 -1
  70. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  71. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  72. package/plugin/scripts/update-apply.mjs +186 -15
  73. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  74. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  75. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  76. package/scripts/adr-072-completion.mjs +2 -1
  77. package/scripts/agentdb-context.mjs +2 -1
  78. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  79. package/scripts/approved-runtime.mjs +2 -1
  80. package/scripts/architecture-review-lock.mjs +2 -1
  81. package/scripts/ascii-drift.mjs +2 -1
  82. package/scripts/brain-latency-50.mjs +2 -1
  83. package/scripts/brain-novice-50.mjs +2 -16
  84. package/scripts/brain-score.mjs +13 -2
  85. package/scripts/build-bundle.mjs +2 -1
  86. package/scripts/build-concepts.mjs +2 -1
  87. package/scripts/candidate-ci-receipt.mjs +2 -1
  88. package/scripts/candidate-host-evidence.mjs +2 -1
  89. package/scripts/card-from-source.mjs +2 -1
  90. package/scripts/ci/warm-brain-models.mjs +2 -1
  91. package/scripts/claims-verify.mjs +7 -3
  92. package/scripts/claude-controlled-terminal.mjs +2 -1
  93. package/scripts/claude-terminal-mod.mjs +2 -1
  94. package/scripts/code-release-corpus.mjs +2 -1
  95. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  96. package/scripts/codex-hook-trust.mjs +40 -1
  97. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  98. package/scripts/codex-host-execution-proof.mjs +2 -1
  99. package/scripts/completion-claim-replay.mjs +2 -1
  100. package/scripts/console-engine.mjs +8 -3
  101. package/scripts/corpus-canary.mjs +2 -1
  102. package/scripts/corpus-candidate.mjs +2 -2
  103. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  104. package/scripts/corpus-dispatch-decision.mjs +2 -1
  105. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  106. package/scripts/corpus-next-seed.mjs +2 -1
  107. package/scripts/corpus-qa.mjs +2 -1
  108. package/scripts/corpus-reconcile.mjs +2 -2
  109. package/scripts/corpus-watchdog.mjs +2 -1
  110. package/scripts/count-chunks.mjs +2 -1
  111. package/scripts/customer-state-matrix.mjs +2 -1
  112. package/scripts/derive-passage-content-map.mjs +2 -1
  113. package/scripts/development-push-check.mjs +2 -1
  114. package/scripts/dispatch-receipt.mjs +2 -1
  115. package/scripts/doc-currency.mjs +2 -1
  116. package/scripts/dream-issue-gate.mjs +2 -1
  117. package/scripts/dual-host-deliberation.mjs +2 -16
  118. package/scripts/dual-host-suggest.mjs +2 -16
  119. package/scripts/eval-brain.mjs +7 -3
  120. package/scripts/execution-policy.mjs +2 -1
  121. package/scripts/execution-preflight.mjs +2 -1
  122. package/scripts/falsify.mjs +2 -1
  123. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  124. package/scripts/fix-workstream.mjs +2 -1
  125. package/scripts/full-hints.mjs +2 -1
  126. package/scripts/full-suite-gate.mjs +2 -1
  127. package/scripts/grok-host.mjs +68 -0
  128. package/scripts/hook-qualify-core.mjs +3 -1
  129. package/scripts/hook-qualify.mjs +2 -1
  130. package/scripts/hook-retirement-check.mjs +2 -1
  131. package/scripts/host-registry.mjs +2 -1
  132. package/scripts/independent-review-receipt.mjs +2 -1
  133. package/scripts/ingest-new-repos.mjs +2 -1
  134. package/scripts/ingest-repo.mjs +12 -2
  135. package/scripts/integration-evidence.mjs +2 -1
  136. package/scripts/issue-fix.mjs +7 -24
  137. package/scripts/issue-watch.mjs +116 -40
  138. package/scripts/job-heartbeat.sh +54 -19
  139. package/scripts/key-canary.mjs +9 -3
  140. package/scripts/latency-to-surface.mjs +2 -1
  141. package/scripts/learning-replay-cli.mjs +97 -19
  142. package/scripts/learning-replay-execution.mjs +2 -0
  143. package/scripts/learning-replay-fixture.mjs +3 -0
  144. package/scripts/learning-replay-proof.mjs +14 -13
  145. package/scripts/learning-replay.mjs +3 -3
  146. package/scripts/learnings.mjs +2 -1
  147. package/scripts/lesson-ratify.mjs +88 -40
  148. package/scripts/loop-checkpoint.mjs +2 -1
  149. package/scripts/metaharness-receipts.mjs +2 -1
  150. package/scripts/model-catalog.mjs +2 -1
  151. package/scripts/model-currency-evidence.mjs +6 -4
  152. package/scripts/model-currency.mjs +33 -3
  153. package/scripts/model-native-catalog.mjs +2 -1
  154. package/scripts/model-router-agent-hook.mjs +2 -1
  155. package/scripts/model-router-dispatch.mjs +2 -1
  156. package/scripts/model-router-engine.mjs +2 -1
  157. package/scripts/model-router-outcome.mjs +2 -1
  158. package/scripts/model-router-setup.mjs +2 -1
  159. package/scripts/model-routing-eval.mjs +2 -1
  160. package/scripts/model-routing-gateway.mjs +2 -1
  161. package/scripts/model-routing-launchers.mjs +4 -1
  162. package/scripts/model-terminal-gateway.mjs +2 -1
  163. package/scripts/model-terminal-launchers.mjs +2 -1
  164. package/scripts/model-weekly-analyst.mjs +2 -1
  165. package/scripts/model-weekly-cycle.mjs +2 -1
  166. package/scripts/model-weekly-qualification.mjs +2 -1
  167. package/scripts/nightly-gists.sh +26 -20
  168. package/scripts/nightly-two-run-proof.mjs +2 -1
  169. package/scripts/nightly-watchdog.mjs +158 -47
  170. package/scripts/no-silent-substitution.mjs +2 -1
  171. package/scripts/notify.sh +11 -6
  172. package/scripts/ntfy-inbox.mjs +41 -18
  173. package/scripts/onboarding-console.mjs +47 -8
  174. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  175. package/scripts/oracle/abstain-trace.mjs +2 -1
  176. package/scripts/oracle/doc2query-generate.mjs +2 -1
  177. package/scripts/oracle/doc2query-reach.mjs +2 -1
  178. package/scripts/oracle/judge-train.mjs +2 -1
  179. package/scripts/oracle/need-set-split.mjs +2 -1
  180. package/scripts/oracle/produce-questions.mjs +2 -16
  181. package/scripts/oracle/repo-recall.mjs +2 -2
  182. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  183. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  184. package/scripts/oracle/source-tree.mjs +2 -15
  185. package/scripts/oracle/source-units.mjs +2 -16
  186. package/scripts/oracle/spike-run.mjs +2 -16
  187. package/scripts/oracle/validate-labels.mjs +2 -16
  188. package/scripts/org-repo-count.mjs +2 -1
  189. package/scripts/package-cards.mjs +2 -1
  190. package/scripts/prepublication-evidence.mjs +2 -1
  191. package/scripts/private-overlay.mjs +2 -1
  192. package/scripts/product-integrity-contract.mjs +2 -1
  193. package/scripts/protected-release-invocation.mjs +2 -1
  194. package/scripts/public-verification-abandon.mjs +2 -1
  195. package/scripts/public-verification-aggregate.mjs +2 -1
  196. package/scripts/public-verification-finalizer.mjs +2 -1
  197. package/scripts/public-verification-inputs.mjs +2 -1
  198. package/scripts/public-verification-lane.mjs +2 -1
  199. package/scripts/publication-receipt.mjs +2 -1
  200. package/scripts/published-surface-probe.mjs +2 -1
  201. package/scripts/qe/card-lane-gate.mjs +2 -16
  202. package/scripts/qe/session-start-gate.mjs +2 -16
  203. package/scripts/qe/ux-suite.mjs +2 -1
  204. package/scripts/qualified-candidate-check.mjs +2 -1
  205. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  206. package/scripts/recommendation-eval.mjs +2 -1
  207. package/scripts/recommendation-floor.mjs +2 -1
  208. package/scripts/recommendation-judge-score.mjs +2 -1
  209. package/scripts/recommendation-real-host-score.mjs +2 -1
  210. package/scripts/recommendation-real-host.mjs +2 -1
  211. package/scripts/refresh-capability-only-store.mjs +2 -1
  212. package/scripts/refresh-model-catalog.mjs +2 -1
  213. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  214. package/scripts/release-authority.mjs +2 -1
  215. package/scripts/release-evidence-aggregate.mjs +2 -1
  216. package/scripts/release-payload.mjs +2 -1
  217. package/scripts/release-projection.mjs +2 -1
  218. package/scripts/release-proof.mjs +2 -1
  219. package/scripts/release-qualification-contract.mjs +64 -28
  220. package/scripts/release-qualification.mjs +8 -1
  221. package/scripts/release-vector.mjs +2 -1
  222. package/scripts/restore-local-ingests.mjs +2 -1
  223. package/scripts/retrieval-canary.mjs +2 -1
  224. package/scripts/route-cheap.mjs +2 -1
  225. package/scripts/route-gold-rank.mjs +2 -1
  226. package/scripts/route-index-memory.mjs +2 -1
  227. package/scripts/route-latency-warm.mjs +2 -1
  228. package/scripts/router-utilization.mjs +2 -1
  229. package/scripts/routing-flywheel.mjs +2 -1
  230. package/scripts/run-operational-benchmark.mjs +2 -1
  231. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  232. package/scripts/rvf-index-audit.mjs +2 -16
  233. package/scripts/seal-gist-receipt.mjs +2 -1
  234. package/scripts/security-guidance-codex-compat.mjs +2 -1
  235. package/scripts/selfcheck.mjs +2 -1
  236. package/scripts/signal-watch.mjs +2 -1
  237. package/scripts/source-coverage.mjs +2 -1
  238. package/scripts/stabilization-receipt.mjs +2 -1
  239. package/scripts/stamp-sweep.mjs +2 -1
  240. package/scripts/status-honesty.mjs +2 -1
  241. package/scripts/sync-commands.mjs +2 -2
  242. package/scripts/top100-benchmark.mjs +2 -1
  243. package/scripts/top100-corpus.mjs +2 -1
  244. package/scripts/user-model-hook-install.mjs +2 -1
  245. package/scripts/user-model-prompt-hook.mjs +2 -1
  246. package/scripts/verify-bundle.mjs +3 -1
  247. package/scripts/verify-model-catalog.mjs +2 -1
  248. package/scripts/version.mjs +2 -1
  249. package/scripts/wired-check.mjs +11 -5
  250. package/scripts/worktree-integrity.mjs +2 -2
@@ -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
 
@@ -3,41 +3,180 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
6
7
  const brainRoot = brainHome => brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache/ruvnet-brain');
7
- export function sharedLockStatus({ brainHome, alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } } = {}) {
8
+ const pidAlive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
9
+ // A dead or reclaimable owner must have been gone this long; an unreadable owner is judged by the directory's age.
10
+ export const STALE_RECLAIM_MS = 10 * 60 * 1000;
11
+ // An owner-less lock younger than this is another process between creating the lock and writing its owner.
12
+ export const OWNERLESS_GRACE_MS = 5 * 1000;
13
+ /** The OS start time of `pid` (`ps -o lstart=`, as kb/refresh-run.mjs records it), or null when it cannot be read (Windows). */
14
+ export function processStartOf(pid, { platform = process.platform } = {}) {
15
+ if (platform === 'win32' || !Number.isSafeInteger(pid) || pid < 1) return null;
16
+ try {
17
+ const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: 5000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
18
+ const start = String(r.stdout || '').trim();
19
+ return r.status === 0 && start ? start : null;
20
+ } catch { return null; }
21
+ }
22
+ const removal = directory => process.platform === 'win32' ? `Remove-Item -Recurse -Force "${directory}"` : `rm -rf "${directory}"`;
23
+ // ── takeover is serialized (4.7.21 review, Codex #1) ─────────────────────────────────────────────
24
+ // Two contenders could judge the same abandoned lock: A moved it aside and acquired, then B moved A's FRESH lock
25
+ // aside on its stale judgement and both ran. A takeover now (1) creates an exclusive claim file inside the judged
26
+ // directory ('wx': one winner per directory instance), (2) re-checks, while holding the claim, that it is the same
27
+ // directory (dev/ino) with byte-identical owner.json, and only then (3) moves it aside. A claim left by a crashed
28
+ // reclaimer is superseded by the next numbered claim once CLAIM_STALE_MS old.
29
+ // Round 4 (Codex #1): a lock that existed before its owner record (mkdir, then write) could be judged owner-less and old,
30
+ // claimed, re-checked (still owner-less, so "the same") and moved aside together with the owner published in between:
31
+ // two owners. The lock is now built complete in a staging sibling and published by ONE rename, so it never exists
32
+ // without its owner; a rename onto an existing lock fails (ENOTEMPTY/EEXIST, EPERM/EACCES on Windows) and is re-judged.
33
+ export const CLAIM_STALE_MS = 5 * 60 * 1000;
34
+ const CLAIM_FILE = /^takeover(?:-(\d+))?\.json$/;
35
+ function observeDirectory(directory) {
36
+ const stat = fs.lstatSync(directory, { bigint: true });
37
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('not an owned directory');
38
+ let raw = null;
39
+ try { raw = fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
40
+ return { identity: `${stat.dev}:${stat.ino}`, raw };
41
+ }
42
+ const readRaw = file => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
43
+ // A claim file must not restart the age an owner-less lock is judged by (Opus r2 #6): writing or removing one bumps the
44
+ // directory's mtime, so the directory's own times are put back.
45
+ function keepDirectoryTimes(directory, change) {
46
+ let before = null;
47
+ try { before = fs.lstatSync(directory); } catch { /* judged below */ }
48
+ try { return change(); } finally { if (before) try { fs.lutimesSync(directory, before.atime, before.mtime); } catch { /* gone */ } }
49
+ }
50
+ const GONE = Symbol('judged lock vanished');
51
+ function claimTakeover(directory) {
52
+ let highest = -1, highestName = null, names;
53
+ // The judged lock vanished (another reclaimer moved it): re-judge rather than throw (Fable r2 P3-1).
54
+ try { names = fs.readdirSync(directory); } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return GONE; throw error; }
55
+ for (const name of names) {
56
+ const match = CLAIM_FILE.exec(name);
57
+ if (match && (match[1] ? Number(match[1]) : 0) > highest) { highest = match[1] ? Number(match[1]) : 0; highestName = name; }
58
+ }
59
+ if (highestName) {
60
+ let ageMs = 0;
61
+ try { ageMs = Date.now() - fs.lstatSync(path.join(directory, highestName)).mtimeMs; } catch { return null; }
62
+ if (!(ageMs > CLAIM_STALE_MS)) return null; // another reclaimer is mid-takeover
63
+ }
64
+ const level = highest + 1;
65
+ const file = path.join(directory, level ? `takeover-${level}.json` : 'takeover.json');
66
+ const token = crypto.randomUUID();
67
+ try { keepDirectoryTimes(directory, () => fs.writeFileSync(file, JSON.stringify({ token, pid: process.pid, claimedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 })); }
68
+ catch (error) { if (error.code === 'EEXIST') return null; if (error.code === 'ENOENT') return GONE; throw error; }
69
+ return { file, token };
70
+ }
71
+ const dropClaim = claim => { try { if (readRaw(claim.file)?.includes(claim.token)) keepDirectoryTimes(path.dirname(claim.file), () => fs.unlinkSync(claim.file)); } catch { /* gone with its directory */ } };
72
+ /** Move `directory` aside only if it is still exactly the one observed: 'removed' | 'changed' | 'busy'. */
73
+ function removeObservedLock(directory, observed, { afterClaim = null } = {}) {
74
+ const claim = claimTakeover(directory);
75
+ if (claim === GONE) return 'changed';
76
+ if (!claim) return 'busy';
77
+ afterClaim?.({ directory, claim });
78
+ let same = false;
79
+ try {
80
+ const now = observeDirectory(directory);
81
+ same = now.identity === observed.identity && now.raw === observed.raw && Boolean(readRaw(claim.file)?.includes(claim.token));
82
+ } catch { same = false; }
83
+ if (!same) { dropClaim(claim); return 'changed'; }
84
+ const tomb = `${directory}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
85
+ try { fs.renameSync(directory, tomb); } catch (error) { if (error.code === 'ENOENT') return 'changed'; dropClaim(claim); throw error; }
86
+ if (!readRaw(path.join(tomb, path.basename(claim.file)))?.includes(claim.token)) {
87
+ try { if (!fs.existsSync(directory)) fs.renameSync(tomb, directory); } catch { /* keep it as evidence */ }
88
+ throw Error(`developer update lock takeover moved a directory it did not claim; ${directory}`);
89
+ }
90
+ fs.rmSync(tomb, { recursive: true, force: true });
91
+ return 'removed';
92
+ }
93
+ /** Publish the lock with its owner already inside: one rename, which fails when any populated lock is there. */
94
+ const CONTENDED = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES']);
95
+ function publishLock(directory, content) {
96
+ const staged = `${directory}.acquire-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
97
+ const dropStaged = () => { try { fs.rmSync(staged, { recursive: true, force: true }); } catch { /* best effort */ } };
98
+ try {
99
+ fs.mkdirSync(staged, { mode: 0o700 });
100
+ fs.writeFileSync(path.join(staged, 'owner.json'), content, { flag: 'wx', mode: 0o600 });
101
+ } catch (error) { dropStaged(); throw error; } // staging failures are real errors, never contention
102
+ try { fs.renameSync(staged, directory); return true; } catch (error) {
103
+ dropStaged();
104
+ // A refused publication is contention even if the lock is gone a moment later: its owner can release in between,
105
+ // and re-checking existsSync here threw a raw ENOTEMPTY at the caller (Opus r3 P2-1). Re-judge (bounded by the caller).
106
+ if (CONTENDED.has(error.code)) return false;
107
+ throw error;
108
+ }
109
+ }
110
+ export function sharedLockStatus({ brainHome, alive = pidAlive, startOf = processStartOf } = {}) {
8
111
  const directory = path.join(brainRoot(brainHome), 'developer-update.lock');
112
+ let status = judgeLock(directory, alive, startOf);
113
+ // An owner-less reading can be the instant between one owner's release and the next owner's publication: the lock was
114
+ // gone (or moved aside before owner.json was read) and a new owner's lock was there when existence was checked. That
115
+ // live lock was judged "unknown" and the refusal offered rm -rf (Opus r3 P2-2). Look again before calling it unknown.
116
+ for (let look = 0; look < 2 && status.state === 'unknown' && (status.observed === null || status.observed.raw === null); look++) {
117
+ let again = null;
118
+ try { again = observeDirectory(directory); } catch { /* vanished or unjudgeable: judged afresh */ }
119
+ if (again && status.observed && again.identity === status.observed.identity && again.raw === null) break; // the same owner-less directory
120
+ status = judgeLock(directory, alive, startOf);
121
+ }
122
+ return status;
123
+ }
124
+ function judgeLock(directory, alive, startOf) {
125
+ let observed = null;
9
126
  try {
10
- if (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) throw Error('not an owned directory');
11
- const owner = JSON.parse(fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'));
127
+ observed = observeDirectory(directory);
128
+ if (observed.raw === null) throw Object.assign(Error('owner.json absent'), { code: 'ENOENT' });
129
+ const owner = JSON.parse(observed.raw);
12
130
  if (!Number.isInteger(owner.pid) || owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length < 16) throw Error('invalid owner');
13
- return { state: alive(owner.pid) ? 'running' : 'stale', directory, owner };
14
- } catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message }; }
15
- }
16
- export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive } = {}) {
17
- const status = sharedLockStatus({ brainHome, alive });
18
- if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
19
- // A dead owner's lock (dead for 10+ minutes) must not wedge every future update (measured 2026-10-15: a coordinator killed mid-run
20
- // left `stale` behind and the automatic updater refused with "developer update lock stale" until a person
21
- // removed the directory). Reclaim it: move the directory aside atomically (so two reclaimers cannot both
22
- // win), then fall through to the normal atomic mkdir claim. Only a PROVABLY dead pid qualifies; a live,
23
- // unknown or malformed lock still refuses.
24
- const STALE_RECLAIM_MS = 10 * 60 * 1000;
25
- const ageMs = status.state === 'stale' ? Date.now() - Date.parse(status.owner?.startedAt || '') : NaN;
26
- if (status.state === 'stale' && Number.isFinite(ageMs) && ageMs >= STALE_RECLAIM_MS) {
27
- const tomb = `${status.directory}.stale-${process.pid}-${Date.now()}`;
28
- try { fs.renameSync(status.directory, tomb); fs.rmSync(tomb, { recursive: true, force: true }); }
29
- catch { /* another reclaimer won, or the owner came back: re-read below */ }
30
- const after = sharedLockStatus({ brainHome, alive });
31
- if (after.state !== 'idle') throw Error(`developer update lock ${after.state}; ${after.directory}`);
32
- } else if (status.state !== 'idle') throw Error(`developer update lock ${status.state}; ${status.directory}`);
33
- fs.mkdirSync(path.dirname(status.directory), { recursive: true });
34
- fs.mkdirSync(status.directory, { mode: 0o700 }); // atomic claim; racing owners get EEXIST
35
- const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString() };
36
- try { fs.writeFileSync(path.join(status.directory, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 }); }
37
- catch (error) { fs.rmdirSync(status.directory); throw error; }
38
- return { token: owner.token, ownerPid: pid, inherited: false, release() {
39
- const current = sharedLockStatus({ brainHome, alive });
40
- if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
41
- fs.unlinkSync(path.join(status.directory, 'owner.json')); fs.rmdirSync(status.directory);
42
- } };
131
+ if (!alive(owner.pid)) return { state: 'stale', directory, owner, observed };
132
+ // A live pid is the owner only when the recorded process start matches: a reused pid is another process.
133
+ const seen = typeof owner.processStart === 'string' && owner.processStart ? startOf(owner.pid) : null;
134
+ if (seen && seen !== owner.processStart) return { state: 'stale', directory, owner, reusedPid: true, observed };
135
+ return { state: 'running', directory, owner, observed };
136
+ } catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message, observed }; }
137
+ }
138
+ export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive, startOf = processStartOf, now = Date.now(), afterTakeoverClaim = null } = {}) {
139
+ for (let attempt = 0; attempt < 3; attempt++) {
140
+ const status = sharedLockStatus({ brainHome, alive, startOf });
141
+ if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
142
+ // A dead owner's lock must not wedge every future update (measured 2026-10-15: a coordinator killed mid-run left `stale`
143
+ // behind and the automatic updater refused until a person removed the directory). Reclaimable: a provably dead or reused-pid
144
+ // owner, or a directory with no readable owner (a kill between mkdir and the owner write), once STALE_RECLAIM_MS old by its
145
+ // recorded start or, failing that, the directory's own mtime. The takeover is claimed and re-checked (above), then the
146
+ // normal atomic mkdir claim follows. A live owner, or anything younger, still refuses.
147
+ let ageMs = NaN;
148
+ if (status.state === 'stale' || status.state === 'unknown') {
149
+ ageMs = now - Date.parse(status.owner?.startedAt || '');
150
+ if (!Number.isFinite(ageMs)) try { ageMs = now - fs.lstatSync(status.directory).mtimeMs; } catch { ageMs = NaN; }
151
+ }
152
+ const refuse = state => Error(`developer update lock ${state}; ${status.directory}. If no update is running on this machine, remove it: ${removal(status.directory)}`);
153
+ // A lock with no owner yet that is seconds old is another process mid-creation (agentic-kit writes its owner after its
154
+ // mkdir), not debris: never offer to remove it (Opus r3 P2-2).
155
+ if (status.state === 'unknown' && status.observed?.raw === null && Number.isFinite(ageMs) && ageMs < OWNERLESS_GRACE_MS) {
156
+ throw Error(`developer update lock is being created by another process; ${status.directory}. Retry shortly.`);
157
+ }
158
+ if ((status.state === 'stale' || status.state === 'unknown') && Number.isFinite(ageMs) && ageMs >= STALE_RECLAIM_MS) {
159
+ if (!status.observed) throw refuse(status.state); // not a lock directory this protocol can judge (a symlink or a file)
160
+ const outcome = removeObservedLock(status.directory, status.observed, { afterClaim: afterTakeoverClaim });
161
+ if (outcome === 'busy') throw refuse('is being reclaimed by another process');
162
+ continue; // 'removed' or 'changed': judge the directory afresh
163
+ } else if (status.state !== 'idle') throw refuse(status.state);
164
+ fs.mkdirSync(path.dirname(status.directory), { recursive: true });
165
+ const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString(), processStart: startOf(pid) };
166
+ if (!publishLock(status.directory, JSON.stringify(owner))) continue; // judge the lock that won
167
+ return { token: owner.token, ownerPid: pid, inherited: false, release() {
168
+ const current = sharedLockStatus({ brainHome, alive, startOf });
169
+ if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
170
+ // Move the whole lock aside in one step, then confirm it was this owner's: removing owner.json first left an empty
171
+ // directory that a contender's publication rename could replace, and the follow-up removal deleted that live lock.
172
+ const tomb = `${status.directory}.released-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
173
+ fs.renameSync(status.directory, tomb);
174
+ if (!readRaw(path.join(tomb, 'owner.json'))?.includes(owner.token)) {
175
+ try { if (!fs.existsSync(status.directory)) fs.renameSync(tomb, status.directory); } catch { /* keep it as evidence */ }
176
+ throw Error('developer update lock ownership changed');
177
+ }
178
+ fs.rmSync(tomb, { recursive: true, force: true });
179
+ } };
180
+ }
181
+ throw Error(`developer update lock is contended; ${path.join(brainRoot(brainHome), 'developer-update.lock')}. Retry shortly.`);
43
182
  }
@@ -3,20 +3,23 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
- import { EXECUTION_MODULES } from './developer-update-policy.mjs';
6
+ import { EXECUTION_MODULES, OPTIONAL_EXECUTION_MODULES } from './developer-update-policy.mjs';
7
7
  const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
8
8
  export function developerCoordinatorOwner({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
9
9
  const config = path.join(brainHome, 'developer-update-config.json'), registration = path.join(brainHome, 'scheduler/registration.json');
10
- let record;
11
- try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch { /* handled below */ }
12
- const configured = fs.existsSync(config) || record?.mode === 'developer-suite';
10
+ let record, registered = true;
11
+ try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch (error) { registered = error.code !== 'ENOENT'; }
12
+ // A config left behind by --disable-nightly (no registration at all) owns nothing: refusing every automatic update
13
+ // as "not ready" forever would wedge the machine (AuditC #11). An unreadable registration still refuses.
14
+ const configured = (fs.existsSync(config) && registered) || record?.mode === 'developer-suite';
13
15
  if (!configured) return { active: false, ready: false };
14
16
  const entry = record?.updateModules?.['developer-update.mjs'];
15
17
  try {
16
18
  if (record?.mode !== 'developer-suite' || !path.isAbsolute(entry?.path || '') || !/^[a-f0-9]{64}$/.test(entry?.sha256 || '')
17
19
  || hash(entry.path) !== entry.sha256 || hash(record.runnerPath) !== record.runnerSha256) throw Error('registered source is absent or changed');
18
20
  if (!record.updateModules?.['plugin-artifact-proof.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw Error('new coordinator artifact closure is incomplete');
19
- const generation = EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || record.updateModules?.[name]);
21
+ if (!record.updateModules?.['entrypoint.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./entrypoint.mjs')) throw Error('coordinator closure is missing entrypoint.mjs');
22
+ const generation = EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name) || record.updateModules?.[name]);
20
23
  if (generation.some(name => !record.updateModules?.[name])) throw Error('coordinator execution closure is incomplete');
21
24
  for (const module of Object.values(record.updateModules)) if (hash(module.path) !== module.sha256) throw Error('registered module closure changed');
22
25
  return { active: true, ready: true, entry: entry.path, node: record.nodePath, registration, sourceSha256: entry.sha256 };
@@ -1,4 +1,7 @@
1
- export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs']);
1
+ export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs', 'entrypoint.mjs']);
2
+ // Members a coordinator generation may lack: each is required exactly when the staged developer-update.mjs imports it
3
+ // (plugin-artifact-proof since 4.6, entrypoint since the 4.7.21 entry-point sweep), so older registrations stay valid.
4
+ export const OPTIONAL_EXECUTION_MODULES = Object.freeze(['plugin-artifact-proof.mjs', 'entrypoint.mjs']);
2
5
  // Shared stack classification and ordering: installations are selected, never manufactured.
3
6
  export const FAMILY = /^(ruflo|ruvnet-brain|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|flow-nexus|agent-browser|agent-browser-mcp|agentic-flow|agentic-qe|agentic-robotics|agentic-payments|agentdb|ruv-swarm|@pacphi\/agentic-kit$|@ruvector\/|@claude-flow\/|@metaharness\/|@agentic-robotics\/)/;
4
7
  export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);