@ijfw/memory-server 1.4.4 → 1.5.1

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 (245) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  23. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  24. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  26. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  27. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  28. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  29. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  47. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  48. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  49. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  50. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  52. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  53. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  54. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  68. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  69. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  71. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  72. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  73. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  74. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  92. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  93. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  94. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  95. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  96. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  97. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  98. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  99. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  115. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  116. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  117. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  118. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  119. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  120. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  121. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  122. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  123. package/package.json +6 -3
  124. package/src/active-extension-writer.js +144 -64
  125. package/src/api-client.js +43 -5
  126. package/src/audit-roster.js +80 -5
  127. package/src/blackboard.js +298 -6
  128. package/src/cli-run.js +33 -5
  129. package/src/codex-agents.js +96 -5
  130. package/src/cost/aggregator.js +39 -9
  131. package/src/cost/pricing.js +57 -0
  132. package/src/cost/readers/gemini.js +1 -1
  133. package/src/cross-audit-chunker.js +189 -0
  134. package/src/cross-dispatcher.js +124 -21
  135. package/src/cross-orchestrator-cli.js +754 -159
  136. package/src/cross-orchestrator.js +1065 -17
  137. package/src/cross-project-search.js +195 -9
  138. package/src/dashboard-client-waves.html +304 -0
  139. package/src/dashboard-client.html +5 -1
  140. package/src/dashboard-server.js +73 -0
  141. package/src/deploy-alerts.js +150 -0
  142. package/src/design/iframe-bridge.js +242 -0
  143. package/src/design-companion.js +144 -0
  144. package/src/dispatch/checkpoint-cli.js +97 -0
  145. package/src/dispatch/colon-syntax.js +81 -1
  146. package/src/dispatch/extension.js +26 -2
  147. package/src/dispatch/registry-cli.js +4 -1
  148. package/src/dispatch/wave-cli.js +201 -6
  149. package/src/dispatch/worktree-cli.js +40 -0
  150. package/src/dispatch-planner.js +97 -2
  151. package/src/dream/runner.mjs +47 -11
  152. package/src/dream/stage-runner.js +40 -0
  153. package/src/dream/state-file.js +102 -0
  154. package/src/extension-installer.js +70 -24
  155. package/src/extension-quota-tracker.js +4 -2
  156. package/src/extension-registry.js +289 -35
  157. package/src/feedback-detector.js +26 -0
  158. package/src/fs-lock.js +259 -7
  159. package/src/gate-result.js +95 -1
  160. package/src/hardware-signer.js +4 -2
  161. package/src/hero-line.js +86 -5
  162. package/src/intent-router.js +35 -0
  163. package/src/lib/a11y-contract.js +117 -0
  164. package/src/lib/atomic-io.js +29 -8
  165. package/src/lib/cache-keepalive.js +150 -0
  166. package/src/lib/jsonl-rotation.js +104 -0
  167. package/src/lib/lighthouse-pillar.js +121 -0
  168. package/src/lib/llm-call.js +121 -0
  169. package/src/lib/playwright-baseline.js +205 -0
  170. package/src/lib/rekor-bridge.js +221 -0
  171. package/src/lib/repo-map.js +392 -0
  172. package/src/lib/shasum-verify.js +164 -0
  173. package/src/lib/sketches-gc.js +132 -0
  174. package/src/lib/tmp-suffix.js +62 -0
  175. package/src/lib/ui-review-runner.js +595 -0
  176. package/src/lib/uispec-drift.js +301 -0
  177. package/src/lib/uispec-intake.js +381 -0
  178. package/src/lib/worktree-guards.js +118 -0
  179. package/src/lib/worktree-recovery.js +100 -0
  180. package/src/memory/auto-linker.js +267 -0
  181. package/src/memory/benchmark.js +498 -0
  182. package/src/memory/dedup.js +126 -0
  183. package/src/memory/embedding-cache.js +136 -0
  184. package/src/memory/fact-extractor.js +168 -0
  185. package/src/memory/fts5.js +65 -1
  186. package/src/memory/migration-runner.js +6 -1
  187. package/src/memory/migrations/004-bitemporal.js +91 -0
  188. package/src/memory/migrations/005-vector-cache.js +61 -0
  189. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  190. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  191. package/src/memory/migrations/008-write-provenance.js +41 -0
  192. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  193. package/src/memory/obsidian-parser.js +152 -0
  194. package/src/memory/query-dataview.js +86 -0
  195. package/src/memory/search.js +46 -15
  196. package/src/memory/temporal.js +529 -0
  197. package/src/memory/tokenize.js +10 -0
  198. package/src/memory-facts-handler.js +37 -0
  199. package/src/memory-feedback.js +260 -2
  200. package/src/model-refresh.js +292 -0
  201. package/src/observability/cost-anomaly.js +166 -0
  202. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  203. package/src/observability/trace-id.js +163 -0
  204. package/src/orchestrator/agents-md-blackboard.js +152 -0
  205. package/src/orchestrator/checkpoint-contract.md +140 -0
  206. package/src/orchestrator/debug-trident-trigger.js +374 -0
  207. package/src/orchestrator/debug-trident.js +570 -0
  208. package/src/orchestrator/merge-block-aware.js +350 -0
  209. package/src/orchestrator/plan-checker.js +475 -0
  210. package/src/orchestrator/post-done-runner.js +277 -0
  211. package/src/orchestrator/review.js +38 -3
  212. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  213. package/src/orchestrator/skill-telemetry.js +37 -0
  214. package/src/orchestrator/state-events.js +459 -0
  215. package/src/orchestrator/state-sdk.js +1932 -0
  216. package/src/orchestrator/status-protocol.js +84 -17
  217. package/src/orchestrator/subagent-telemetry.js +471 -0
  218. package/src/orchestrator/termination.js +160 -0
  219. package/src/orchestrator/verification-gate.js +200 -16
  220. package/src/orchestrator/wave-state.js +332 -23
  221. package/src/orchestrator/worktree-provision.js +77 -0
  222. package/src/override-resolver.js +5 -3
  223. package/src/override-use-registry.js +111 -5
  224. package/src/receipts.js +36 -4
  225. package/src/recovery/checkpoint.js +56 -3
  226. package/src/recovery/code-fixer.js +961 -0
  227. package/src/recovery/truncation.js +317 -0
  228. package/src/redactor.js +75 -6
  229. package/src/runtime-mediator.js +15 -1
  230. package/src/sanitizer.js +10 -0
  231. package/src/search-hybrid.js +139 -0
  232. package/src/server.js +795 -112
  233. package/src/swarm/worktree.js +27 -4
  234. package/src/swarm-config.js +102 -17
  235. package/src/team/domain-templates/book.json +51 -0
  236. package/src/team/domain-templates/business.json +44 -0
  237. package/src/team/domain-templates/content.json +50 -0
  238. package/src/team/domain-templates/design.json +44 -0
  239. package/src/team/domain-templates/research.json +44 -0
  240. package/src/team/domain-templates/software.json +40 -0
  241. package/src/team/generator.js +440 -3
  242. package/src/team/modify.js +203 -0
  243. package/src/team/schemas.js +48 -0
  244. package/src/update-apply.js +19 -3
  245. package/src/dashboard-charts.js +0 -239
@@ -23,20 +23,105 @@ import { buildRequest, parseResponse, mergeResponses, checkBudget } from './cros
23
23
  import { writeReceipt, readReceipts } from './receipts.js';
24
24
  import { runViaApi } from './api-client.js';
25
25
  import { RELEASE_BLOCKER_GATES, DegradedTridentError } from './trident/dispatch.js';
26
+ // v1.5.0 wire-W1.B — Anthropic ephemeral-cache TTL heartbeat for long
27
+ // convergence waves. Opt-in via IJFW_CACHE_KEEPALIVE_MS env (1s..5min).
28
+ import { startKeepaliveFromEnv } from './lib/cache-keepalive.js';
29
+ // v1.5.0 T21 (W4) — convergence telemetry via state-SDK telemetry.record verb.
30
+ // Writes `.ijfw/telemetry/convergence.json` with cycles-to-converge, false-
31
+ // positive rate, and cost. Routed through the SDK so the append is journaled,
32
+ // dedup'd, and tap-emitted on the standard observability surface.
33
+ import { query as _stateQuery } from './orchestrator/state-sdk.js';
26
34
 
27
35
  // ---------------------------------------------------------------------------
28
36
  // Per-provider timeout defaults (ms). Codex cold-start can take 120s+ (U2).
37
+ // r17.1 — bumped gemini 45s → 90s. The 45s budget was tuned for Gemini 2.x
38
+ // which routinely returned in 10-20s; Gemini 3.x cold starts + larger context
39
+ // windows mean a single audit on a 10KB target can exceed 45s end-to-end
40
+ // (verified in cross-audit r16 on this codebase). 90s gives realistic
41
+ // headroom without making a hung process feel hung.
29
42
  // ---------------------------------------------------------------------------
30
43
  const PROVIDER_TIMEOUT_MS = {
31
44
  codex: 120_000,
32
- gemini: 45_000,
45
+ gemini: 90_000,
33
46
  anthropic: 60_000,
34
47
  'api-mode': 30_000,
35
48
  };
36
49
  const DEFAULT_TIMEOUT_MS = 90_000;
37
50
 
51
+ // r17.1 — retry budget for transient timeouts. Gemini's API frequently returns
52
+ // transient 502s and TCP hangs that resolve on a second attempt; codex more
53
+ // often has a real cold-start problem that needs the longer first timeout
54
+ // rather than a retry. Default: 1 retry, only for the gemini family. Per-
55
+ // auditor `retryOnTimeout: true|false` in audit-roster.js overrides.
56
+ // r17-L1 closure: also keyed by `family` so any future google-family auditor
57
+ // (e.g. a "gemini-fast" id) inherits the family retry policy without us
58
+ // having to add every id explicitly.
59
+ const PROVIDER_RETRY_ON_TIMEOUT = {
60
+ gemini: true,
61
+ };
62
+ const FAMILY_RETRY_ON_TIMEOUT = {
63
+ google: true,
64
+ };
65
+
66
+ // v1.5.0 audit-MED-trident-M7 — per-(provider, path) retry matrix.
67
+ // CLI cold-start failure modes (process startup, codex/gemini binary auth) are
68
+ // different from API failure modes (transient 502s, network blips). A blanket
69
+ // "no retry for codex" was wrong: codex CLI shouldn't retry (cold-start hits
70
+ // the same wall), but codex *API* path absolutely should retry on transient
71
+ // network noise. Resolves a finding where the api-fallback path inherited the
72
+ // CLI's no-retry policy and gave up after one HTTP 502.
73
+ //
74
+ // Lookup precedence (most → least specific):
75
+ // 1. pick.retryMatrix[path] -- explicit per-auditor override
76
+ // 2. PROVIDER_RETRY_PATH[id][path]
77
+ // 3. FAMILY_RETRY_PATH[family][path]
78
+ // 4. PROVIDER_RETRY_ON_TIMEOUT[id] / FAMILY_RETRY_ON_TIMEOUT[family]
79
+ // (legacy single-axis policy, kept for back-compat)
80
+ // 5. default: false (CLI), true (API)
81
+ const PROVIDER_RETRY_PATH = {
82
+ // codex CLI cold-start is real work; API is HTTP and benefits from retry.
83
+ codex: { cli: false, api: true },
84
+ gemini: { cli: true, api: true },
85
+ };
86
+ const FAMILY_RETRY_PATH = {
87
+ openai: { cli: false, api: true },
88
+ google: { cli: true, api: true },
89
+ anthropic: { cli: false, api: true },
90
+ };
91
+
92
+ // shouldRetryOnTimeout(pick, path) -- path is 'cli' | 'api'.
93
+ function shouldRetryOnTimeout(pick, path) {
94
+ // Explicit pick-level override.
95
+ if (pick && pick.retryMatrix && pick.retryMatrix[path] !== undefined) {
96
+ return pick.retryMatrix[path] === true;
97
+ }
98
+ // Per-provider, per-path.
99
+ const provider = pick && pick.id;
100
+ if (provider && PROVIDER_RETRY_PATH[provider] && PROVIDER_RETRY_PATH[provider][path] !== undefined) {
101
+ return PROVIDER_RETRY_PATH[provider][path] === true;
102
+ }
103
+ // Per-family, per-path.
104
+ const family = pick && pick.family;
105
+ if (family && FAMILY_RETRY_PATH[family] && FAMILY_RETRY_PATH[family][path] !== undefined) {
106
+ return FAMILY_RETRY_PATH[family][path] === true;
107
+ }
108
+ // Legacy single-axis fallback (preserves r17.1 behavior for non-matrixed picks).
109
+ if (pick && pick.retryOnTimeout === true) return true;
110
+ if (pick && pick.retryOnTimeout === false) return false;
111
+ if (provider && PROVIDER_RETRY_ON_TIMEOUT[provider] === true) return true;
112
+ if (family && FAMILY_RETRY_ON_TIMEOUT[family] === true) return true;
113
+ // Default: API path retries on timeout; CLI does not.
114
+ return path === 'api';
115
+ }
116
+
117
+ // Exported for tests.
118
+ export const _shouldRetryOnTimeout = shouldRetryOnTimeout;
119
+
38
120
  function timeoutForPick(pick, resolvedTimeoutSec) {
39
121
  if (resolvedTimeoutSec) return resolvedTimeoutSec * 1000;
122
+ // v1.5.0 S7: roster-level override takes precedence over family default.
123
+ // Codex needs 8min for review work vs 90s default; see audit-roster.js.
124
+ if (pick.timeoutMs) return pick.timeoutMs;
40
125
  return PROVIDER_TIMEOUT_MS[pick.id] ?? DEFAULT_TIMEOUT_MS;
41
126
  }
42
127
 
@@ -121,11 +206,72 @@ function angleFor(mode, id) {
121
206
  // silently pick up an unrelated gcloud project (cloudaicompanion.googleapis.com
122
207
  // billing collisions). Reproduced by Kat in issue #9.
123
208
  //
124
- // Precedence we enforce when GEMINI_API_KEY is set:
209
+ // v1.5.0 audit-MED-trident-M2 (F-SEC-1): per-pick API-key allowlist.
210
+ // Previously every cross-fired auditor inherited the FULL parent env --
211
+ // codex saw GEMINI_API_KEY + DEEPSEEK_API_KEY + ANTHROPIC_API_KEY, gemini saw
212
+ // OPENAI_API_KEY, etc. A prompt-injected auditor could egress every key the
213
+ // host has loaded. We now whitelist the keys each auditor legitimately needs
214
+ // (its own auth + minimal POSIX baseline) and drop everything else from the
215
+ // inherited env. Non-secret config vars (PATH, HOME, LANG, IJFW_*, CODEX_*)
216
+ // pass through; vendor API keys are filtered.
217
+ //
218
+ // Precedence we still enforce when GEMINI_API_KEY is set:
125
219
  // GEMINI_API_KEY (kept) > GOOGLE_APPLICATION_CREDENTIALS (dropped)
126
220
  // > gcloud active-project env (dropped)
221
+
222
+ // Vendor API-key env-vars that MUST NOT leak across auditor boundaries.
223
+ // Any key matching this list is dropped unless explicitly re-added by the
224
+ // per-pick allowlist below.
225
+ const VENDOR_API_KEY_VARS = new Set([
226
+ 'OPENAI_API_KEY',
227
+ 'ANTHROPIC_API_KEY',
228
+ 'GEMINI_API_KEY',
229
+ 'GOOGLE_API_KEY',
230
+ 'DEEPSEEK_API_KEY',
231
+ 'DASHSCOPE_API_KEY', // qwen / Alibaba
232
+ 'MOONSHOT_API_KEY', // kimi
233
+ 'GROQ_API_KEY',
234
+ 'XAI_API_KEY', // grok
235
+ 'MISTRAL_API_KEY',
236
+ 'COHERE_API_KEY',
237
+ 'PERPLEXITY_API_KEY',
238
+ 'TOGETHER_API_KEY',
239
+ 'OPENROUTER_API_KEY',
240
+ 'AZURE_OPENAI_API_KEY',
241
+ 'GH_COPILOT_TOKEN',
242
+ 'GITHUB_TOKEN',
243
+ ]);
244
+
245
+ // Per-pick API-key allowlist. Each auditor sees ONLY the keys it needs.
246
+ // Unrecognized picks fall through to a conservative "no vendor keys" default
247
+ // (still inherits PATH/HOME/LANG/etc via the broader env, just no API keys).
248
+ const PER_PICK_API_KEY_ALLOWLIST = {
249
+ codex: ['OPENAI_API_KEY'],
250
+ copilot: ['OPENAI_API_KEY', 'GH_COPILOT_TOKEN', 'GITHUB_TOKEN'],
251
+ gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
252
+ claude: ['ANTHROPIC_API_KEY'],
253
+ deepseek: ['DEEPSEEK_API_KEY'],
254
+ qwen: ['DASHSCOPE_API_KEY'],
255
+ kimi: ['MOONSHOT_API_KEY'],
256
+ opencode: [],
257
+ aider: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'], // aider routes to either
258
+ };
259
+
127
260
  export function buildSpawnEnv(pick, baseEnv) {
128
261
  const env = { ...baseEnv };
262
+
263
+ // M2: strip every vendor API key, then re-inject only the ones this pick
264
+ // is on the allowlist for. The allowlist is keyed by pick.id; unknown ids
265
+ // get an empty allowlist (still safe — drops all vendor keys).
266
+ const pickId = pick && pick.id;
267
+ const allowed = PER_PICK_API_KEY_ALLOWLIST[pickId] || [];
268
+ const allowedSet = new Set(allowed);
269
+ for (const key of VENDOR_API_KEY_VARS) {
270
+ if (!allowedSet.has(key)) delete env[key];
271
+ }
272
+
273
+ // Issue #9-A guard remains: if gemini is the pick AND its own key is set,
274
+ // also scrub gcloud project env vars so they don't silently override.
129
275
  if (pick && pick.id === 'gemini' && env.GEMINI_API_KEY) {
130
276
  delete env.GOOGLE_APPLICATION_CREDENTIALS;
131
277
  delete env.GOOGLE_CLOUD_PROJECT;
@@ -255,23 +401,150 @@ async function fireExternal(pick, request, timeoutMs, env = process.env, signal
255
401
  return { stdout: '', stderr: apiResult.error, exitCode: null, status: 'failed', source: 'none', elapsedMs: elapsed() };
256
402
  }
257
403
 
258
- const raw = await spawnCli(pick, request, timeoutMs, signal, env);
404
+ let raw = await spawnCli(pick, request, timeoutMs, signal, env);
259
405
 
260
406
  // Aborted by runAc
261
407
  if (raw && raw.aborted) {
262
408
  return { stdout: '', stderr: 'aborted', exitCode: null, status: 'aborted', source: 'none', elapsedMs: elapsed() };
263
409
  }
264
410
 
265
- // Explicit timeout -- attempt API fallback before giving up.
266
- if (raw && raw.timedOut) {
267
- if (pick.apiFallback && isReachable(pick.id, env).api) {
411
+ // v1.5.0 audit-MED-tok-M8 + audit-MED-trident-M7 Parallel retry-vs-fallback
412
+ // race, gated by the path-aware retry matrix.
413
+ //
414
+ // The retry policy is per-(provider,path) — CLI cold-start (path='cli') and
415
+ // API transient errors (path='api') are distinct failure modes with distinct
416
+ // retry profiles. shouldRetryOnTimeout() consults the matrix; an explicit
417
+ // `pick.retryMatrix[path] === false` or `pick.retryOnTimeout === false`
418
+ // always wins for caller opt-out.
419
+ //
420
+ // When BOTH a CLI retry AND an api-fallback are eligible, we RACE them
421
+ // concurrently and take the first productive result, capping the timeout-
422
+ // recovery budget at max(retry-timeout, api-mode-timeout) instead of the
423
+ // sequential sum (~25% wall-clock improvement on the gemini unhappy path).
424
+ // When only one channel is eligible, we fall back to sequential behaviour
425
+ // so the change is purely additive — same outcome whenever exactly one
426
+ // recovery channel exists.
427
+ if (raw && raw.timedOut && !signal?.aborted) {
428
+ const canRetry = shouldRetryOnTimeout(pick, 'cli');
429
+ const canFallback = Boolean(pick.apiFallback) && isReachable(pick.id, env).api;
430
+
431
+ if (canRetry && canFallback) {
432
+ // Race retry against api-fallback. Each path runs with its own abort
433
+ // controller; the loser is aborted as soon as we have a winner.
434
+ const retryAc = new AbortController();
435
+ const fallbackAc = new AbortController();
436
+ // Honour the parent runAc abort by cascading into both.
437
+ const onParentAbort = () => { retryAc.abort(); fallbackAc.abort(); };
438
+ if (signal) {
439
+ if (signal.aborted) onParentAbort();
440
+ else signal.addEventListener('abort', onParentAbort, { once: true });
441
+ }
442
+
443
+ const retryPromise = spawnCli(pick, request, timeoutMs, retryAc.signal, env)
444
+ .then(r => ({ kind: 'retry', raw: r }));
445
+
268
446
  const { mode, angle, target } = extractApiParams();
269
- const apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
447
+ // M7: api-path retry on transient timeout (separate from CLI policy).
448
+ // Wrapped in an async IIFE so the per-path retry happens inside the
449
+ // single racing promise.
450
+ const fallbackPromise = (async () => {
451
+ let api = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], fallbackAc.signal);
452
+ if (api.status !== 'ok' && shouldRetryOnTimeout(pick, 'api') && !fallbackAc.signal.aborted) {
453
+ api = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], fallbackAc.signal);
454
+ }
455
+ return { kind: 'fallback', api };
456
+ })();
457
+
458
+ // We want the FIRST PRODUCTIVE result. Promise.race() returns whichever
459
+ // settles first regardless of productivity, so we iterate manually:
460
+ // if the first to resolve is "unproductive" (retry timed out, fallback
461
+ // errored), we await the other.
462
+ const settled = { retry: null, fallback: null };
463
+ let winner = null;
464
+
465
+ // Helper: did this kind produce a usable result?
466
+ const isProductive = (kind) => {
467
+ if (kind === 'retry') {
468
+ const r = settled.retry;
469
+ return Boolean(r && !r.aborted && !r.timedOut && r.exitCode === 0);
470
+ }
471
+ if (kind === 'fallback') {
472
+ return settled.fallback && settled.fallback.status === 'ok';
473
+ }
474
+ return false;
475
+ };
476
+
477
+ // Resolve as soon as ONE side returns a productive result, OR both have settled.
478
+ await new Promise((resolve) => {
479
+ let pending = 2;
480
+ let done = false;
481
+ const finish = (kind) => {
482
+ if (done) return;
483
+ if (isProductive(kind)) {
484
+ done = true;
485
+ winner = kind;
486
+ // Abort the loser so it stops consuming budget.
487
+ if (kind === 'retry') fallbackAc.abort();
488
+ else retryAc.abort();
489
+ resolve();
490
+ return;
491
+ }
492
+ if (--pending === 0) {
493
+ done = true;
494
+ resolve();
495
+ }
496
+ };
497
+ retryPromise.then(({ raw: r }) => { settled.retry = r; finish('retry'); })
498
+ .catch(() => { settled.retry = null; finish('retry'); });
499
+ fallbackPromise.then(({ api: a }) => { settled.fallback = a; finish('fallback'); })
500
+ .catch(() => { settled.fallback = null; finish('fallback'); });
501
+ });
502
+
503
+ // Detach parent-abort listener if it didn't already fire.
504
+ if (signal) {
505
+ try { signal.removeEventListener('abort', onParentAbort); } catch {}
506
+ }
507
+
508
+ // Decision matrix on the raced outcome.
509
+ if (winner === 'fallback' && settled.fallback && settled.fallback.status === 'ok') {
510
+ return { stdout: settled.fallback.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
511
+ }
512
+ if (winner === 'retry' && settled.retry && settled.retry.exitCode === 0) {
513
+ // CLI second attempt landed -- use it as if it were the original.
514
+ raw = settled.retry;
515
+ } else {
516
+ // Neither path produced a productive result. Prefer the most-informative
517
+ // failure status: an explicit fallback error beats a bare timeout.
518
+ if (settled.fallback && settled.fallback.status !== 'ok' && settled.fallback.error) {
519
+ return { stdout: '', stderr: 'timeout-and-fallback-failed', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
520
+ }
521
+ return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
522
+ }
523
+ } else if (canRetry) {
524
+ // Retry-only path (no api-fallback eligible): single sequential CLI retry.
525
+ raw = await spawnCli(pick, request, timeoutMs, signal, env);
526
+ if (raw && raw.aborted) {
527
+ return { stdout: '', stderr: 'aborted-after-retry', exitCode: null, status: 'aborted', source: 'none', elapsedMs: elapsed() };
528
+ }
529
+ if (raw && raw.timedOut) {
530
+ return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
531
+ }
532
+ } else if (canFallback) {
533
+ // Fallback-only path (no CLI retry eligible): API fallback with M7
534
+ // path-aware retry on transient API timeout.
535
+ const { mode, angle, target } = extractApiParams();
536
+ let apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
537
+ if (apiResult.status !== 'ok' && shouldRetryOnTimeout(pick, 'api') && !signal?.aborted) {
538
+ apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
539
+ }
270
540
  if (apiResult.status === 'ok') {
271
541
  return { stdout: apiResult.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
272
542
  }
543
+ return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
544
+ } else {
545
+ // Neither retry nor fallback eligible: nothing more we can do.
546
+ return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
273
547
  }
274
- return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
275
548
  }
276
549
 
277
550
  // CLI failed -- try API fallback
@@ -486,25 +759,31 @@ async function runPhaseEAuto({ projectDir, phase, target, env, quiet }) {
486
759
  const auditorResults = rawResults.map((raw, i) => {
487
760
  const pick = picks[i];
488
761
  if (raw === null) {
489
- return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
762
+ return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
490
763
  }
491
764
  const { stdout, exitCode, status: rawStatus } = raw;
492
- if (rawStatus === 'timeout') return { status: 'timeout', parsed: { items: [], prose: `[${pick.id}: timeout]` } };
493
- if (rawStatus === 'failed') return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: failed]` } };
494
- if (rawStatus === 'aborted') return { status: 'aborted', parsed: { items: [], prose: `[${pick.id}: aborted]` } };
765
+ // v1.5.0 S7: non-productive results MUST NOT count toward synthesis verdict.
766
+ // A hung CLI returning zero items would silently produce a PASS under the
767
+ // old logic (classifyVerdict([]) === 'PASS'). counted:false isolates them.
768
+ if (rawStatus === 'timeout') return { status: 'timeout', counted: false, parsed: { items: [], prose: `[${pick.id}: timeout]` } };
769
+ if (rawStatus === 'failed') return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: failed]` } };
770
+ if (rawStatus === 'aborted') return { status: 'aborted', counted: false, parsed: { items: [], prose: `[${pick.id}: aborted]` } };
495
771
  if (rawStatus === 'fallback-used') {
496
772
  const p = parseResponse('audit', stdout);
497
- return { status: 'fallback-used', parsed: p };
773
+ return { status: 'fallback-used', counted: true, parsed: p };
498
774
  }
499
- if (exitCode !== 0) return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
775
+ if (exitCode !== 0) return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
500
776
  const p = parseResponse('audit', stdout);
501
- return { status: 'ok', parsed: p };
777
+ return { status: 'ok', counted: true, parsed: p };
502
778
  });
503
779
 
504
- const parsed = auditorResults.map(r => r.parsed);
780
+ const productive = auditorResults.filter(r => r.counted);
781
+ const parsed = productive.map(r => r.parsed);
505
782
  const merged = mergeResponses('audit', parsed);
506
783
  const items = Array.isArray(merged) ? merged : [];
507
- const verdict = classifyVerdict(items);
784
+ // v1.5.0 S7: when zero auditors return productive output, the verdict is
785
+ // INCONCLUSIVE — refuses to grant PASS from a hung-CLI floor.
786
+ const verdict = productive.length === 0 ? 'INCONCLUSIVE' : classifyVerdict(items);
508
787
 
509
788
  // Write synthesis to .planning/<phase>/CROSS-AUDIT-r<N>.md
510
789
  const outputPath = resolveAuditOutputPath(projectDir, phase);
@@ -766,3 +1045,772 @@ export async function runCrossOp({
766
1045
  accept_degraded: !!accept_degraded,
767
1046
  };
768
1047
  }
1048
+
1049
+ // ---------------------------------------------------------------------------
1050
+ // v1.5.0 W12-C N01+N03 — runPhaseEConverge
1051
+ // ---------------------------------------------------------------------------
1052
+ //
1053
+ // Multi-lens consensus convergence (lock-in #47 — canonical Phase E).
1054
+ // Single-shot Phase E is the FALLBACK. Default behavior is a convergence
1055
+ // loop with divergence detection.
1056
+ //
1057
+ // Per-iteration flow:
1058
+ // 1. Dispatch all lenses in parallel.
1059
+ // 2. Collect verdicts (PASS / CONDITIONAL / FAIL) + finding lists.
1060
+ // 3. If all PASS → done.
1061
+ // 4. If verdicts diverge, build a CYCLE_SUMMARY (prior verdicts +
1062
+ // areas of disagreement) and re-dispatch with it injected.
1063
+ // 5. Cap at `maxIterations` (default 3). If still divergent at the cap,
1064
+ // emit `consensus_failed` and surface divergence.
1065
+ // 6. Stall breaker: if iter N produces byte-identical findings to
1066
+ // iter N-1, halt with `stalled: true` rather than burn tokens.
1067
+ //
1068
+ // Dispatcher contract (for test/DI):
1069
+ // dispatch({ lens, commitRange, iteration, cycleSummary }) →
1070
+ // { lens, verdict: 'PASS'|'CONDITIONAL'|'FAIL'|'UNREACHABLE',
1071
+ // findings: Array<{severity?, text?, ...}> }
1072
+ //
1073
+ // Tests inject a synthetic dispatcher; production passes a real one
1074
+ // that wraps `fireExternal` / `parseResponse` / `classifyVerdict` (or
1075
+ // reuses runCrossOp for each iteration).
1076
+ // ---------------------------------------------------------------------------
1077
+
1078
+ const VERDICT_PASS = 'PASS';
1079
+ const VERDICT_CONDITIONAL = 'CONDITIONAL';
1080
+ const VERDICT_FAIL = 'FAIL';
1081
+ const VERDICT_UNREACHABLE = 'UNREACHABLE';
1082
+ const VERDICT_CONSENSUS_FAIL = 'consensus_failed';
1083
+ const DEFAULT_LENSES = ['codex', 'gemini', 'claude'];
1084
+
1085
+ // v1.5.0 audit-H4.1 — hard upper bound on convergence iterations. A caller
1086
+ // asking for 100 rounds would burn 100 rounds of full Trident dispatch (~3
1087
+ // auditors × ~90s = ~4.5h per cycle on cold start). 10 is well above the
1088
+ // observed empirical ceiling — the convergence loop almost always settles in
1089
+ // 2-3 iters; >5 is a smell, >10 is a misuse. Anything above the cap is
1090
+ // silently clamped to MAX_CONVERGE_ITERATIONS + emits a single dedup'd warning.
1091
+ export const MAX_CONVERGE_ITERATIONS = 10;
1092
+
1093
+ // Module-level dedup set for max-iterations clamp warnings. One stderr line
1094
+ // per unique requested value per process lifetime (same pattern as
1095
+ // cross-project-search SKIP_LOG).
1096
+ const _MAX_ITER_WARN_LOG = new Set();
1097
+
1098
+ /** Reset the max-iter warn-log dedup set. Test-only. */
1099
+ export function _resetMaxIterWarnLog() {
1100
+ _MAX_ITER_WARN_LOG.clear();
1101
+ }
1102
+
1103
+ // Stable serialization for stall comparison.
1104
+ function stableFindingsKey(perLensFindings) {
1105
+ // perLensFindings: { lens → Array<finding> }
1106
+ // Use lens-sorted keys, and within each lens stringify findings (sorted by
1107
+ // a stable text representation) so semantically-identical iterations match.
1108
+ const lenses = Object.keys(perLensFindings).sort();
1109
+ const parts = [];
1110
+ for (const lens of lenses) {
1111
+ const findings = Array.isArray(perLensFindings[lens]) ? perLensFindings[lens] : [];
1112
+ const serialized = findings
1113
+ .map(f => JSON.stringify(f, Object.keys(f || {}).sort()))
1114
+ .sort();
1115
+ parts.push(`${lens}:[${serialized.join(',')}]`);
1116
+ }
1117
+ return parts.join('|');
1118
+ }
1119
+
1120
+ // Detect whether lens verdicts diverge.
1121
+ // All-PASS (with no UNREACHABLE) → false. Anything else with mixed verdicts → true.
1122
+ // All-FAIL across reachable lenses → still divergent only if findings differ;
1123
+ // pure consensus FAIL is not divergence (everyone agrees the change is bad).
1124
+ function detectDivergence(lensResults) {
1125
+ const reachable = lensResults.filter(r => r.verdict !== VERDICT_UNREACHABLE);
1126
+ if (reachable.length === 0) return { divergent: false, axes: [] };
1127
+ const verdicts = new Set(reachable.map(r => r.verdict));
1128
+ if (verdicts.size === 1) {
1129
+ // All reachable lenses agree on the verdict. Findings can still differ
1130
+ // (one lens may report extra MEDIUM findings), but verdict consensus is
1131
+ // sufficient — convergence is verdict-level, not finding-level.
1132
+ return { divergent: false, axes: [] };
1133
+ }
1134
+ // Verdicts diverge: compute axes (which lens differs from the majority).
1135
+ const counts = {};
1136
+ for (const r of reachable) counts[r.verdict] = (counts[r.verdict] || 0) + 1;
1137
+ let majority = reachable[0].verdict;
1138
+ let max = 0;
1139
+ for (const v of Object.keys(counts)) {
1140
+ if (counts[v] > max) { max = counts[v]; majority = v; }
1141
+ }
1142
+ const axes = reachable
1143
+ .filter(r => r.verdict !== majority)
1144
+ .map(r => ({ lens: r.lens, verdict: r.verdict, majority }));
1145
+ return { divergent: true, axes };
1146
+ }
1147
+
1148
+ // Build a CYCLE_SUMMARY string injected into the next iteration's lens brief.
1149
+ function buildCycleSummary(iteration, prior) {
1150
+ const lines = [
1151
+ `# CYCLE_SUMMARY (iteration ${iteration})`,
1152
+ '',
1153
+ 'Previous round verdicts:',
1154
+ ];
1155
+ for (const r of prior.lensResults) {
1156
+ lines.push(`- ${r.lens}: ${r.verdict} (${(r.findings || []).length} findings)`);
1157
+ }
1158
+ if (prior.divergence && prior.divergence.axes && prior.divergence.axes.length > 0) {
1159
+ lines.push('');
1160
+ lines.push('Areas of disagreement (lens vs majority):');
1161
+ for (const a of prior.divergence.axes) {
1162
+ lines.push(`- ${a.lens} said ${a.verdict}; majority said ${a.majority}`);
1163
+ }
1164
+ }
1165
+ lines.push('');
1166
+ lines.push('Re-evaluate. If your prior verdict was correct, restate it with explicit reasoning. If the other lenses changed your mind, update accordingly.');
1167
+ return lines.join('\n');
1168
+ }
1169
+
1170
+ // Public convergence entrypoint.
1171
+ // Inputs:
1172
+ // commitRange string (e.g. 'HEAD~1..HEAD')
1173
+ // lenses array of lens ids (default ['codex','gemini','claude'])
1174
+ // maxIterations number (default 3; lock-in #43/#44 style)
1175
+ // dispatch async function (DI hook for tests/production)
1176
+ // projectRoot string (passed through to dispatch)
1177
+ // totalTimeoutMs v1.5.0 audit-MED-trident-M6 — cumulative wall-clock cap.
1178
+ // When set, an AbortController fires at the deadline and
1179
+ // cancels remaining iterations. 3 iters × 3 lenses × 90s =
1180
+ // 270s worst case without a cap; this lets a caller say
1181
+ // "no more than 4 minutes for the whole convergence".
1182
+ // Defaults to env IJFW_AUDIT_CONVERGE_TOTAL_TIMEOUT_SEC.
1183
+ // perLensBudgetUsd v1.5.0 audit-MED-trident-M5 — per-lens USD cap across
1184
+ // this convergence cycle. Aborts a lens once its
1185
+ // cumulative cost in this run exceeds the cap. Defaults
1186
+ // to env IJFW_AUDIT_BUDGET_USD_PER_LENS.
1187
+ // autoFix v1.5.1 C2 (T27) — opt-in. When truthy, after a non-PASS
1188
+ // convergence the consensus code-fixer (recovery/code-fixer.js)
1189
+ // fires on HIGH findings that 2+ lenses agreed on. `true`
1190
+ // uses defaults; an object is forwarded to runConsensusFix
1191
+ // (minLenses, dryRun, verifyCmd, maxAutoFixFiles, ...).
1192
+ // Mutates the working tree + writes per-finding atomic
1193
+ // commits — off by default.
1194
+ // SAFETY BOUNDARY (R5-1.10): the fixer can only modify files
1195
+ // inside `projectRoot` (path containment — out-of-root
1196
+ // findings are refused) and `maxAutoFixFiles` (default 10,
1197
+ // ceiling 50) caps the distinct files one run may touch;
1198
+ // beyond the cap it stops + reports rather than mass-rewrite.
1199
+ // `dryRun: true` reports what it WOULD fix without writing.
1200
+ // Returns:
1201
+ // { verdict, iterations, findings, divergence?, stalled?, perIteration,
1202
+ // timedOutTotal?, lensesOverBudget?, lensCosts, autoFix? }
1203
+ export async function runPhaseEConverge({
1204
+ commitRange,
1205
+ lenses = DEFAULT_LENSES,
1206
+ maxIterations = 3,
1207
+ dispatch,
1208
+ projectRoot,
1209
+ projectDir, // v1.5.0 audit-H4.5 — receipts destination (defaults to projectRoot)
1210
+ runStamp, // v1.5.0 audit-H4.5 — caller-supplied stamp; auto if absent
1211
+ totalTimeoutMs, // v1.5.0 audit-MED-trident-M6 — cumulative timeout
1212
+ perLensBudgetUsd, // v1.5.0 audit-MED-trident-M5 — per-lens USD cap
1213
+ keepaliveOnTick, // v1.5.0 wire-W1.B — caller-supplied keepalive heartbeat
1214
+ autoFix = false, // v1.5.1 C2 (T27) — when truthy, fire the consensus
1215
+ // code-fixer on 2+-lens-agreed HIGH findings after a
1216
+ // non-PASS convergence. Accepts `true` (defaults) or an
1217
+ // options object { minLenses, dryRun, verifyCmd, ... }
1218
+ // forwarded to recovery/code-fixer.js#runConsensusFix.
1219
+ env = process.env,
1220
+ } = {}) {
1221
+ if (typeof dispatch !== 'function') {
1222
+ throw new Error('runPhaseEConverge: dispatch function is required');
1223
+ }
1224
+ if (!Array.isArray(lenses) || lenses.length === 0) {
1225
+ throw new Error('runPhaseEConverge: lenses must be a non-empty array');
1226
+ }
1227
+ // v1.5.0 audit-H4.1 — clamp to [1, MAX_CONVERGE_ITERATIONS]. Anything above
1228
+ // the cap is silently coerced; one-line stderr warning per unique requested
1229
+ // value (Set dedup) so a caller running the same misconfigured tool 100
1230
+ // times doesn't spam the log.
1231
+ const requested = Math.max(1, Math.floor(maxIterations));
1232
+ const cap = Math.min(requested, MAX_CONVERGE_ITERATIONS);
1233
+ if (requested > MAX_CONVERGE_ITERATIONS) {
1234
+ const key = String(requested);
1235
+ if (!_MAX_ITER_WARN_LOG.has(key)) {
1236
+ _MAX_ITER_WARN_LOG.add(key);
1237
+ process.stderr.write(
1238
+ `runPhaseEConverge: maxIterations=${requested} clamped to MAX_CONVERGE_ITERATIONS=${MAX_CONVERGE_ITERATIONS}.\n`
1239
+ );
1240
+ }
1241
+ }
1242
+
1243
+ // v1.5.0 audit-H4.5 — receipts wiring. The flagship converge tool was
1244
+ // previously invisible to `ijfw status` and the dashboard because no receipt
1245
+ // was written. Capture start time + per-cycle finding counts here, write
1246
+ // ONE summary receipt before each return path (writeReceiptIfPossible).
1247
+ const _startMs = Date.now();
1248
+ const _receiptCycles = []; // [{ iteration, findingCount, lensVerdicts: {lens:verdict} }]
1249
+ let _totalInvocations = 0; // dispatch calls across all iterations
1250
+
1251
+ const _resolvedProjectDir = projectDir ?? projectRoot ?? process.cwd();
1252
+ const _resolvedRunStamp = runStamp ?? new Date().toISOString();
1253
+
1254
+ // v1.5.0 T21 — telemetry accumulators for the three metrics published via
1255
+ // the state-SDK `telemetry.record` verb at finalize time.
1256
+ // * _telemetryAlarms — count of (lens, cycle) observations whose verdict
1257
+ // was FAIL or CONDITIONAL (i.e. "raised an alarm"). The false-positive
1258
+ // rate compares this against the final consensus verdict.
1259
+ // * _telemetryReachableObs — count of reachable (lens, cycle) observations
1260
+ // (excludes UNREACHABLE/budget-capped) — the denominator for the rate.
1261
+ // Cost is read off `_lensCosts` (already accumulated) at finalize.
1262
+ let _telemetryAlarms = 0;
1263
+ let _telemetryReachableObs = 0;
1264
+
1265
+ // v1.5.0 audit-MED-trident-M6 — cumulative-timeout AbortController.
1266
+ // Either an arg-supplied totalTimeoutMs or env var; >0 enables. The signal
1267
+ // is checked between iterations + passed to dispatch (dispatchers that
1268
+ // honor `signal` will tear down in-flight lens calls).
1269
+ const _envTotalSec = env && env.IJFW_AUDIT_CONVERGE_TOTAL_TIMEOUT_SEC;
1270
+ const _resolvedTotalMs = (typeof totalTimeoutMs === 'number' && totalTimeoutMs > 0)
1271
+ ? totalTimeoutMs
1272
+ : (Number.isFinite(Number(_envTotalSec)) && Number(_envTotalSec) > 0
1273
+ ? Math.floor(Number(_envTotalSec) * 1000)
1274
+ : null);
1275
+ const _cycleAc = new AbortController();
1276
+ let _totalTimedOut = false;
1277
+ let _totalTimer = null;
1278
+ if (_resolvedTotalMs) {
1279
+ _totalTimer = setTimeout(() => {
1280
+ _totalTimedOut = true;
1281
+ try { _cycleAc.abort(); } catch { /* ignore */ }
1282
+ }, _resolvedTotalMs);
1283
+ // Don't keep the event loop alive purely for this timer.
1284
+ if (typeof _totalTimer.unref === 'function') _totalTimer.unref();
1285
+ }
1286
+
1287
+ // v1.5.0 wire-W1.B — Anthropic ephemeral-cache keepalive heartbeat.
1288
+ // Production wiring for `mcp-server/src/lib/cache-keepalive.js`. A long
1289
+ // Trident convergence (5+ minutes of sequential lens calls separated by
1290
+ // CLI / API latency) can lose cache hits mid-wave because the 5-min TTL
1291
+ // expires between calls even though every call re-sends the same cache-
1292
+ // eligible prefix. The lib provides an opt-in heartbeat that fires a
1293
+ // no-op on a configurable interval to keep the cache warm.
1294
+ //
1295
+ // Default OFF (intervalMs=0 when IJFW_CACHE_KEEPALIVE_MS env is unset).
1296
+ // When the env var is set in the [1000, 300000] range, the heartbeat
1297
+ // fires every N ms until convergence completes or the cumulative-timeout
1298
+ // signal aborts (signal is passed through so the cap cascades into
1299
+ // keepalive too).
1300
+ //
1301
+ // Every tick increments a counter (surfaced on the return value + receipt
1302
+ // for observability). Production callers can additionally supply their own
1303
+ // onTick via `keepaliveOnTick` arg (e.g. to ping an API endpoint that
1304
+ // re-warms the cache prefix); it runs alongside the counter, not instead.
1305
+ let _keepaliveTicks = 0;
1306
+ const _keepalive = startKeepaliveFromEnv({
1307
+ // r21-LOW: count every tick regardless of whether a custom onTick is
1308
+ // supplied. Previously the counter only ran on the default path, so
1309
+ // runs that passed keepaliveOnTick under-reported as zero ticks.
1310
+ onTick: () => {
1311
+ _keepaliveTicks += 1;
1312
+ if (typeof keepaliveOnTick === 'function') {
1313
+ try { keepaliveOnTick(); } catch { /* keepalive errors must never crash the wave */ }
1314
+ }
1315
+ },
1316
+ onError: () => { /* keepalive errors must never crash the wave */ },
1317
+ signal: _cycleAc.signal,
1318
+ env,
1319
+ });
1320
+
1321
+ // v1.5.0 audit-MED-trident-M5 — per-lens budget. Tracks cumulative cost
1322
+ // attributed to each lens across this converge cycle; abort that lens once
1323
+ // its accumulated cost > cap. Cost source priority on dispatch return:
1324
+ // 1. result.cost_usd
1325
+ // 2. result.usage?.cost_usd
1326
+ // 3. 0 (unknown — counted as 0, can still hit cap via N iterations)
1327
+ const _envPerLensCap = env && env.IJFW_AUDIT_BUDGET_USD_PER_LENS;
1328
+ const _resolvedPerLensCap = (typeof perLensBudgetUsd === 'number' && perLensBudgetUsd > 0)
1329
+ ? perLensBudgetUsd
1330
+ : (Number.isFinite(Number(_envPerLensCap)) && Number(_envPerLensCap) > 0
1331
+ ? Number(_envPerLensCap)
1332
+ : null);
1333
+ const _lensCosts = Object.create(null);
1334
+ const _lensOverBudget = new Set();
1335
+ for (const lens of lenses) _lensCosts[lens] = 0;
1336
+
1337
+ async function _finalize(returnVal) {
1338
+ // Clear cumulative-timeout timer so the process can exit cleanly.
1339
+ if (_totalTimer) {
1340
+ clearTimeout(_totalTimer);
1341
+ _totalTimer = null;
1342
+ }
1343
+ // v1.5.0 wire-W1.B — sample the active flag BEFORE cancel. isActive()
1344
+ // returns false once cancelled, so reading it after teardown (r21-MED)
1345
+ // would under-report a heartbeat that was wired and running this wave.
1346
+ const _keepaliveActive = _keepalive.isActive();
1347
+ // Cancel keepalive (idempotent; no-op when never started).
1348
+ try { _keepalive.cancel(); } catch { /* never throws */ }
1349
+ // M5/M6: surface lens-budget + total-timeout posture on the return value
1350
+ // so callers can branch on partial-completion. lensCosts is always
1351
+ // present (even when no cap was set) so observability is consistent.
1352
+ const enriched = {
1353
+ ...returnVal,
1354
+ lensCosts: { ..._lensCosts },
1355
+ ...(_lensOverBudget.size > 0 ? { lensesOverBudget: [..._lensOverBudget] } : {}),
1356
+ ...(_totalTimedOut ? { timedOutTotal: true } : {}),
1357
+ // wire-W1.B observability: tick count + whether the heartbeat was wired
1358
+ // at all for this run (proves the lib is live, not just imported).
1359
+ keepaliveTicks: _keepaliveTicks,
1360
+ keepaliveWired: _keepaliveActive || _keepaliveTicks > 0,
1361
+ };
1362
+ // Build + write the converge receipt. Failure to write must NOT clobber
1363
+ // the orchestrator return value (defensive — receipts are observability,
1364
+ // not correctness).
1365
+ try {
1366
+ const record = {
1367
+ v: 1,
1368
+ timestamp: new Date().toISOString(),
1369
+ run_stamp: _resolvedRunStamp,
1370
+ mode: 'converge',
1371
+ target: commitRange,
1372
+ // Receipt-compatible auditor list (renderReceipt reads .id from each).
1373
+ auditors: lenses.map(id => ({ id })),
1374
+ // findings shape matches renderReceipt's array branch.
1375
+ findings: { items: Array.isArray(enriched.findings) ? enriched.findings : [] },
1376
+ duration_ms: Date.now() - _startMs,
1377
+ // Converge-specific fields (new — minimal addition; renderReceipt
1378
+ // ignores unknown keys, so existing receipt renderers keep working).
1379
+ converge: {
1380
+ iterations: enriched.iterations,
1381
+ verdict: enriched.verdict,
1382
+ stalled: !!enriched.stalled,
1383
+ divergent: Array.isArray(enriched.divergence) && enriched.divergence.length > 0,
1384
+ total_invocations: _totalInvocations,
1385
+ cycles: _receiptCycles,
1386
+ requested_max_iterations: requested,
1387
+ effective_max_iterations: cap,
1388
+ // M5/M6 observability.
1389
+ total_timeout_ms: _resolvedTotalMs,
1390
+ timed_out_total: _totalTimedOut,
1391
+ per_lens_budget_usd: _resolvedPerLensCap,
1392
+ lens_costs: _lensCosts,
1393
+ lenses_over_budget: [..._lensOverBudget],
1394
+ // wire-W1.B observability: how many keepalive heartbeats fired
1395
+ // during this convergence; 0 means the lib was wired but the env
1396
+ // opt-in was off, >0 proves the heartbeat ran in production.
1397
+ keepalive_ticks: _keepaliveTicks,
1398
+ },
1399
+ };
1400
+ writeReceipt(_resolvedProjectDir, record);
1401
+ } catch {
1402
+ // Receipts are observability; never fail the converge run on a write error.
1403
+ }
1404
+
1405
+ // v1.5.0 T21 (W4) — convergence telemetry via state-SDK `telemetry.record`.
1406
+ //
1407
+ // NOTE — this `_finalize` IIFE inside an async function chain: the
1408
+ // telemetry recorder needs to publish BEFORE the function returns so
1409
+ // callers (and tests) can read `.ijfw/telemetry/convergence.json`
1410
+ // synchronously after `await runPhaseEConverge(...)`. We therefore
1411
+ // shape `_finalize` to be async and await the telemetry write here.
1412
+ // The await is inside a try/catch that swallows everything — failure
1413
+ // to write telemetry never changes the convergence verdict.
1414
+ //
1415
+ // The three metrics are computed here AFTER the final verdict is known so
1416
+ // the false-positive rate has a stable consensus reference point. Failure
1417
+ // to record telemetry MUST NOT corrupt or override the convergence verdict:
1418
+ // wrapped in try/catch with full swallow per the off-the-critical-path
1419
+ // discipline. The convergence return value (`enriched`) is unchanged.
1420
+ //
1421
+ // Metric definitions (locked here so downstream dashboards and the
1422
+ // STATE-SDK contract §7 telemetry.record consumers can rely on them):
1423
+ //
1424
+ // 1. cyclesToConverge (integer ≥ 1, ≤ MAX_CONVERGE_ITERATIONS):
1425
+ // The iteration count at which the loop terminated. Equals
1426
+ // `enriched.iterations` — i.e. the number of dispatch rounds
1427
+ // actually executed before a stop condition (PASS consensus,
1428
+ // non-PASS consensus short-circuit, byte-identical stall,
1429
+ // cap reached, total-timeout, or all-budget-capped) was met.
1430
+ // A single-cycle PASS reports 1; a 3-iter stalemate that ends
1431
+ // in `consensus_failed` reports 3.
1432
+ //
1433
+ // 2. falsePositiveRate (decimal in [0, 1]):
1434
+ // Numerator: (lens, cycle) observations where a lens raised an
1435
+ // alarm (verdict FAIL or CONDITIONAL) but the FINAL consensus
1436
+ // verdict was PASS. UNREACHABLE observations are excluded.
1437
+ // Denominator: total reachable (lens, cycle) observations across
1438
+ // all cycles in this run.
1439
+ // Rationale: a "false positive" is a lens that cried wolf — it
1440
+ // said "this change is broken" against a run the swarm
1441
+ // ultimately blessed. When the final verdict is NOT PASS (FAIL,
1442
+ // CONDITIONAL, consensus_failed, UNREACHABLE), no alarm can be
1443
+ // retro-classified as false — the numerator is 0 and the rate
1444
+ // collapses to 0. When the denominator is 0 (no reachable
1445
+ // observations ever fired — all lenses budget-capped or all
1446
+ // dispatch threw), the rate is reported as 0 (no signal to
1447
+ // divide by). 0 ≤ rate ≤ 1 by construction.
1448
+ //
1449
+ // 3. costUsd (decimal ≥ 0):
1450
+ // Sum across all lenses of cumulative cost attributed to this
1451
+ // convergence run. Source: `_lensCosts[lens]`, which is fed from
1452
+ // `dispatch().cost_usd` (or `dispatch().usage.cost_usd`) on every
1453
+ // per-cycle settlement. Lenses that never returned a cost field
1454
+ // contribute 0 (their wall-clock time isn't a "USD" cost — that's
1455
+ // already captured in the receipt's `duration_ms` field). When
1456
+ // no lens reports cost, costUsd is 0.0; this is the truthful
1457
+ // signal that the swarm ran on CLI credentials with no per-call
1458
+ // billing surface, not a missing-data sentinel.
1459
+ try {
1460
+ const finalVerdict = enriched.verdict;
1461
+ const finalIsPass = finalVerdict === VERDICT_PASS;
1462
+ // Numerator: alarms only count as FALSE positives when consensus PASS'd.
1463
+ const _falsePositives = finalIsPass ? _telemetryAlarms : 0;
1464
+ const falsePositiveRate = _telemetryReachableObs > 0
1465
+ ? _falsePositives / _telemetryReachableObs
1466
+ : 0;
1467
+ let _summedCost = 0;
1468
+ for (const v of Object.values(_lensCosts)) {
1469
+ if (typeof v === 'number' && Number.isFinite(v)) _summedCost += v;
1470
+ }
1471
+ const metrics = {
1472
+ cyclesToConverge: enriched.iterations,
1473
+ falsePositiveRate,
1474
+ costUsd: _summedCost,
1475
+ // Diagnostics — not part of the locked three but useful to readers
1476
+ // of `.ijfw/telemetry/convergence.json`. The SDK verb stores the
1477
+ // metrics object opaquely so extra keys are non-breaking.
1478
+ verdict: finalVerdict,
1479
+ commitRange: typeof commitRange === 'string' ? commitRange : null,
1480
+ lensCount: lenses.length,
1481
+ reachableObservations: _telemetryReachableObs,
1482
+ alarmObservations: _telemetryAlarms,
1483
+ durationMs: Date.now() - _startMs,
1484
+ };
1485
+ // Stable, run-scoped dedup key. The verb's append-idempotency (§4)
1486
+ // requires a string; the runStamp is the canonical per-run identity
1487
+ // and the commitRange disambiguates concurrent runs against different
1488
+ // targets. Same (stamp, range) → same key → second record is dedup'd
1489
+ // by the verb (deterministic for tests + safe under double-fire).
1490
+ const dedupKey = `convergence:${_resolvedRunStamp}:${typeof commitRange === 'string' ? commitRange : 'nocommitrange'}`;
1491
+ // Off the critical path: the await is wrapped + swallowed. The verdict
1492
+ // has already been computed; this is observability only.
1493
+ await _stateQuery('telemetry.record', {
1494
+ kind: 'convergence',
1495
+ metrics,
1496
+ dedupKey,
1497
+ }, {
1498
+ projectRoot: _resolvedProjectDir,
1499
+ }).catch(() => {
1500
+ // Telemetry failures must NEVER affect the convergence verdict.
1501
+ // (e.g. read-only FS, missing .ijfw/, lock contention) — swallow.
1502
+ });
1503
+ } catch {
1504
+ // Defensive — should never throw before the `.catch` chain, but the
1505
+ // metric computation itself (e.g. exotic NaN in _lensCosts) must not
1506
+ // break the orchestrator return value.
1507
+ }
1508
+
1509
+ // v1.5.1 C2 (T27) — consensus code-fixer wire-up. This is the call site
1510
+ // T27 was designed for: "when 2+ lenses agree on the same HIGH, the fixer
1511
+ // fires automatically." The convergence loop is the canonical Trident
1512
+ // path; once it settles on a non-PASS verdict, extract the consensus HIGH
1513
+ // findings from `perIteration` and run recovery/code-fixer.js's atomic
1514
+ // per-finding fix loop over them. Opt-in (`autoFix`) because it mutates
1515
+ // the working tree + writes commits — never the default for a read-only
1516
+ // audit. PASS verdicts are skipped (nothing to fix). Failure here is
1517
+ // surfaced on `enriched.autoFix` but NEVER changes the convergence
1518
+ // verdict — the fixer is a downstream remediation, not a gate.
1519
+ if (autoFix && enriched.verdict !== VERDICT_PASS) {
1520
+ try {
1521
+ const { runConsensusFix } = await import('./recovery/code-fixer.js');
1522
+ const fixOpts = (autoFix && typeof autoFix === 'object') ? autoFix : {};
1523
+ const fixResult = await runConsensusFix({
1524
+ perIteration,
1525
+ projectRoot: _resolvedProjectDir,
1526
+ dispatch,
1527
+ commitRange,
1528
+ lenses,
1529
+ ...fixOpts,
1530
+ });
1531
+ enriched.autoFix = fixResult;
1532
+ } catch (err) {
1533
+ enriched.autoFix = {
1534
+ triggered: false,
1535
+ reason: `code-fixer error: ${err && err.message ? err.message : String(err)}`,
1536
+ };
1537
+ }
1538
+ }
1539
+
1540
+ return enriched;
1541
+ }
1542
+
1543
+ let cycleSummary = null;
1544
+ let prior = null;
1545
+ let priorFindingsKey = null;
1546
+ const perIteration = [];
1547
+
1548
+ for (let iter = 1; iter <= cap; iter++) {
1549
+ // M6: stop the loop if the cumulative wall-clock budget has elapsed.
1550
+ if (_totalTimedOut || _cycleAc.signal.aborted) {
1551
+ return _finalize({
1552
+ verdict: VERDICT_CONSENSUS_FAIL,
1553
+ iterations: Math.max(1, iter - 1),
1554
+ findings: [],
1555
+ perIteration,
1556
+ });
1557
+ }
1558
+
1559
+ // M5: filter out lenses that have exceeded the per-lens USD cap.
1560
+ const activeLenses = lenses.filter(l => !_lensOverBudget.has(l));
1561
+ if (activeLenses.length === 0) {
1562
+ // All lenses budget-capped — return what we have with consensus_failed.
1563
+ return _finalize({
1564
+ verdict: VERDICT_CONSENSUS_FAIL,
1565
+ iterations: Math.max(1, iter - 1),
1566
+ findings: [],
1567
+ perIteration,
1568
+ });
1569
+ }
1570
+
1571
+ // Fire all (still-active) lenses in parallel.
1572
+ _totalInvocations += activeLenses.length;
1573
+ const settlements = await Promise.all(activeLenses.map(lens =>
1574
+ Promise.resolve()
1575
+ .then(() => dispatch({
1576
+ lens,
1577
+ commitRange,
1578
+ iteration: iter,
1579
+ cycleSummary,
1580
+ projectRoot,
1581
+ // M6: pass the cumulative AbortController signal to dispatchers
1582
+ // that honor it. Existing tests inject a dispatcher that ignores
1583
+ // `signal`; production dispatchers should pass it through to
1584
+ // fireExternal so in-flight CLI/API calls tear down on deadline.
1585
+ signal: _cycleAc.signal,
1586
+ }))
1587
+ .catch(err => ({
1588
+ lens,
1589
+ verdict: VERDICT_UNREACHABLE,
1590
+ findings: [],
1591
+ error: err && err.message ? err.message : String(err),
1592
+ }))
1593
+ ));
1594
+
1595
+ // M5: accrue per-lens cost + flag over-budget lenses for the next iter.
1596
+ if (_resolvedPerLensCap) {
1597
+ for (const s of settlements) {
1598
+ if (!s || !s.lens) continue;
1599
+ const cost = (typeof s.cost_usd === 'number' && Number.isFinite(s.cost_usd))
1600
+ ? s.cost_usd
1601
+ : (s.usage && typeof s.usage.cost_usd === 'number' ? s.usage.cost_usd : 0);
1602
+ _lensCosts[s.lens] = (_lensCosts[s.lens] || 0) + cost;
1603
+ if (_lensCosts[s.lens] > _resolvedPerLensCap) {
1604
+ _lensOverBudget.add(s.lens);
1605
+ }
1606
+ }
1607
+ } else {
1608
+ // Even without a cap, still track cumulative cost for observability.
1609
+ for (const s of settlements) {
1610
+ if (!s || !s.lens) continue;
1611
+ const cost = (typeof s.cost_usd === 'number' && Number.isFinite(s.cost_usd))
1612
+ ? s.cost_usd
1613
+ : (s.usage && typeof s.usage.cost_usd === 'number' ? s.usage.cost_usd : 0);
1614
+ _lensCosts[s.lens] = (_lensCosts[s.lens] || 0) + cost;
1615
+ }
1616
+ }
1617
+
1618
+ // Map back to the full lens list: lenses we filtered out (budget-capped)
1619
+ // synthesize a stub UNREACHABLE result so downstream divergence/stall
1620
+ // detection sees the same lens set every iteration.
1621
+ const settlementByLens = new Map();
1622
+ for (let i = 0; i < settlements.length; i++) {
1623
+ const r = settlements[i];
1624
+ const lensId = r && r.lens ? r.lens : activeLenses[i];
1625
+ settlementByLens.set(lensId, r);
1626
+ }
1627
+ const lensResults = lenses.map(lensId => {
1628
+ const r = settlementByLens.get(lensId);
1629
+ if (!r) {
1630
+ // M5: budget-capped lens — present as UNREACHABLE with explanatory error.
1631
+ return {
1632
+ lens: lensId,
1633
+ verdict: VERDICT_UNREACHABLE,
1634
+ findings: [],
1635
+ error: 'over per-lens budget',
1636
+ };
1637
+ }
1638
+ return {
1639
+ lens: r.lens || lensId,
1640
+ verdict: r.verdict ? r.verdict : VERDICT_UNREACHABLE,
1641
+ findings: Array.isArray(r.findings) ? r.findings : [],
1642
+ ...(r.error ? { error: r.error } : {}),
1643
+ };
1644
+ });
1645
+
1646
+ // Build per-lens findings map for stall detection.
1647
+ const perLensFindings = {};
1648
+ for (const r of lensResults) perLensFindings[r.lens] = r.findings;
1649
+ const findingsKey = stableFindingsKey(perLensFindings);
1650
+
1651
+ const reachable = lensResults.filter(r => r.verdict !== VERDICT_UNREACHABLE);
1652
+ const divergence = detectDivergence(lensResults);
1653
+
1654
+ perIteration.push({
1655
+ iteration: iter,
1656
+ lensResults,
1657
+ divergent: divergence.divergent,
1658
+ });
1659
+
1660
+ const mergedFindings = lensResults.flatMap(r => r.findings.map(f => ({ ...f, _lens: r.lens })));
1661
+
1662
+ // Receipt cycle metadata — per-cycle finding count + verdict map.
1663
+ const lensVerdicts = {};
1664
+ for (const r of lensResults) lensVerdicts[r.lens] = r.verdict;
1665
+ _receiptCycles.push({
1666
+ iteration: iter,
1667
+ findingCount: mergedFindings.length,
1668
+ lensVerdicts,
1669
+ });
1670
+
1671
+ // T21 — false-positive accounting. Every reachable lens observation is
1672
+ // a denominator tick; every FAIL/CONDITIONAL vote is a numerator tick
1673
+ // (it "raised an alarm"). Whether each alarm was a true or false positive
1674
+ // is decided at finalize against the final consensus verdict (see
1675
+ // `_finalize`). UNREACHABLE lenses are excluded from both numerator and
1676
+ // denominator — a CLI that never replied is neither a true nor false
1677
+ // positive, it's a no-data point.
1678
+ for (const r of lensResults) {
1679
+ if (r.verdict === VERDICT_UNREACHABLE) continue;
1680
+ _telemetryReachableObs += 1;
1681
+ if (r.verdict === VERDICT_FAIL || r.verdict === VERDICT_CONDITIONAL) {
1682
+ _telemetryAlarms += 1;
1683
+ }
1684
+ }
1685
+
1686
+ // Stop conditions, in priority order.
1687
+
1688
+ // 1. maxIterations === 1 — cap the loop after first iter no matter what.
1689
+ if (cap === 1) {
1690
+ const verdict = reachable.length === 0
1691
+ ? VERDICT_UNREACHABLE
1692
+ : (divergence.divergent ? VERDICT_CONSENSUS_FAIL : reachable[0].verdict);
1693
+ return _finalize({
1694
+ verdict,
1695
+ iterations: 1,
1696
+ findings: mergedFindings,
1697
+ ...(divergence.divergent ? { divergence: divergence.axes } : {}),
1698
+ perIteration,
1699
+ });
1700
+ }
1701
+
1702
+ // 2. All reachable lenses PASS → done.
1703
+ if (reachable.length > 0 && !divergence.divergent && reachable[0].verdict === VERDICT_PASS) {
1704
+ return _finalize({
1705
+ verdict: VERDICT_PASS,
1706
+ iterations: iter,
1707
+ findings: mergedFindings,
1708
+ perIteration,
1709
+ });
1710
+ }
1711
+
1712
+ // 3. Consensus on non-PASS (e.g. all FAIL) — short-circuit, no point looping.
1713
+ if (reachable.length > 0 && !divergence.divergent) {
1714
+ return _finalize({
1715
+ verdict: reachable[0].verdict,
1716
+ iterations: iter,
1717
+ findings: mergedFindings,
1718
+ perIteration,
1719
+ });
1720
+ }
1721
+
1722
+ // 4. Stall breaker: byte-identical findings to prior iter → halt.
1723
+ if (priorFindingsKey !== null && findingsKey === priorFindingsKey) {
1724
+ return _finalize({
1725
+ verdict: VERDICT_CONSENSUS_FAIL,
1726
+ iterations: iter,
1727
+ findings: mergedFindings,
1728
+ divergence: divergence.axes,
1729
+ stalled: true,
1730
+ perIteration,
1731
+ });
1732
+ }
1733
+
1734
+ // 5. Cap hit while still divergent → consensus_failed.
1735
+ if (iter === cap) {
1736
+ return _finalize({
1737
+ verdict: VERDICT_CONSENSUS_FAIL,
1738
+ iterations: iter,
1739
+ findings: mergedFindings,
1740
+ divergence: divergence.axes,
1741
+ perIteration,
1742
+ });
1743
+ }
1744
+
1745
+ // Otherwise: stage next iteration with cycle summary.
1746
+ prior = { lensResults, divergence };
1747
+ cycleSummary = buildCycleSummary(iter + 1, prior);
1748
+ priorFindingsKey = findingsKey;
1749
+ }
1750
+
1751
+ // Unreachable; loop must exit via one of the return paths above.
1752
+ /* c8 ignore next */
1753
+ return _finalize({ verdict: VERDICT_CONSENSUS_FAIL, iterations: cap, findings: [], perIteration });
1754
+ }
1755
+
1756
+ // Default production dispatcher. Wraps the existing single-lens spawn path
1757
+ // (audit-roster pick → buildRequest → fireExternal → parseResponse →
1758
+ // classifyVerdict). Tests pass their own dispatcher; production callers can
1759
+ // either pass this one or supply a customized wrapper.
1760
+ //
1761
+ // One quirk: this dispatcher embeds the cycleSummary into the audit target
1762
+ // string (prefixed) so the lens sees prior-round context in its prompt.
1763
+ // Lens stdout shape: same as parseResponse('audit', stdout).
1764
+ export async function defaultConvergeDispatch({ lens, commitRange, iteration, cycleSummary, projectRoot, signal } = {}) {
1765
+ const env = process.env;
1766
+ const entry = ROSTER.find(e => e.id === lens);
1767
+ if (!entry) {
1768
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `lens "${lens}" not in roster` };
1769
+ }
1770
+ // v1.5.0 wire-W1.F — honor a pre-aborted cumulative-timeout signal before
1771
+ // any CLI/API spawn so runPhaseEConverge's totalTimeoutMs cap actually
1772
+ // bounds wall-clock time when the deadline expired between iterations.
1773
+ if (signal && signal.aborted) {
1774
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: 'aborted' };
1775
+ }
1776
+ const reach = isReachable(lens, env);
1777
+ if (!reach.any) {
1778
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `lens "${lens}" CLI missing and no apiFallback` };
1779
+ }
1780
+ const pick = (!reach.cli && reach.api) ? { ...entry, preferredSource: 'api' } : { ...entry };
1781
+
1782
+ // v1.5.0 audit-H4.2 — cycleSummary MUST come AFTER any cache_control-eligible
1783
+ // block. Cached content (the stable commitRange/target) goes FIRST so user-
1784
+ // message cache_control (planned for v1.5.0 ADJUDICATION-1 cap-layer work)
1785
+ // hits the same prefix on every iteration; the iteration-varying summary
1786
+ // goes LAST so it never busts the cache. The previous prefix layout would
1787
+ // invalidate the cache on every iteration ≥ 2. See ADJUDICATIONS.md
1788
+ // DISPUTED-1 (cache_control ordering invariant).
1789
+ const target = (iteration > 1 && cycleSummary)
1790
+ ? `${commitRange}\n\n---\n\n${cycleSummary}`
1791
+ : commitRange;
1792
+
1793
+ const request = buildRequest('audit', target, pick.id, 'general', null);
1794
+ const timeoutMs = timeoutForPick(pick, null);
1795
+ try {
1796
+ // v1.5.0 wire-W1.F — forward cumulative-timeout signal so in-flight CLI
1797
+ // spawn + API fallback both cascade their AbortControllers on deadline.
1798
+ const raw = await fireExternal(pick, request, timeoutMs, env, signal || null);
1799
+ if (!raw || raw.status === 'timeout' || raw.status === 'failed' || raw.status === 'aborted') {
1800
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: (raw && raw.stderr) || 'no output' };
1801
+ }
1802
+ if (raw.exitCode !== 0 && raw.status !== 'fallback-used') {
1803
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `exit ${raw.exitCode}` };
1804
+ }
1805
+ const parsed = parseResponse('audit', raw.stdout);
1806
+ const items = Array.isArray(parsed.items) ? parsed.items : [];
1807
+ const verdict = classifyVerdict(items);
1808
+ return { lens, verdict, findings: items };
1809
+ } catch (err) {
1810
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: err && err.message ? err.message : String(err) };
1811
+ }
1812
+ /* projectRoot reserved for future per-project dispatcher overrides */
1813
+ // eslint-disable-next-line no-unreachable
1814
+ void projectRoot;
1815
+ }
1816
+