session-orchestrator 3.17.0 → 3.20.0

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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,782 @@
1
+ /**
2
+ * learnings/judgment.mjs — the RELATION-judgment contract and its fail-closed
3
+ * enforcement (#1016).
4
+ *
5
+ * ## Why this exists (the honest justification)
6
+ *
7
+ * NOT "clean up the contradictions we have". A wave-1 measurement over the live
8
+ * corpus found zero true contradictions (max pairwise Jaccard 0.14, 0 exact
9
+ * `(type, subject)` collisions, 97.9% of pairs at zero token overlap). Building
10
+ * for a backlog that does not exist would be slop.
11
+ *
12
+ * The real justification is narrower: **four consumers of a `contradicted`
13
+ * verdict already ship, and none has a producer.**
14
+ *
15
+ * - `skills/evolve/SKILL.md:211` — `-0.2 if contradicted`
16
+ * - `skills/evolve/SKILL.md:252` — do NOT reset `expires_at` for contradicted
17
+ * - `skills/_shared/config-reading.md:134` — the lifecycle-state definition
18
+ * - `skills/session-end/learning-patterns.md:64` — the write step
19
+ *
20
+ * Today the only path into that branch is an operator hand-picking "Reduce
21
+ * confidence" in an AUQ. This module is the missing producer — and, more
22
+ * importantly, the gate that decides when a produced verdict is trustworthy
23
+ * enough to reach any of those four.
24
+ *
25
+ * ## What this module is NOT
26
+ *
27
+ * NOT an LLM call. The judge invocation belongs to the `/evolve` wiring; here it
28
+ * is an INJECTED function (`opts.judge`) that returns a candidate verdict.
29
+ * NOT a write path: nothing here touches `learnings.jsonl`, the archive, or the
30
+ * store. {@link applyVerdict} dispatches to caller-supplied effect handlers and
31
+ * is the single choke point where a judgment may become an effect.
32
+ * NOT a candidate-pool builder: which neighbours are worth showing is a sibling
33
+ * concern (`./affinity.mjs` + its consumer). This module takes the pool as given.
34
+ *
35
+ * ## Why `store` is absent from the decision enum
36
+ *
37
+ * The persist decision already has an owner and its own operator AUQ
38
+ * (`skills/evolve/SKILL.md` Step 3.4). Re-introducing `store` here would build a
39
+ * SECOND, ungated write door into the same file. "No relation found" is
40
+ * {@link JUDGMENT_DECISIONS `skip`}, not a store.
41
+ *
42
+ * `update` is split into `refine` and `supersede` because their remedies are
43
+ * opposite: refinement keeps both records and links them, supersession archives
44
+ * the old one (`_archive_reason: 'superseded'`, see `./expiry-sweep.mjs`). A
45
+ * single `update` under-determines an irreversible branch.
46
+ *
47
+ * `abstain` is distinct from `skip` and the distinction is load-bearing:
48
+ * `skip` = judged, no relation. `abstain` = could not judge. Collapsing them is
49
+ * exactly how fail-open sneaks in — it makes an undecided case indistinguishable
50
+ * from a decided one in telemetry and turns the `skip` count into a lie. Both
51
+ * write nothing; only the counters differ.
52
+ *
53
+ * ## Surface, not scope — the canonical false positive
54
+ *
55
+ * Two LIVE records look like opposites at subject level and are NOT a
56
+ * contradiction:
57
+ *
58
+ * `5bd963d4-…` a rule-loader frontmatter key is INERT → `rule-loader.mjs` key `paths:`
59
+ * `fe22ff1a-…` a rule-loader frontmatter key is LIVE → `rule-loader.mjs` key `expires-at`
60
+ *
61
+ * Same file, different keys. Nothing mechanical separates them: measured
62
+ * 2026-08-13 over the 100 live records, `scope` is `local` in 100/100 and
63
+ * `host_class` is absent in 89/100 — **neither field discriminates**, and only
64
+ * 17/100 carry `file_paths` at all (both records above carry none). A judgment
65
+ * must therefore resolve its `surface` to IDENTIFIER level — file plus the key /
66
+ * function / flag named in the insight text — which is why `surface` is a
67
+ * required, non-empty field on every relation decision.
68
+ *
69
+ * ## Fail-CLOSED contract
70
+ *
71
+ * **The batch is atomic.** One malformed decision voids the WHOLE batch, not
72
+ * just its own entry. Per-decision salvage is fail-open wearing a
73
+ * partial-success costume.
74
+ *
75
+ * "No write" means, per decision type:
76
+ *
77
+ * | decision | no-write means |
78
+ * |---------------------|-----------------------------------------------------------------------------|
79
+ * | `skip` / `abstain` | trivially none |
80
+ * | `refine` | no rule file; no store mutation; no confidence delta; no `expires_at` reset; no link stamped |
81
+ * | `supersede` | no archive append; no store rewrite; no `_archive_reason`/`_superseded_by` stamp |
82
+ * | `merge` | no merged record; no source archived; the candidate is NOT consumed — it stays queued |
83
+ * | `contradict` | no `-0.2`; no `expires_at` touched; **and no AskUserQuestion rendered** |
84
+ *
85
+ * That last clause is the non-obvious one: **rendering an AUQ from an unreadable
86
+ * judgment IS the write.** The AUQ is the write authorization — a garbled
87
+ * verdict an operator approves has failed open *through the human*. Fail-closed
88
+ * therefore means the operator never sees a proposal derived from an unreadable
89
+ * judgment; it does not mean "the operator gets to decide about the garbage".
90
+ * Structurally: the AUQ renderer is registered in {@link EFFECT_BY_DECISION}
91
+ * alongside the archive writer, so it is unreachable by the same gate.
92
+ *
93
+ * One rule covering all eight failure modes: **the ONLY value that may be
94
+ * written on any judgment path is a decision the parser produced and the
95
+ * id-validity gate accepted. There is no default. There is no fallback member.
96
+ * An absent decision performs nothing.**
97
+ *
98
+ * Consequences, spelled out because each was a named MUST-NOT:
99
+ * - No fallback decision on unparseable output (that is the upstream bug this
100
+ * issue exists to avoid) and no retry-with-repair into a write.
101
+ * - No salvage of the valid subset of a partial batch.
102
+ * - A phantom target id never creates the record and never gets dropped so the
103
+ * rest can proceed — a `supersede` with a silently truncated target list
104
+ * archives the WRONG record.
105
+ * - No coercion of an enum near-miss to the nearest member: coercion is how
106
+ * `abstain` becomes a write.
107
+ * - An empty response never reads as "no relation → safe". Absence of a
108
+ * verdict is not a verdict of no-relation.
109
+ * - A timeout never blocks session close and never auto-retries; the candidate
110
+ * simply stays queued (`requeueCandidate`).
111
+ *
112
+ * ## Tolerance boundary (deliberate)
113
+ *
114
+ * {@link parseJudgment} accepts a parsed object or a raw JSON string, and
115
+ * NOTHING else — no fenced-code-block extraction, no trailing-comma repair, no
116
+ * case-folding. Any tolerance added here is tolerance applied at the fail-closed
117
+ * boundary itself. Providers that wrap JSON in prose must be unwrapped BEFORE
118
+ * this call, by the caller, in a layer that cannot write.
119
+ *
120
+ * ## Contract
121
+ *
122
+ * 1. Never throws. Every entry point returns a verdict / result object.
123
+ * 2. `verdict.ok === false` implies `verdict.decisions.length === 0` and
124
+ * `verdict.requeueCandidate === true`.
125
+ * 3. {@link applyVerdict} invokes an effect handler only when
126
+ * `verdict.ok === true`, and validates every required handler is wired
127
+ * BEFORE invoking the first one (atomicity).
128
+ * 4. Deterministic and clock-free: no `Date.now()`, no fs, no network.
129
+ */
130
+
131
+ import { createHash } from 'node:crypto';
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Enums — closed vocabularies. Nothing coerces INTO these; membership is exact.
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** The six decisions. `store` is deliberately absent — see the module header. */
138
+ export const JUDGMENT_DECISIONS = Object.freeze([
139
+ 'skip',
140
+ 'refine',
141
+ 'supersede',
142
+ 'merge',
143
+ 'contradict',
144
+ 'abstain',
145
+ ]);
146
+
147
+ /** Decisions that assert a relation to at least one other record. */
148
+ export const RELATION_DECISIONS = Object.freeze(['refine', 'supersede', 'merge', 'contradict']);
149
+
150
+ /** Decisions that assert no relation (`skip`) or no judgment (`abstain`). */
151
+ export const NO_RELATION_DECISIONS = Object.freeze(['skip', 'abstain']);
152
+
153
+ /**
154
+ * The eight failure modes. Closed set — the free-text `detail` field carries
155
+ * the discriminator within a mode, so the counter vocabulary stays stable for
156
+ * telemetry across releases.
157
+ */
158
+ export const FAILURE_MODES = Object.freeze([
159
+ 'unparseable',
160
+ 'partial',
161
+ 'phantom_id',
162
+ 'self_reference',
163
+ 'empty',
164
+ 'timeout',
165
+ 'enum_violation',
166
+ 'duplicate_target',
167
+ ]);
168
+
169
+ /**
170
+ * Decision → the effect-handler key {@link applyVerdict} may invoke for it.
171
+ * `null` means "this decision performs nothing" (see the no-write table).
172
+ *
173
+ * `contradict` maps to `proposeContradiction` — the AskUserQuestion renderer —
174
+ * on purpose: registering the AUQ in the SAME map as the archive writer is what
175
+ * makes "an AUQ is a write" structural rather than a comment someone can miss.
176
+ */
177
+ export const EFFECT_BY_DECISION = Object.freeze({
178
+ skip: null,
179
+ abstain: null,
180
+ refine: 'refine',
181
+ supersede: 'supersede',
182
+ merge: 'merge',
183
+ contradict: 'proposeContradiction',
184
+ });
185
+
186
+ /**
187
+ * How many neighbours may be presented in one judgment input.
188
+ *
189
+ * Ceiling: 12 — the judge prompt is a budget, and the measured corpus tops out
190
+ * at 0.14 pairwise Jaccard, so a wider net adds noise, not signal. Revisit if
191
+ * the corpus develops genuine clusters (max pairwise similarity > ~0.4) or if
192
+ * live judgments start naming a truncated-away record in their rationale.
193
+ */
194
+ export const DEFAULT_MAX_NEIGHBOURS = 12;
195
+
196
+ /**
197
+ * Judge wall-clock budget. Ceiling: 60s — a judgment pass runs at session-end
198
+ * next to the close gates, and a stuck provider must never hold the close.
199
+ * Revisit if a batched multi-candidate judge call replaces the per-candidate one.
200
+ */
201
+ export const DEFAULT_JUDGE_TIMEOUT_MS = 60_000;
202
+
203
+ /**
204
+ * The discrimination hint handed to the judge. Its facts are MEASURED, not
205
+ * assumed (see the module header for the command and date) — a hint that told
206
+ * the judge to lean on `scope` or `host_class` would be steering it by a field
207
+ * that is constant across the corpus.
208
+ */
209
+ export const SURFACE_HINT = [
210
+ 'Judge the SURFACE, not the subject line.',
211
+ '',
212
+ 'Measured 2026-08-13 over the 100 live records: `scope` is "local" in 100/100 and',
213
+ '`host_class` is absent in 89/100. NEITHER FIELD DISCRIMINATES — do not use them to',
214
+ 'separate or to relate two records. Only 17/100 carry `file_paths` at all.',
215
+ '',
216
+ 'Resolve the surface to IDENTIFIER level: the file plus the specific key, function,',
217
+ 'flag, or command the record is about — taking identifiers from `file_paths` AND from',
218
+ 'the identifiers named in the insight text, since most records carry no `file_paths`.',
219
+ '',
220
+ 'Two records that name the SAME FILE but DIFFERENT identifiers are NOT in conflict.',
221
+ 'Worked example from the live corpus, which is NOT a contradiction:',
222
+ ' A: a rule-loader frontmatter key is inert -> surface: rule-loader.mjs frontmatter key "paths:"',
223
+ ' B: a rule-loader frontmatter key is live -> surface: rule-loader.mjs frontmatter key "expires-at"',
224
+ 'Same file, different keys, same `scope`, both without `host_class`.',
225
+ '',
226
+ 'When the surface cannot be resolved to identifier level, answer "abstain" — never',
227
+ 'guess a relation from a file name alone.',
228
+ ].join('\n');
229
+
230
+ /**
231
+ * The output shape, co-located with the validator that enforces it. The judge
232
+ * prompt MUST embed this rather than restating the shape in skill prose: a
233
+ * restatement drifts silently, and the failure mode of drift here is that every
234
+ * batch voids and the feature is dead while looking wired.
235
+ */
236
+ export const JUDGMENT_OUTPUT_CONTRACT = [
237
+ 'Answer with JSON only, exactly this shape:',
238
+ '',
239
+ '{"candidate_id": "<the candidate id you were given>",',
240
+ ' "decisions": [',
241
+ ' {"decision": "skip|refine|supersede|merge|contradict|abstain",',
242
+ ' "target_ids": ["<id from the presented neighbours>"],',
243
+ ' "surface": "<file + identifier this decision is about>",',
244
+ ' "rationale": "<one sentence that NAMES the surface>",',
245
+ ' "confidence": 0.0}',
246
+ ' ]}',
247
+ '',
248
+ 'Rules the reader enforces (a violation voids the WHOLE batch — nothing is written):',
249
+ ' - `decision` must be one of the six members EXACTLY, lowercase. No other value.',
250
+ ' - `target_ids` must be empty for `skip` and `abstain`, and non-empty otherwise.',
251
+ ' - Every target id must be one of the ids presented to you. Never invent an id.',
252
+ ' - Never put the candidate id in `target_ids`.',
253
+ ' - Never name the same target id twice, in one decision or across the batch.',
254
+ ' - `surface` and `rationale` are required for refine/supersede/merge/contradict,',
255
+ ' and the rationale must name the surface.',
256
+ ' - `confidence` is a number in [0,1] on every decision.',
257
+ ' - Emit `abstain` when you cannot judge. Never guess, never emit a default.',
258
+ ].join('\n');
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Internals
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /** A judge cannot forge a module-private Symbol, so a timeout cannot be spoofed. */
265
+ const TIMEOUT_SENTINEL = Symbol('judgment-timeout');
266
+
267
+ /** True for a plain-ish object we may read properties off (arrays excluded). */
268
+ function _isRecord(v) {
269
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
270
+ }
271
+
272
+ /** Non-empty string after trimming. */
273
+ function _isNonEmptyString(v) {
274
+ return typeof v === 'string' && v.trim().length > 0;
275
+ }
276
+
277
+ /** Confidence must be a real number in [0,1]. NaN/Infinity/null all fail. */
278
+ function _isValidConfidence(v) {
279
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
280
+ }
281
+
282
+ /**
283
+ * A record is LIVE only if it carries no archive tombstone. `_archived_at` /
284
+ * `_archive_reason` are stamped by `./expiry-sweep.mjs` on everything that
285
+ * leaves the active store. Presenting an archived record as a neighbour would
286
+ * invite a `supersede` that archives an already-archived record — a legal-looking
287
+ * path to a dangling tombstone pointer.
288
+ */
289
+ function _isLive(entry) {
290
+ if (!_isRecord(entry)) return false;
291
+ return entry._archived_at === undefined && entry._archive_reason === undefined;
292
+ }
293
+
294
+ /**
295
+ * Does the rationale name the surface?
296
+ *
297
+ * Lexical containment: the whole surface, or any of its tokens of length >= 3.
298
+ * Ceiling: this is lexical, NOT semantic — a rationale that refers to the
299
+ * surface only by synonym is rejected, and rejection means the batch voids.
300
+ * That asymmetry is deliberate (a false void costs one missed judgment; a
301
+ * vacuous rationale costs an unreviewable write). Revisit if live judgments trip
302
+ * this on more than a rare case.
303
+ */
304
+ function _rationaleNamesSurface(rationale, surface) {
305
+ const r = rationale.toLowerCase();
306
+ const s = surface.toLowerCase().trim();
307
+ if (s.length > 0 && r.includes(s)) return true;
308
+ const tokens = s.split(/[^a-z0-9_.-]+/).filter((t) => t.length >= 3);
309
+ return tokens.some((t) => r.includes(t));
310
+ }
311
+
312
+ /** The zero-value counter block. Built fresh — never a shared mutable singleton. */
313
+ function _emptyCounters() {
314
+ /** @type {Record<string, number>} */
315
+ const failure_modes = {};
316
+ for (const m of FAILURE_MODES) failure_modes[m] = 0;
317
+ /** @type {Record<string, number>} */
318
+ const decisions = {};
319
+ for (const d of JUDGMENT_DECISIONS) decisions[d] = 0;
320
+ return { batches: 1, voided: 0, failure_modes, decisions };
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Public surface
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * @typedef {{decision: string, target_ids: string[], surface: string,
329
+ * rationale: string, confidence: number}} JudgmentDecision
330
+ *
331
+ * @typedef {{ok: boolean, candidate_id: string|null, decisions: JudgmentDecision[],
332
+ * failureMode: string|null, detail: string, requeueCandidate: boolean,
333
+ * counters: object}} Verdict
334
+ */
335
+
336
+ /**
337
+ * Build a voided verdict — the ONLY shape any failure path produces.
338
+ *
339
+ * Exported so the `/evolve` wiring can void on a transport failure it detects
340
+ * before this module ever sees output (a dropped connection, a refused call)
341
+ * without hand-rolling a second, subtly different "nothing happened" object.
342
+ *
343
+ * A voided verdict carries ZERO decisions by construction: there is no field a
344
+ * caller could read to salvage a partial batch. `requeueCandidate` is always
345
+ * true — the candidate was not consumed and stays queued for the next pass.
346
+ *
347
+ * @param {string} failureMode — one of {@link FAILURE_MODES}
348
+ * @param {string} [detail] — free-text discriminator; never used for control flow
349
+ * @param {string|null} [candidateId]
350
+ * @returns {Verdict}
351
+ */
352
+ export function voidVerdict(failureMode, detail = '', candidateId = null) {
353
+ const counters = _emptyCounters();
354
+ const mode = FAILURE_MODES.includes(failureMode) ? failureMode : 'unparseable';
355
+ counters.voided = 1;
356
+ counters.failure_modes[mode] += 1;
357
+ // A voided batch resolves to abstain, never to skip: an undecided case must
358
+ // stay distinguishable from a decided one in telemetry.
359
+ counters.decisions.abstain += 1;
360
+ return {
361
+ ok: false,
362
+ candidate_id: typeof candidateId === 'string' ? candidateId : null,
363
+ decisions: [],
364
+ failureMode: mode,
365
+ detail: typeof detail === 'string' ? detail : '',
366
+ requeueCandidate: true,
367
+ counters,
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Build the corpus fingerprint: the set of ids a verdict may legally name.
373
+ *
374
+ * The valid id set is exactly the ids PRESENTED to the judge, not the whole
375
+ * store. That is the stronger gate and it subsumes the weaker one: a judge
376
+ * cannot legitimately relate the candidate to a record it was never shown, and
377
+ * "was this id presented" is checkable without a second read of the store —
378
+ * which is the whole point of shipping the fingerprint inside the input.
379
+ *
380
+ * @param {object[]} records — the neighbours that will be presented
381
+ * @returns {{ids: string[], count: number, digest: string}}
382
+ */
383
+ export function buildCorpusFingerprint(records) {
384
+ const seen = new Set();
385
+ if (Array.isArray(records)) {
386
+ for (const r of records) {
387
+ const id = _isRecord(r) ? r.id : undefined;
388
+ if (typeof id === 'string' && id.length > 0) seen.add(id);
389
+ }
390
+ }
391
+ const ids = [...seen].sort();
392
+ // Digest over the sorted ids lets a caller detect a verdict computed against a
393
+ // DIFFERENT presentation (a stale retry, a reordered pool) for the price of
394
+ // one hash; `parseJudgment` enforces it only when the verdict echoes it back.
395
+ const digest = createHash('sha256').update(ids.join('\n')).digest('hex').slice(0, 16);
396
+ return { ids, count: ids.length, digest };
397
+ }
398
+
399
+ /**
400
+ * Assemble the judgment input.
401
+ *
402
+ * Neighbours are filtered to LIVE records only, the candidate is never its own
403
+ * neighbour, and the set is capped at `maxNeighbours` in the order given (the
404
+ * caller's ranking is authoritative — this module adds no second ranking).
405
+ *
406
+ * @param {object} args
407
+ * @param {object} args.candidate — the record under judgment
408
+ * @param {object[]} args.neighbours — bounded candidate pool, LIVE store only
409
+ * @param {number} [args.maxNeighbours=DEFAULT_MAX_NEIGHBOURS]
410
+ * @returns {{candidate: object, neighbours: object[], corpus_fingerprint: object,
411
+ * surface_hint: string, decisions_allowed: readonly string[],
412
+ * output_contract: string}|null}
413
+ * `null` when the candidate has no usable id — an input whose id gate cannot
414
+ * be checked must not be judged at all.
415
+ */
416
+ export function buildJudgmentInput({
417
+ candidate,
418
+ neighbours,
419
+ maxNeighbours = DEFAULT_MAX_NEIGHBOURS,
420
+ } = {}) {
421
+ if (!_isRecord(candidate) || typeof candidate.id !== 'string' || candidate.id.length === 0) {
422
+ return null;
423
+ }
424
+ const cap =
425
+ Number.isInteger(maxNeighbours) && maxNeighbours >= 0 ? maxNeighbours : DEFAULT_MAX_NEIGHBOURS;
426
+
427
+ const pool = [];
428
+ const seenIds = new Set();
429
+ for (const n of Array.isArray(neighbours) ? neighbours : []) {
430
+ if (pool.length >= cap) break;
431
+ if (!_isLive(n)) continue;
432
+ if (typeof n.id !== 'string' || n.id.length === 0) continue;
433
+ if (n.id === candidate.id) continue; // never its own neighbour — see F4
434
+ if (seenIds.has(n.id)) continue;
435
+ seenIds.add(n.id);
436
+ pool.push(n);
437
+ }
438
+
439
+ return {
440
+ candidate,
441
+ neighbours: pool,
442
+ corpus_fingerprint: buildCorpusFingerprint(pool),
443
+ surface_hint: SURFACE_HINT,
444
+ decisions_allowed: JUDGMENT_DECISIONS,
445
+ output_contract: JUDGMENT_OUTPUT_CONTRACT,
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Parse and validate a candidate verdict against its input. Never throws.
451
+ *
452
+ * Precedence is declaration order below; the FIRST violation encountered voids
453
+ * the batch and names the mode. Validation is total before anything is returned:
454
+ * there is no path that returns a subset of the decisions.
455
+ *
456
+ * @param {string|object} raw — raw JSON text, or an already-parsed object
457
+ * @param {object} input — the {@link buildJudgmentInput} result the judge saw
458
+ * @returns {Verdict}
459
+ */
460
+ export function parseJudgment(raw, input) {
461
+ // The id gate is uncheckable without a usable input, so an unusable input is
462
+ // itself a void — never a permissive pass-through.
463
+ if (
464
+ !_isRecord(input) ||
465
+ !_isRecord(input.candidate) ||
466
+ typeof input.candidate.id !== 'string' ||
467
+ input.candidate.id.length === 0 ||
468
+ !_isRecord(input.corpus_fingerprint) ||
469
+ !Array.isArray(input.corpus_fingerprint.ids)
470
+ ) {
471
+ return voidVerdict('partial', 'judgment-input-unusable: cannot check the id gate');
472
+ }
473
+
474
+ const candidateId = input.candidate.id;
475
+ const validIds = new Set(input.corpus_fingerprint.ids);
476
+
477
+ // F5 — empty response. Zero bytes / whitespace / nullish is NOT "no relation".
478
+ if (raw === null || raw === undefined) {
479
+ return voidVerdict('empty', 'judge returned no output', candidateId);
480
+ }
481
+ if (typeof raw === 'string' && raw.trim().length === 0) {
482
+ return voidVerdict('empty', 'judge returned an empty string', candidateId);
483
+ }
484
+
485
+ // F1 — unparseable. No fence stripping, no repair (see § Tolerance boundary).
486
+ let parsed = raw;
487
+ if (typeof raw === 'string') {
488
+ try {
489
+ parsed = JSON.parse(raw);
490
+ } catch (err) {
491
+ return voidVerdict('unparseable', `JSON.parse failed: ${err?.message ?? err}`, candidateId);
492
+ }
493
+ }
494
+ if (!_isRecord(parsed)) {
495
+ return voidVerdict(
496
+ 'unparseable',
497
+ `expected a JSON object envelope, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
498
+ candidateId,
499
+ );
500
+ }
501
+
502
+ // F2 — partial. Envelope fields.
503
+ if (parsed.candidate_id !== candidateId) {
504
+ return voidVerdict(
505
+ 'partial',
506
+ `candidate_id mismatch: expected ${candidateId}, got ${JSON.stringify(parsed.candidate_id)}`,
507
+ candidateId,
508
+ );
509
+ }
510
+ if (parsed.corpus_fingerprint !== undefined) {
511
+ // Only enforced when the judge echoes it back — an echo that disagrees means
512
+ // the verdict was computed against a different presentation.
513
+ const echoed = _isRecord(parsed.corpus_fingerprint)
514
+ ? parsed.corpus_fingerprint.digest
515
+ : parsed.corpus_fingerprint;
516
+ if (echoed !== input.corpus_fingerprint.digest) {
517
+ return voidVerdict(
518
+ 'partial',
519
+ 'corpus_fingerprint echo does not match the input',
520
+ candidateId,
521
+ );
522
+ }
523
+ }
524
+ if (!Array.isArray(parsed.decisions)) {
525
+ return voidVerdict('partial', 'envelope has no `decisions` array', candidateId);
526
+ }
527
+ if (parsed.decisions.length === 0) {
528
+ return voidVerdict('empty', 'envelope carries zero decisions', candidateId);
529
+ }
530
+
531
+ /** @type {JudgmentDecision[]} */
532
+ const decisions = [];
533
+ const targetsSeen = new Set();
534
+
535
+ for (let i = 0; i < parsed.decisions.length; i++) {
536
+ const d = parsed.decisions[i];
537
+ const at = `decisions[${i}]`;
538
+
539
+ if (!_isRecord(d)) return voidVerdict('partial', `${at} is not an object`, candidateId);
540
+
541
+ // F2 before F7: an ABSENT decision field is partial output; a PRESENT but
542
+ // wrong one (including casing drift) is an enum violation.
543
+ if (d.decision === undefined || d.decision === null) {
544
+ return voidVerdict('partial', `${at} has no \`decision\``, candidateId);
545
+ }
546
+ // F7 — enum violation. Exact membership. Nothing is coerced to the nearest
547
+ // member: coercion is how `abstain` becomes a write.
548
+ if (typeof d.decision !== 'string' || !JUDGMENT_DECISIONS.includes(d.decision)) {
549
+ return voidVerdict(
550
+ 'enum_violation',
551
+ `${at}.decision is not an exact enum member: ${JSON.stringify(d.decision)}`,
552
+ candidateId,
553
+ );
554
+ }
555
+ if (d.candidate_id !== undefined && d.candidate_id !== candidateId) {
556
+ return voidVerdict(
557
+ 'partial',
558
+ `${at}.candidate_id mismatch: ${JSON.stringify(d.candidate_id)}`,
559
+ candidateId,
560
+ );
561
+ }
562
+
563
+ const isRelation = RELATION_DECISIONS.includes(d.decision);
564
+
565
+ if (!Array.isArray(d.target_ids)) {
566
+ return voidVerdict('partial', `${at} has no \`target_ids\` array`, candidateId);
567
+ }
568
+ // Empty IFF skip/abstain — both directions are a shape violation.
569
+ if (isRelation && d.target_ids.length === 0) {
570
+ return voidVerdict('partial', `${at}.target_ids is empty for "${d.decision}"`, candidateId);
571
+ }
572
+ if (!isRelation && d.target_ids.length > 0) {
573
+ return voidVerdict(
574
+ 'partial',
575
+ `${at}.target_ids must be empty for "${d.decision}"`,
576
+ candidateId,
577
+ );
578
+ }
579
+
580
+ for (const tid of d.target_ids) {
581
+ if (!_isNonEmptyString(tid)) {
582
+ return voidVerdict(
583
+ 'partial',
584
+ `${at}.target_ids contains a non-string id: ${JSON.stringify(tid)}`,
585
+ candidateId,
586
+ );
587
+ }
588
+ // F4 — self-reference. Archiving a record into itself is data loss through
589
+ // a legal-looking path. Checked before the phantom gate so a self-reference
590
+ // is never mis-reported as a phantom id.
591
+ if (tid === candidateId) {
592
+ return voidVerdict(
593
+ 'self_reference',
594
+ `${at}.target_ids names the candidate itself (${tid})`,
595
+ candidateId,
596
+ );
597
+ }
598
+ // F3 — phantom id. The bad id is NOT dropped so the rest can proceed: a
599
+ // `supersede` with a truncated target list archives the wrong record.
600
+ if (!validIds.has(tid)) {
601
+ return voidVerdict(
602
+ 'phantom_id',
603
+ `${at}.target_ids names an id that was never presented: ${tid}`,
604
+ candidateId,
605
+ );
606
+ }
607
+ // F8 — duplicate target. Applying in array order would let the second
608
+ // write operate on an already-archived record.
609
+ if (targetsSeen.has(tid)) {
610
+ return voidVerdict('duplicate_target', `${at}.target_ids repeats ${tid}`, candidateId);
611
+ }
612
+ targetsSeen.add(tid);
613
+ }
614
+
615
+ // `surface` / `rationale` are required on RELATION decisions only. A decision
616
+ // that writes nothing needs no surface — and demanding one would void batches
617
+ // for a field that guards nothing.
618
+ if (isRelation) {
619
+ if (!_isNonEmptyString(d.surface)) {
620
+ return voidVerdict('partial', `${at} has no non-empty \`surface\``, candidateId);
621
+ }
622
+ if (!_isNonEmptyString(d.rationale)) {
623
+ return voidVerdict('partial', `${at} has no non-empty \`rationale\``, candidateId);
624
+ }
625
+ if (!_rationaleNamesSurface(d.rationale, d.surface)) {
626
+ return voidVerdict(
627
+ 'partial',
628
+ `${at}.rationale does not name the surface "${d.surface}"`,
629
+ candidateId,
630
+ );
631
+ }
632
+ } else if (d.surface !== undefined && typeof d.surface !== 'string') {
633
+ return voidVerdict('partial', `${at}.surface is not a string`, candidateId);
634
+ } else if (d.rationale !== undefined && typeof d.rationale !== 'string') {
635
+ return voidVerdict('partial', `${at}.rationale is not a string`, candidateId);
636
+ }
637
+
638
+ if (!_isValidConfidence(d.confidence)) {
639
+ return voidVerdict(
640
+ 'partial',
641
+ `${at}.confidence is not a number in [0,1]: ${JSON.stringify(d.confidence)}`,
642
+ candidateId,
643
+ );
644
+ }
645
+
646
+ decisions.push(
647
+ Object.freeze({
648
+ decision: d.decision,
649
+ target_ids: Object.freeze([...d.target_ids]),
650
+ surface: typeof d.surface === 'string' ? d.surface : '',
651
+ rationale: typeof d.rationale === 'string' ? d.rationale : '',
652
+ confidence: d.confidence,
653
+ }),
654
+ );
655
+ }
656
+
657
+ const counters = _emptyCounters();
658
+ for (const d of decisions) counters.decisions[d.decision] += 1;
659
+
660
+ return {
661
+ ok: true,
662
+ candidate_id: candidateId,
663
+ decisions,
664
+ failureMode: null,
665
+ detail: '',
666
+ // A `merge` consumes the candidate only once it is APPLIED; on any void the
667
+ // candidate is untouched and stays queued.
668
+ requeueCandidate: false,
669
+ counters,
670
+ };
671
+ }
672
+
673
+ /**
674
+ * Run the injected judge under a timeout and validate whatever comes back.
675
+ * Never throws, never retries, never blocks longer than `timeoutMs`.
676
+ *
677
+ * A judge that throws maps to `unparseable` — no readable verdict came back, and
678
+ * every MUST-NOT of that mode applies identically (no default, no repair-retry,
679
+ * no AUQ). The thrown message lands in `detail`, which is diagnostic only.
680
+ *
681
+ * @param {object} input — a {@link buildJudgmentInput} result
682
+ * @param {object} opts
683
+ * @param {(input: object) => any} opts.judge — injected; returns raw JSON text or an object
684
+ * @param {number} [opts.timeoutMs=DEFAULT_JUDGE_TIMEOUT_MS]
685
+ * @returns {Promise<Verdict>}
686
+ */
687
+ export async function judgeCandidate(input, opts = {}) {
688
+ const candidateId = _isRecord(input) && _isRecord(input.candidate) ? input.candidate.id : null;
689
+ const judge = _isRecord(opts) ? opts.judge : undefined;
690
+ if (typeof judge !== 'function') {
691
+ return voidVerdict('unparseable', 'no judge function injected', candidateId ?? null);
692
+ }
693
+ const timeoutMs =
694
+ typeof opts.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
695
+ ? opts.timeoutMs
696
+ : DEFAULT_JUDGE_TIMEOUT_MS;
697
+
698
+ let timer;
699
+ try {
700
+ const timeout = new Promise((resolve) => {
701
+ timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs);
702
+ // Unref so a resolved-early judge lets the process exit without waiting
703
+ // out the full budget (testing.md § Async & Timeout Patterns).
704
+ if (timer && typeof timer.unref === 'function') timer.unref();
705
+ });
706
+ const raw = await Promise.race([Promise.resolve().then(() => judge(input)), timeout]);
707
+ if (raw === TIMEOUT_SENTINEL) {
708
+ // F6 — never applies a partially-streamed array, never auto-retries, never
709
+ // blocks session close. The candidate simply stays queued.
710
+ return voidVerdict('timeout', `judge exceeded ${timeoutMs}ms`, candidateId ?? null);
711
+ }
712
+ return parseJudgment(raw, input);
713
+ } catch (err) {
714
+ return voidVerdict('unparseable', `judge threw: ${err?.message ?? err}`, candidateId ?? null);
715
+ } finally {
716
+ clearTimeout(timer);
717
+ }
718
+ }
719
+
720
+ /**
721
+ * The single choke point where a judgment may become an effect.
722
+ *
723
+ * Refuses outright unless `verdict.ok === true`, so every one of the eight
724
+ * failure modes reaches zero handler invocations — including
725
+ * `proposeContradiction`, the AskUserQuestion renderer, because rendering an AUQ
726
+ * from an unreadable judgment IS the write.
727
+ *
728
+ * Handlers are resolved for the WHOLE batch before the first invocation: an
729
+ * unwired handler refuses the batch instead of applying the decisions that
730
+ * happened to come first.
731
+ *
732
+ * Ceiling: already-invoked effects are NOT rolled back when a later handler
733
+ * throws — transactionality across heterogeneous effects (archive append + store
734
+ * rewrite + AUQ) belongs to the caller that owns those resources. Revisit if a
735
+ * batch can carry more than one relation decision with interdependent writes.
736
+ *
737
+ * @param {Verdict} verdict
738
+ * @param {Record<string, (decision: JudgmentDecision, verdict: Verdict) => any>} [effects]
739
+ * @returns {Promise<{applied: boolean, invoked: string[], results: any[], refused: string|null}>}
740
+ */
741
+ export async function applyVerdict(verdict, effects = {}) {
742
+ const refuse = (reason) => ({ applied: false, invoked: [], results: [], refused: reason });
743
+
744
+ if (!_isRecord(verdict) || verdict.ok !== true || !Array.isArray(verdict.decisions)) {
745
+ return refuse(
746
+ `verdict-not-ok:${_isRecord(verdict) ? (verdict.failureMode ?? 'unknown') : 'malformed'}`,
747
+ );
748
+ }
749
+ if (verdict.decisions.length === 0) return refuse('verdict-has-no-decisions');
750
+
751
+ const handlers = _isRecord(effects) ? effects : {};
752
+
753
+ // Pass 1 — resolve every required handler. Nothing is invoked yet.
754
+ /** @type {{decision: JudgmentDecision, key: string, fn: Function}[]} */
755
+ const plan = [];
756
+ for (const d of verdict.decisions) {
757
+ const key = EFFECT_BY_DECISION[d.decision];
758
+ if (!key) continue; // skip / abstain perform nothing
759
+ const fn = handlers[key];
760
+ if (typeof fn !== 'function') return refuse(`effect-not-wired:${key}`);
761
+ plan.push({ decision: d, key, fn });
762
+ }
763
+
764
+ // Pass 2 — invoke.
765
+ const invoked = [];
766
+ const results = [];
767
+ for (const step of plan) {
768
+ try {
769
+ results.push(await step.fn(step.decision, verdict));
770
+ invoked.push(step.key);
771
+ } catch (err) {
772
+ return {
773
+ applied: false,
774
+ invoked,
775
+ results,
776
+ refused: `effect-threw:${step.key}:${err?.message ?? err}`,
777
+ };
778
+ }
779
+ }
780
+
781
+ return { applied: true, invoked, results, refused: null };
782
+ }