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
package/scripts/notify.sh CHANGED
@@ -1,12 +1,17 @@
1
1
  #!/bin/sh
2
2
  # notify.sh "<title>" "<message>" [priority] [tags] — push alert to Stuart's phone via ntfy.sh.
3
- # Topic from $NTFY_TOPIC or the project .env. Fail-silent: alerting must never break a pipeline.
3
+ # Topic from $NTFY_TOPIC or the project .env. Alerting must never break a pipeline (no caller runs under
4
+ # `set -e`), but the exit code now tells the truth so a caller can record delivery (4.7.21):
5
+ # 0 delivered (2xx) · 1 not delivered (network or HTTP error; curl --fail) · 2 paging off (NTFY_TOPIC="" or no topic)
6
+ # NTFY_TOPIC set-but-EMPTY is an explicit opt-out (job-heartbeat.sh's convention): it never falls through to .env.
4
7
  DIR=$(dirname "$0")
5
- TOPIC="${NTFY_TOPIC:-$(grep -m1 '^NTFY_TOPIC=' "$DIR/../.env" 2>/dev/null | cut -d= -f2)}"
6
- [ -z "$TOPIC" ] && exit 0
7
- curl -sS --max-time 10 \
8
+ if [ "${NTFY_TOPIC+set}" = "set" ]; then TOPIC="$NTFY_TOPIC"
9
+ else TOPIC="$(grep -m1 '^NTFY_TOPIC=' "$DIR/../.env" 2>/dev/null | cut -d= -f2)"; fi
10
+ [ -z "$TOPIC" ] && exit 2
11
+ curl -sS --fail --max-time 10 \
8
12
  -H "Title: ${1:-RuvNet Brain}" \
9
13
  -H "Priority: ${3:-default}" \
10
14
  -H "Tags: ${4:-brain}" \
11
- -d "${2:-(no message)}" \
12
- "https://ntfy.sh/$TOPIC" >/dev/null 2>&1 || true
15
+ -d "${2:-(no message)}" \
16
+ "${NTFY_URL:-https://ntfy.sh}/$TOPIC" >/dev/null 2>&1 || exit 1
17
+ exit 0
@@ -23,6 +23,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
23
23
  import { spawn, spawnSync } from 'node:child_process';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { isPersonIssue, judgeIssue } from './issue-watch.mjs';
26
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
26
27
 
27
28
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
29
  const REPO = 'stuinfla/ruvnet-brain';
@@ -61,14 +62,27 @@ export function classify(msg) {
61
62
  let m;
62
63
  // job-heartbeat.sh pages "SCHEDULED JOB FAILED: X" / "Recovered: X"; nightly-watchdog.mjs pages "🔴 <STATE>: X" /
63
64
  // "✅ X is healthy again". Same cause, same key, so either recovery closes either failure.
64
- if ((m = /SCHEDULED JOB FAILED: (\S+)/.exec(title)) || (m = /^\W*(?:FAILING|NEVER-RAN|STALE|MISSING|STALLED): (\S+)/.exec(title))) return { kind: 'job', key: `job:${m[1]}`, label: m[1], actionable: true };
65
+ if ((m = /SCHEDULED JOB FAILED: (\S+)/.exec(title)) || (m = /^\W*(?:FAILING|NEVER-RAN|STALE|MISSING|STALLED|STUCK): (\S+)/.exec(title))) return { kind: 'job', key: `job:${m[1]}`, label: m[1], actionable: true };
65
66
  if ((m = /Recovered: (\S+)/.exec(title)) || (m = /^\W*(\S+) is healthy again/.exec(title))) return { kind: 'job-recovered', key: `job:${m[1]}`, label: m[1], actionable: false, resolves: true };
66
67
  if ((m = /CI \w+: (.+)$/.exec(title))) {
67
68
  const b = /branch ([\w./-]{1,100})/.exec(body);
68
69
  // Keyed by workflow AND branch: a green main must never close a still-red release branch (review round 4).
69
70
  return { kind: 'ci', key: `ci:${m[1].trim()}@${b ? b[1] : 'main'}`, workflow: m[1].trim(), ...(b ? { branch: b[1] } : {}), actionable: true };
70
71
  }
71
- if ((m = /(?:New issue|SLA breach: issue) #(\d+)/.exec(title))) return { kind: 'issue', key: `issue:${m[1]}`, number: Number(m[1]), actionable: true };
72
+ if ((m = /(?:New issue|SLA breach: issue) #(\d+)/.exec(title))) {
73
+ // ONE combined SLA page names every breaching issue (scripts/issue-watch.mjs: a "#N (age) title" line each, and
74
+ // "…and K more: #a #b" past 8). Each named issue is its own alert, so a 17-issue breach counts 17, not 1.
75
+ const numbers = [Number(m[1])];
76
+ if (/^SLA breach/.test(title)) {
77
+ for (const line of body.split('\n')) {
78
+ const listed = /^#(\d+) \(/.exec(line);
79
+ if (listed) numbers.push(Number(listed[1]));
80
+ else if (/^…and \d+ more:/.test(line)) for (const x of line.matchAll(/#(\d+)/g)) numbers.push(Number(x[1]));
81
+ }
82
+ }
83
+ const unique = [...new Set(numbers.filter((n) => Number.isSafeInteger(n) && n > 0))].slice(0, MAX_OPEN_ALERTS);
84
+ return { kind: 'issue', key: `issue:${unique[0]}`, number: unique[0], actionable: true, ...(unique.length > 1 ? { numbers: unique } : {}) };
85
+ }
72
86
  if ((m = /New PR #(\d+)/.exec(title))) return { kind: 'pr', key: `pr:${m[1]}`, number: Number(m[1]), actionable: true };
73
87
  if (/^Waiting on a reply/.test(title)) return { kind: 'digest', key: 'digest:issues', actionable: false };
74
88
  if (/New star/.test(title)) return { kind: 'info', key: `info:${msg.id}`, actionable: false };
@@ -115,23 +129,32 @@ export function fold(open, messages, { now = Date.now(), resolver = resolvedNow,
115
129
  for (const msg of messages) {
116
130
  if (!msg || typeof msg.id !== 'string' || folded.has(msg.id)) continue;
117
131
  folded.add(msg.id);
118
- const c = classify(msg);
132
+ const classified = classify(msg);
119
133
  const at = new Date((msg.time || now / 1000) * 1000).toISOString();
120
- if (c.resolves) {
121
- const a = byKey.get(c.key);
122
- if (a && !a.closedAt && Date.parse(at) >= Date.parse(a.lastAt) && resolver(a)) { a.closedAt = at; a.closedBy = 'recovered notice (confirmed)'; a.closedByMsg = msg.id; }
123
- continue;
124
- }
125
- if (!c.actionable) continue;
126
- const existing = byKey.get(c.key);
127
- if (existing && !existing.closedAt) {
128
- // lastAt only moves forward: a delayed older message must not make a newer failure look resolved.
129
- existing.count += 1;
130
- if (Date.parse(at) > Date.parse(existing.lastAt)) { existing.lastAt = at; existing.lastTitle = msg.title; }
131
- existing.msgIds = [...(existing.msgIds || []), msg.id].slice(-10);
132
- continue;
134
+ // A combined SLA page becomes one alert per issue it names; the first keeps the message id, the rest get id-N.
135
+ const parts = classified.numbers ? classified.numbers.map((n, i) => {
136
+ const one = { ...classified, key: `issue:${n}`, number: n, alertId: i === 0 ? msg.id : `${msg.id}-${n}` };
137
+ delete one.numbers;
138
+ return one;
139
+ }) : [classified];
140
+ for (const c of parts) {
141
+ if (c.resolves) {
142
+ const a = byKey.get(c.key);
143
+ if (a && !a.closedAt && Date.parse(at) >= Date.parse(a.lastAt) && resolver(a)) { a.closedAt = at; a.closedBy = 'recovered notice (confirmed)'; a.closedByMsg = msg.id; }
144
+ continue;
145
+ }
146
+ if (!c.actionable) continue;
147
+ const existing = byKey.get(c.key);
148
+ if (existing && !existing.closedAt) {
149
+ // lastAt only moves forward: a delayed older message must not make a newer failure look resolved.
150
+ existing.count += 1;
151
+ if (Date.parse(at) > Date.parse(existing.lastAt)) { existing.lastAt = at; existing.lastTitle = msg.title; }
152
+ existing.msgIds = [...(existing.msgIds || []), msg.id].slice(-10);
153
+ continue;
154
+ }
155
+ const { alertId, ...fields } = c;
156
+ byKey.set(c.key, { id: alertId || msg.id, ...fields, firstAt: at, lastAt: at, count: 1, title: msg.title || '', body: String(msg.message || '').slice(0, 200), msgIds: [msg.id], attempts: 0 });
133
157
  }
134
- byKey.set(c.key, { id: msg.id, ...c, firstAt: at, lastAt: at, count: 1, title: msg.title || '', body: String(msg.message || '').slice(0, 200), msgIds: [msg.id], attempts: 0 });
135
158
  }
136
159
  const byOldestCheck = [...byKey.values()].sort((x, y) => Date.parse(x.lastCheckedAt || 0) - Date.parse(y.lastCheckedAt || 0));
137
160
  for (const a of byOldestCheck) {
@@ -506,7 +529,7 @@ export function formatOpen(alerts) {
506
529
  + `${a.count > 1 ? ` (×${a.count}, since ${clean(a.firstAt, 30)})` : ''}\n ${clean(String(a.body || '').split('\n')[0], 160)}`).join('\n') + tail;
507
530
  }
508
531
 
509
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
532
+ if (isDirectInvocation(import.meta.url)) {
510
533
  const args = process.argv.slice(2);
511
534
  try {
512
535
  if (args[0] === '--open') {
@@ -64,7 +64,7 @@ import {
64
64
  } from '../kb/brain-profile.mjs';
65
65
  // Lessons: read model + the two user verbs. Every mutation goes through lesson-store's own
66
66
  // updateLessons/ratify/demote/restore — this file adds a SURFACE, never a second writer.
67
- import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs';
67
+ import { loadLessons, lessonApplies, projectNameFor, STORE_PATH as LESSON_STORE_PATH, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs';
68
68
  import {
69
69
  openRouterCredentialStatus,
70
70
  saveOpenRouterCredential,
@@ -430,8 +430,31 @@ function wiringSurvey() {
430
430
  }
431
431
 
432
432
  // ── Memory health (read-only probes for the project the console was launched from) ────────────────
433
+ // Dream Cycle 2026-09-18 (memory-durability, round-trip-proof): this used to also pass on ANY
434
+ // `.claude/hooks` directory existing, so a machine whose hooks folder holds only an unrelated
435
+ // script (a different plugin, a stale leftover) scored `sessionSurfacing: ok` and printed "the
436
+ // global SessionStart hook surfaces project state at launch" — a claim about a hook that was never
437
+ // installed. Only the specific recall hook's presence can support that claim.
438
+ //
439
+ // Dream Cycle 2026-09-19 (memory-durability, managed-boundary): the fix above still checked only
440
+ // the LEGACY standalone `agentdb-ensure.sh` global hook. It ignored the mechanism this very repo
441
+ // ships and documents as the automatic one — `plugin/hooks/hooks.json`'s own SessionStart entry,
442
+ // which every session restores via `project-progression-session-start.mjs` (wired through
443
+ // `session-start-core.mjs`) whenever the `ruvnet-brain` plugin itself is enabled. A machine that
444
+ // installed this plugin the real, current way (Claude Code's marketplace flow) and never separately
445
+ // placed the legacy global shell hook scored `sessionSurfacing: warn` — a false negative, flagged
446
+ // as an open scope question by PR #296's independent critic, not chased there to keep that change
447
+ // to one conceptual fix. `pluginEnabled()` mirrors the exact `k.startsWith('ruvnet-brain@')` check
448
+ // `capability-registry.mjs`'s `wiredForRoute()` already uses for the same "is our plugin the one
449
+ // Claude Code will actually load" question, rather than re-deriving a second way to ask it.
450
+ function pluginEnabled() {
451
+ const settings = readJSON(path.join(CONSOLE_ROOT, '.claude', 'settings.json'));
452
+ return Object.entries(settings?.enabledPlugins || {}).some(([k, v]) => k.startsWith('ruvnet-brain@') && v === true);
453
+ }
433
454
  function sessionHookExists() {
434
- return fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks'));
455
+ if (fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks/agentdb-ensure.sh'))) return 'agentdb-ensure';
456
+ if (pluginEnabled()) return 'ruvnet-brain-plugin';
457
+ return null;
435
458
  }
436
459
  // WHICH FILE IS THE PROJECT'S MEMORY STORE IS NOT A CONSTANT (issue #127).
437
460
  //
@@ -496,7 +519,12 @@ function probeMemory(projectDir, { now = Date.now() } = {}) {
496
519
  // Compaction survival is a ruflo write at PreCompact (project-state-auto-* + exact readback); a refusal
497
520
  // reaches the hook-failures summary. It is not re-probed at render.
498
521
  probes.compactionSurvival = { status: 'notTested', detail: 'PreCompact stores a project-state-auto-* checkpoint through ruflo and reads it back; failures appear in the SessionStart hook-failures summary' };
499
- probes.sessionSurfacing = sessionHookExists() ? { status: 'ok', detail: 'the global SessionStart hook surfaces project state at launch' } : { status: 'warn', detail: 'no SessionStart recall hook found' };
522
+ const sessionHookVia = sessionHookExists();
523
+ probes.sessionSurfacing = sessionHookVia === 'agentdb-ensure'
524
+ ? { status: 'ok', detail: 'the global SessionStart hook surfaces project state at launch', via: sessionHookVia }
525
+ : sessionHookVia === 'ruvnet-brain-plugin'
526
+ ? { status: 'ok', detail: 'the ruvnet-brain plugin\'s own SessionStart hook surfaces project state at launch', via: sessionHookVia }
527
+ : { status: 'warn', detail: 'no SessionStart recall hook found', via: null };
500
528
  // recall quality: honestly NOT probed at render (a true probe needs an embedding query; left for an explicit deep test)
501
529
  probes.recallQuality = { status: 'notTested', detail: 'not checked this session — a real recall probe needs an embedding round-trip, which render deliberately avoids' };
502
530
 
@@ -1373,6 +1401,8 @@ function setLesson(body) {
1373
1401
  // --refresh-cache child. A request never scans — not inline on a first-ever run, and not via
1374
1402
  // setImmediate either (deferring synchronous work still blocks the loop when it runs). A machine with
1375
1403
  // no fleet scan yet is reported `warming`, never as a fabricated zero.
1404
+ // #500: bump when the meaning of the cached fleet changes. 2 = one entry per resolved store; 3 = + stable `id`.
1405
+ const ACTIVITY_CACHE_SCHEMA = 3; // 3 = each project also carries `id`, the store's dev:inode identity
1376
1406
  let ACTIVITY_MACHINE_CACHE = null;
1377
1407
  const CONSOLE_CACHE_PATH = path.join(CONSOLE_ROOT, '.cache/ruvnet-brain/console-cache.json');
1378
1408
 
@@ -1834,7 +1864,9 @@ function loadConsoleCache() {
1834
1864
  let restored = false;
1835
1865
  try {
1836
1866
  const j = JSON.parse(fs.readFileSync(CONSOLE_CACHE_PATH, 'utf8'));
1837
- if (j.activity && j.activity.at) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
1867
+ // #500: a cache written before the fleet scan deduped by store identity can hold one database
1868
+ // counted twice (a nested docs/adr/.swarm beside its parent). Never trust it — re-measure.
1869
+ if (j.activity && j.activity.at && j.activity.schema === ACTIVITY_CACHE_SCHEMA) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
1838
1870
  if (j.trust && j.trust.at) { TRUST_CACHE = j.trust; restored = true; }
1839
1871
  } catch { /* no cache yet — first ever boot */ }
1840
1872
  return restored;
@@ -1868,7 +1900,7 @@ function saveConsoleCache() {
1868
1900
  function adoptFleetFromDisk() {
1869
1901
  try {
1870
1902
  const j = readJSON(CONSOLE_CACHE_PATH);
1871
- if (j && j.activity && j.activity.at && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
1903
+ if (j && j.activity && j.activity.at && j.activity.schema === ACTIVITY_CACHE_SCHEMA && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
1872
1904
  ACTIVITY_MACHINE_CACHE = j.activity;
1873
1905
  }
1874
1906
  } catch { /* no cache yet — the caller kicks a child and reports `warming` */ }
@@ -1884,8 +1916,10 @@ function refreshFleetCache() {
1884
1916
  // pathIdentity, not path.resolve: resolve() normalises `.`/`..` and nothing else, so a project
1885
1917
  // reached through a symlink OR through the other capitalisation of a case-insensitive volume
1886
1918
  // was two distinct strings for one directory, and its memories were summed twice (#107).
1887
- const resolved = pathIdentity(s.project) ?? path.resolve(s.project);
1888
- if (seen.has(resolved)) continue; // a project visible under two roots counts once
1919
+ // #500: dedupe on the DATABASE, which is what holds the memories. Two directories (a repo and its
1920
+ // nested docs/adr/.swarm, or a linked worktree) can resolve to one store; project paths differ.
1921
+ const resolved = pathIdentity(s.db) ?? path.resolve(s.db);
1922
+ if (seen.has(resolved)) continue; // a store visible twice counts once
1889
1923
  seen.add(resolved);
1890
1924
  const n = Number(robustRead(s.db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0);
1891
1925
  if (n > 0) {
@@ -1894,17 +1928,25 @@ function refreshFleetCache() {
1894
1928
  const lastTouched = Number(robustRead(s.db, 'SELECT MAX(updated_at) FROM memory_entries').value || 0);
1895
1929
  // rel = the root-relative path — the SAME key reconcile:<id> recommendations use (wiringSurvey
1896
1930
  // computes projName the same way, relative to whichever root the project was found under).
1897
- projects.push({ name: path.basename(s.project), rel: path.relative(root, s.project), memories: n, lastTouched });
1931
+ // Name from the RESOLVED project root. A linked worktree whose main checkout lies outside this
1932
+ // scan root would give a '..'-prefixed rel, but reconcile:<id> keys (wiringSurvey) are relative to
1933
+ // the directory found under the root — so fall back to the found directory in that case.
1934
+ const inRoot = path.relative(root, s.project);
1935
+ const rel = (inRoot === '' || inRoot.startsWith('..') || path.isAbsolute(inRoot)) ? path.relative(root, s.found || s.project) : inRoot;
1936
+ // `id` = the store's own dev:inode identity (pathIdentity follows symlinks and sees hard links), so a
1937
+ // client can tell WHICH project is "you" without comparing folder names (two repos can share one).
1938
+ projects.push({ id: resolved, name: path.basename(s.project), rel, memories: n, lastTouched });
1898
1939
  total += n;
1899
1940
  }
1900
1941
  }
1901
1942
  }
1902
1943
  projects.sort((a, b) => b.memories - a.memories);
1903
- ACTIVITY_MACHINE_CACHE = { at: Date.now(), projects, totalMemories: total };
1944
+ ACTIVITY_MACHINE_CACHE = { schema: ACTIVITY_CACHE_SCHEMA, at: Date.now(), projects, totalMemories: total };
1904
1945
  saveConsoleCache();
1905
1946
  }
1906
- function findMemoryStores(root) {
1947
+ export function findMemoryStores(root) {
1907
1948
  const out = [];
1949
+ const seenDb = new Set();
1908
1950
  const walk = (dir, depth) => {
1909
1951
  if (depth > 3) return;
1910
1952
  let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
@@ -1916,8 +1958,12 @@ function findMemoryStores(root) {
1916
1958
  // Fleet activity is project memory, so resolve the canonical store rather than selecting a
1917
1959
  // coordination database by size. The latter is diagnostic-only and can contain unrelated rows.
1918
1960
  try {
1919
- const resolved = resolveProjectStore({ projectDir: dir }).canonicalAgentDbPath;
1920
- if (fs.existsSync(resolved)) out.push({ project: dir, db: resolved });
1961
+ const store = resolveProjectStore({ projectDir: dir });
1962
+ const resolved = store.canonicalAgentDbPath;
1963
+ // #500: record the RESOLVED project root and one entry per database. A nested docs/adr/.swarm
1964
+ // resolves to its parent's store; recording `dir` listed that one database twice.
1965
+ const id = pathIdentity(resolved) ?? path.resolve(resolved);
1966
+ if (fs.existsSync(resolved) && !seenDb.has(id)) { seenDb.add(id); out.push({ project: store.projectRoot, found: dir, db: resolved }); }
1921
1967
  } catch { /* an unresolvable directory is not a project store */ }
1922
1968
  continue;
1923
1969
  }
@@ -1928,6 +1974,38 @@ function findMemoryStores(root) {
1928
1974
  walk(root, 0);
1929
1975
  return out;
1930
1976
  }
1977
+ // #499: approved lessons are the ratified rules in the lesson store (the SAME active rule as
1978
+ // gatherLessons().counts.active). `available:false` means the store could not be read — unknown, never 0.
1979
+ // count = machine-wide (every ratified/active, non-demoted lesson)
1980
+ // applicable = the subset that applies to THIS project, by the SAME rule plugin/scripts/lesson-gate.mjs
1981
+ // uses (lesson-store.lessonApplies): unscoped, or scoped to this project, or proven in >=2 projects.
1982
+ // items = per-lesson id/title/scope/enforcement/status for the applicable ones, bounded (the metadata
1983
+ // the reporter asked to keep), `itemsTruncated` says when the bound cut the list.
1984
+ const APPROVED_ITEMS_MAX = 25;
1985
+ function approvedLessonsFigure(here) {
1986
+ try {
1987
+ // loadLessons() swallows a parse failure and returns [] — which would read as "0 approved". A file
1988
+ // that exists but does not parse is UNKNOWN; only an absent file is a true zero.
1989
+ if (fs.existsSync(LESSON_STORE_PATH)) JSON.parse(fs.readFileSync(LESSON_STORE_PATH, 'utf8'));
1990
+ const approved = loadLessons().filter((l) => (l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE) && !l.demoted);
1991
+ const mine = approved.filter((l) => lessonApplies(l, here)); // the gate's own rule (lesson-store.lessonApplies)
1992
+ const items = mine.slice(0, APPROVED_ITEMS_MAX).map((l) => {
1993
+ const ps = Array.isArray(l.projects) ? l.projects : [];
1994
+ const text = String(l.statement || '');
1995
+ return { id: l.id, title: text.length > 140 ? `${text.slice(0, 139)}…` : text,
1996
+ scope: ps.length ? ps.join(', ') : 'all projects', enforcement: l.enforcement ?? null,
1997
+ ratified: l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE, status: l.status };
1998
+ });
1999
+ return { available: true, count: approved.length, applicable: mine.length, items, itemsTruncated: mine.length > items.length };
2000
+ } catch { return { available: false, count: null, applicable: null, items: [], itemsTruncated: false }; }
2001
+ }
2002
+ // #499: a count that could not be read is null, not 0 — "0" would claim the store is empty.
2003
+ function readCount(db, sql) {
2004
+ const r = robustRead(db, sql);
2005
+ if (!r.ok) return null;
2006
+ const n = Number(r.value ?? 0);
2007
+ return Number.isFinite(n) ? n : null;
2008
+ }
1931
2009
  function gatherActivity(cwd) {
1932
2010
  let project;
1933
2011
  let db;
@@ -1937,19 +2015,27 @@ function gatherActivity(cwd) {
1937
2015
  db = resolved.canonicalAgentDbPath;
1938
2016
  } catch {
1939
2017
  const measuredAt = new Date().toISOString();
1940
- return { generatedAt: measuredAt, project: path.basename(cwd), hasStore: false, continuity: 'unavailable', ...freshnessOf(measuredAt) };
2018
+ return { generatedAt: measuredAt, project: path.basename(cwd), hasStore: false, continuity: 'unavailable', approvedLessons: approvedLessonsFigure(projectNameFor(cwd)), ...freshnessOf(measuredAt) };
1941
2019
  }
1942
2020
  if (!fs.existsSync(db)) {
1943
2021
  // No store to read — the existence check above IS the entire measurement, so it IS the
1944
2022
  // observation instant. Stamped here, not with a value taken before the check ran.
1945
2023
  const measuredAt = new Date().toISOString();
1946
- return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, ...freshnessOf(measuredAt) };
2024
+ return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, approvedLessons: approvedLessonsFigure(path.basename(project)), ...freshnessOf(measuredAt) };
1947
2025
  }
1948
- const out = { project: path.basename(project), hasStore: true };
2026
+ // projectId = the same dev:inode identity the fleet entries carry, so the client matches on identity, not the folder name.
2027
+ const out = { project: path.basename(project), projectId: pathIdentity(db) ?? path.resolve(db), hasStore: true, approvedLessons: approvedLessonsFigure(path.basename(project)) };
1949
2028
  const rows = (sql) => robustReadJSON(db, sql).rows;
2029
+ const agentdbLessons = readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'");
1950
2030
  out.totals = {
1951
- memories: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0),
1952
- lessons: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'").value || 0),
2031
+ memories: readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'"),
2032
+ agentdbLessons,
2033
+ lessons: agentdbLessons, // legacy alias of agentdbLessons
2034
+ // #499: the learning pipeline's OWN measurements (null = unreadable, shown as Unknown, never 0). Distillation
2035
+ // writes patterns/episodes, never `lessons` rows; observations are the namespace it mines.
2036
+ observations: readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='learning-observations' AND status='active'"),
2037
+ patterns: readCount(db, 'SELECT COUNT(*) FROM reasoning_patterns'),
2038
+ episodes: readCount(db, 'SELECT COUNT(*) FROM episodes'),
1953
2039
  };
1954
2040
  out.lessons = rows("SELECT key, access_count, date(created_at/1000,'unixepoch') AS learned, substr(content,1,600) AS excerpt FROM memory_entries WHERE namespace='lessons' AND status='active' ORDER BY created_at DESC");
1955
2041
  out.recent = rows("SELECT key, namespace, type, datetime(updated_at/1000,'unixepoch','localtime') AS at FROM memory_entries WHERE status='active' ORDER BY updated_at DESC LIMIT 18");
@@ -2613,12 +2699,8 @@ function apply(ids) {
2613
2699
  } : {});
2614
2700
  if (res.ok && learningBefore) {
2615
2701
  const after = observeLearning();
2616
- // `ruflo memory distill run` owns the patterns; progress here is a known, readable learner afterwards.
2617
- const progressed = learningBefore.statusKnown && after.statusKnown;
2618
- if (!progressed) {
2619
- res.ok = false;
2620
- res.log += `\nNo measured learning progress: queue ${after.queueDir}; learner ${after.learnerCwd}. Reload to inspect the remaining evidence.`;
2621
- }
2702
+ res.ok = learningProgressed(learningBefore, after);
2703
+ if (!res.ok) res.log += `\nNo measured learning progress: ${learningProgressDetail(learningBefore, after)}. Reload to inspect the remaining evidence.`;
2622
2704
  }
2623
2705
  phaseMs.childRemedyMs += elapsedMs(remedyStartedAt);
2624
2706
  results.push({ id, ...res, undoToken });
@@ -2628,6 +2710,21 @@ function apply(ids) {
2628
2710
  return { results, timings: { ...phaseMs, totalMs: elapsedMs(startedAt) } };
2629
2711
  }
2630
2712
 
2713
+ /**
2714
+ * #377: Apply on "Your learner has gone quiet" counts as progress only when `ruflo memory distill
2715
+ * status` shows the learner's last distillation moved FORWARD. A readable store alone is not progress.
2716
+ */
2717
+ export function learningProgressed(before, after) {
2718
+ const was = Date.parse(before?.lastDistillAt || '');
2719
+ const now = Date.parse(after?.lastDistillAt || '');
2720
+ return !!(before?.statusKnown && after?.statusKnown && after.distillKnown === true
2721
+ && Number.isFinite(now) && (!Number.isFinite(was) || now > was));
2722
+ }
2723
+ export function learningProgressDetail(before, after) {
2724
+ return `learner ${after?.learningDb || before?.learningDb || 'unresolved'}; last distillation `
2725
+ + `${before?.lastDistillAt || 'not recorded'} → ${after?.distillKnown === true ? (after.lastDistillAt || 'not recorded') : 'unknown'}`;
2726
+ }
2727
+
2631
2728
  function autoEligibleIds(recommendations = []) {
2632
2729
  return recommendations
2633
2730
  .filter((rec) => rec?.scope === 'project')
@@ -3511,6 +3608,9 @@ export {
3511
3608
  saveBrainProfile,
3512
3609
  setLesson,
3513
3610
  gatherLessons,
3611
+ gatherActivity,
3612
+ refreshFleetCache,
3613
+ loadConsoleCache,
3514
3614
  gatherRouterEngine,
3515
3615
  autoEligibleIds,
3516
3616
  gatherConfig,
@@ -20,6 +20,7 @@ import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { wilson } from '../eval-brain.mjs';
23
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
23
24
 
24
25
  const rate = (k, n) => { const w = wilson(k, n); return { k, n, p: +w.p.toFixed(4), lo: +w.lo.toFixed(4), hi: +w.hi.toFixed(4) }; };
25
26
  const within5 = (r) => (r.fileRank != null && r.fileRank <= 5) || (r.altFileRank != null && r.altFileRank <= 5);
@@ -59,4 +60,4 @@ async function main() {
59
60
  }
60
61
  }
61
62
 
62
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
63
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -28,6 +28,7 @@ import fs from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import readline from 'node:readline';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
31
32
 
32
33
  const args = process.argv.slice(2);
33
34
  const arg = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
@@ -136,4 +137,4 @@ async function main() {
136
137
  process.exit(0);
137
138
  }
138
139
 
139
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
140
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -23,6 +23,7 @@ import os from 'node:os';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { claudeArgs, spawnHost, isQuotaRefusal } from './producer-hosts.mjs';
25
25
  import { subscriptionOnlyEnv } from '../subscription-hosts.mjs';
26
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
26
27
 
27
28
  export const EXCERPT_CHARS = 1500;
28
29
  export const D2Q_SCHEMA = Object.freeze({
@@ -159,4 +160,4 @@ async function main() {
159
160
  console.log(JSON.stringify({ files: docs.length, written, stopped: stop }));
160
161
  }
161
162
 
162
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
163
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -22,6 +22,7 @@ import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
24
  import { wilson } from '../eval-brain.mjs';
25
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
25
26
 
26
27
  /** Collapse ranked entry hits (each pointing at a file) to the first `files` distinct files. */
27
28
  export function filesFromEntries(hits, pathOfId, files) {
@@ -107,4 +108,4 @@ async function main() {
107
108
  process.exit(0);
108
109
  }
109
110
 
110
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
111
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -21,6 +21,7 @@ import fs from 'node:fs';
21
21
  import path from 'node:path';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
23
  import { wilson } from '../eval-brain.mjs';
24
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
24
25
 
25
26
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
26
27
  const { JUDGE_FEATURES, judgeFeatures, judgeLogit } = await import(pathToFileURL(path.join(ROOT, 'kb/judge-rank.mjs')).href);
@@ -155,4 +156,4 @@ async function main() {
155
156
  console.log(JSON.stringify({ threshold: tau, trainedOn: model.trainedOn, heldout: report.heldout }, null, 1));
156
157
  }
157
158
 
158
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
159
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -13,6 +13,7 @@ import { createHash } from 'node:crypto';
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
16
17
 
17
18
  export function splitNeedSet(questions, { salt = 'v1', trainFraction = 0.5 } = {}) {
18
19
  const byRepo = new Map();
@@ -45,4 +46,4 @@ async function main() {
45
46
  console.log(JSON.stringify({ train: split.train.length, heldout: split.heldout.length, contentHash: set.contentHash }));
46
47
  }
47
48
 
48
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
49
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -34,6 +34,7 @@ import {
34
34
  DEFAULT_ROLES, LABEL_SCHEMA, PRODUCER_MODELS, VERDICT_SCHEMA, generatorPrompt, hostAdapters, isQuotaRefusal,
35
35
  judgePrompt, spawnHost,
36
36
  } from './producer-hosts.mjs';
37
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
37
38
 
38
39
  export {
39
40
  CLAUDE_SYSTEM_PROMPT, CLAUDE_TIMEOUT_MS, CODEX_TIMEOUT_MS, DEFAULT_ROLES, JUDGE_SYSTEM_PROMPT, LABEL_SCHEMA,
@@ -275,19 +276,4 @@ export async function main(argv = process.argv.slice(2)) {
275
276
  return labels.suspended ? 75 : 0;
276
277
  }
277
278
 
278
- // Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
279
- // NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
280
- // shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
281
- // never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
282
- // from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
283
- // archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
284
- function isDirectInvocation() {
285
- try {
286
- if (!process.argv[1]) return false;
287
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
288
- } catch {
289
- return false;
290
- }
291
- }
292
-
293
- if (isDirectInvocation()) process.exitCode = await main();
279
+ if (isDirectInvocation(import.meta.url)) process.exitCode = await main();
@@ -36,6 +36,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
36
36
  import { extractZip } from '../../kb/zip-extract.mjs';
37
37
  import { validateCoverageLedger } from '../coverage-integrity.mjs';
38
38
  import { retiredFixtureStores } from '../fixture-denominator.mjs';
39
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
39
40
 
40
41
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
41
42
 
@@ -556,8 +557,7 @@ export async function main(argv = process.argv.slice(2), { searchAll = null } =
556
557
  // archive on disk (measured 2026-09-14).
557
558
  function isMain() {
558
559
  try {
559
- if (!process.argv[1]) return false;
560
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
560
+ return isDirectInvocation(import.meta.url);
561
561
  } catch { return false; }
562
562
  }
563
563
 
@@ -54,6 +54,7 @@ import os from 'node:os';
54
54
  import path from 'node:path';
55
55
  import { fileURLToPath } from 'node:url';
56
56
  import { extractZip } from '../../kb/zip-extract.mjs';
57
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
57
58
 
58
59
  const HERE = path.dirname(fileURLToPath(import.meta.url));
59
60
  const ROOT = path.resolve(HERE, '..', '..');
@@ -871,8 +872,7 @@ export async function main(argv = process.argv.slice(2)) {
871
872
  // with no archive and no receipt on disk. Same defect, same fix as plugin/scripts/hook-input.mjs:518.
872
873
  function isMain() {
873
874
  try {
874
- if (!process.argv[1]) return false;
875
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
875
+ return isDirectInvocation(import.meta.url);
876
876
  } catch {
877
877
  return false;
878
878
  }
@@ -22,6 +22,7 @@ import path from 'node:path';
22
22
  import { createRequire } from 'node:module';
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
24
  import { wilson } from '../eval-brain.mjs';
25
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
25
26
 
26
27
  /** Rank (1-based) of the first hit whose path is `gold`, or null within the list. */
27
28
  export function rankOf(paths, gold) {
@@ -136,4 +137,4 @@ async function main() {
136
137
  process.exit(0);
137
138
  }
138
139
 
139
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
140
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -20,6 +20,7 @@ import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { fileURLToPath } from 'node:url';
23
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
23
24
 
24
25
  export const SOURCE_TREE_VERSION = 'oracle-source-tree/1';
25
26
  export const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v1';
@@ -148,18 +149,4 @@ export function main(argv = process.argv.slice(2)) {
148
149
  return 0;
149
150
  }
150
151
 
151
- // Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
152
- // NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
153
- // shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
154
- // never runs -- and because nothing throws, the process exits 0. Pinned by
155
- // tests/unit/entrypoint-symlink.test.mjs.
156
- function isDirectInvocation() {
157
- try {
158
- if (!process.argv[1]) return false;
159
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
160
- } catch {
161
- return false;
162
- }
163
- }
164
-
165
- if (isDirectInvocation()) process.exitCode = main();
152
+ if (isDirectInvocation(import.meta.url)) process.exitCode = main();
@@ -25,6 +25,7 @@ import { createHash } from 'node:crypto';
25
25
  import { execFileSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { parse as babelParse } from '@babel/parser';
28
+ import { isDirectInvocation } from '../../plugin/scripts/entrypoint.mjs';
28
29
 
29
30
  export const RULES_VERSION = 'oracle-source-units/1';
30
31
  export const MAX_SELECTED = 100;
@@ -373,19 +374,4 @@ export function main(argv = process.argv.slice(2)) {
373
374
  return 0;
374
375
  }
375
376
 
376
- // Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
377
- // NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
378
- // shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
379
- // never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
380
- // from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
381
- // archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
382
- function isDirectInvocation() {
383
- try {
384
- if (!process.argv[1]) return false;
385
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
386
- } catch {
387
- return false;
388
- }
389
- }
390
-
391
- if (isDirectInvocation()) process.exitCode = main();
377
+ if (isDirectInvocation(import.meta.url)) process.exitCode = main();