@ijfw/memory-server 1.4.3 → 1.5.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 (233) hide show
  1. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  2. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  3. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  4. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  5. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  6. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  7. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  8. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  9. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  23. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  24. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  26. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  27. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  28. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  29. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  47. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  48. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  49. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  50. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  52. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  53. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  54. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  68. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  69. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  71. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  72. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  73. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  74. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  92. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  93. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  94. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  95. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  96. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  97. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  98. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  99. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  115. package/package.json +1 -1
  116. package/src/active-extension-writer.js +144 -64
  117. package/src/api-client.js +43 -5
  118. package/src/audit-roster.js +80 -5
  119. package/src/blackboard.js +298 -6
  120. package/src/cli-run.js +33 -5
  121. package/src/codex-agents.js +96 -5
  122. package/src/cost/aggregator.js +39 -9
  123. package/src/cost/pricing.js +57 -0
  124. package/src/cost/readers/gemini.js +1 -1
  125. package/src/cross-audit-chunker.js +189 -0
  126. package/src/cross-dispatcher.js +124 -21
  127. package/src/cross-orchestrator-cli.js +550 -14
  128. package/src/cross-orchestrator.js +1171 -10
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-planning.html +273 -0
  131. package/src/dashboard-client-waves.html +304 -0
  132. package/src/dashboard-client.html +17 -2
  133. package/src/dashboard-server.js +152 -0
  134. package/src/deploy-alerts.js +150 -0
  135. package/src/design/iframe-bridge.js +242 -0
  136. package/src/design-companion.js +144 -0
  137. package/src/dispatch/checkpoint-cli.js +97 -0
  138. package/src/dispatch/colon-syntax.js +81 -1
  139. package/src/dispatch/extension.js +27 -1
  140. package/src/dispatch/registry-cli.js +4 -1
  141. package/src/dispatch/wave-cli.js +323 -0
  142. package/src/dispatch/worktree-cli.js +40 -0
  143. package/src/dispatch-planner.js +97 -2
  144. package/src/dream/runner.mjs +47 -11
  145. package/src/dream/stage-runner.js +40 -0
  146. package/src/dream/state-file.js +102 -0
  147. package/src/extension-installer.js +70 -24
  148. package/src/extension-quota-tracker.js +4 -2
  149. package/src/extension-registry.js +289 -35
  150. package/src/feedback-detector.js +26 -0
  151. package/src/fs-lock.js +259 -7
  152. package/src/gate-result.js +95 -1
  153. package/src/hero-line.js +86 -5
  154. package/src/intent-router.js +35 -0
  155. package/src/lib/a11y-contract.js +117 -0
  156. package/src/lib/atomic-io.js +29 -8
  157. package/src/lib/cache-keepalive.js +150 -0
  158. package/src/lib/jsonl-rotation.js +104 -0
  159. package/src/lib/lighthouse-pillar.js +121 -0
  160. package/src/lib/llm-call.js +121 -0
  161. package/src/lib/playwright-baseline.js +205 -0
  162. package/src/lib/rekor-bridge.js +221 -0
  163. package/src/lib/repo-map.js +392 -0
  164. package/src/lib/shasum-verify.js +164 -0
  165. package/src/lib/sketches-gc.js +132 -0
  166. package/src/lib/tmp-suffix.js +62 -0
  167. package/src/lib/ui-review-runner.js +554 -0
  168. package/src/lib/uispec-drift.js +301 -0
  169. package/src/lib/uispec-intake.js +381 -0
  170. package/src/lib/worktree-guards.js +118 -0
  171. package/src/lib/worktree-recovery.js +100 -0
  172. package/src/memory/auto-linker.js +152 -0
  173. package/src/memory/benchmark.js +498 -0
  174. package/src/memory/dedup.js +126 -0
  175. package/src/memory/embedding-cache.js +136 -0
  176. package/src/memory/fact-extractor.js +168 -0
  177. package/src/memory/fts5.js +65 -1
  178. package/src/memory/migrations/004-bitemporal.js +91 -0
  179. package/src/memory/migrations/005-vector-cache.js +61 -0
  180. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  181. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  182. package/src/memory/migrations/008-write-provenance.js +41 -0
  183. package/src/memory/obsidian-parser.js +91 -0
  184. package/src/memory/query-dataview.js +86 -0
  185. package/src/memory/search.js +10 -0
  186. package/src/memory/temporal.js +529 -0
  187. package/src/memory/tokenize.js +10 -0
  188. package/src/memory-facts-handler.js +37 -0
  189. package/src/memory-feedback.js +260 -2
  190. package/src/model-refresh.js +292 -0
  191. package/src/observability/cost-anomaly.js +166 -0
  192. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  193. package/src/observability/trace-id.js +163 -0
  194. package/src/orchestrator/agents-md-blackboard.js +152 -0
  195. package/src/orchestrator/checkpoint-contract.md +140 -0
  196. package/src/orchestrator/debug-trident.js +570 -0
  197. package/src/orchestrator/merge-block-aware.js +350 -0
  198. package/src/orchestrator/plan-checker.js +475 -0
  199. package/src/orchestrator/post-done-runner.js +249 -0
  200. package/src/orchestrator/review.js +136 -0
  201. package/src/orchestrator/runtime-loop.js +430 -0
  202. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  203. package/src/orchestrator/skill-telemetry.js +37 -0
  204. package/src/orchestrator/state-events.js +459 -0
  205. package/src/orchestrator/state-sdk.js +1764 -0
  206. package/src/orchestrator/status-protocol.js +235 -0
  207. package/src/orchestrator/subagent-telemetry.js +452 -0
  208. package/src/orchestrator/termination.js +160 -0
  209. package/src/orchestrator/verification-gate.js +281 -0
  210. package/src/orchestrator/wave-state.js +564 -0
  211. package/src/orchestrator/worktree-provision.js +77 -0
  212. package/src/override-use-registry.js +111 -5
  213. package/src/receipts.js +36 -4
  214. package/src/recovery/checkpoint.js +56 -3
  215. package/src/recovery/code-fixer.js +656 -0
  216. package/src/recovery/truncation.js +317 -0
  217. package/src/redactor.js +75 -6
  218. package/src/runtime-mediator.js +15 -0
  219. package/src/sanitizer.js +10 -0
  220. package/src/search-hybrid.js +139 -0
  221. package/src/server.js +603 -59
  222. package/src/swarm/worktree.js +27 -4
  223. package/src/swarm-config.js +113 -12
  224. package/src/team/domain-templates/book.json +51 -0
  225. package/src/team/domain-templates/business.json +41 -0
  226. package/src/team/domain-templates/content.json +50 -0
  227. package/src/team/domain-templates/design.json +44 -0
  228. package/src/team/domain-templates/research.json +41 -0
  229. package/src/team/domain-templates/software.json +40 -0
  230. package/src/team/generator.js +278 -3
  231. package/src/team/modify.js +203 -0
  232. package/src/team/schemas.js +48 -0
  233. package/src/update-apply.js +19 -3
@@ -15,26 +15,113 @@
15
15
 
16
16
  import { spawn } from 'node:child_process';
17
17
  import * as readline from 'node:readline';
18
- import { pickAuditors, isReachable } from './audit-roster.js';
19
- import { loadSwarmConfig } from './swarm-config.js';
18
+ import { readdirSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
+ import { pickAuditors, isReachable, ROSTER } from './audit-roster.js';
21
+ import { loadSwarmConfig, DEFAULT_AUDITORS } from './swarm-config.js';
20
22
  import { buildRequest, parseResponse, mergeResponses, checkBudget } from './cross-dispatcher.js';
21
23
  import { writeReceipt, readReceipts } from './receipts.js';
22
24
  import { runViaApi } from './api-client.js';
23
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';
24
34
 
25
35
  // ---------------------------------------------------------------------------
26
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.
27
42
  // ---------------------------------------------------------------------------
28
43
  const PROVIDER_TIMEOUT_MS = {
29
44
  codex: 120_000,
30
- gemini: 45_000,
45
+ gemini: 90_000,
31
46
  anthropic: 60_000,
32
47
  'api-mode': 30_000,
33
48
  };
34
49
  const DEFAULT_TIMEOUT_MS = 90_000;
35
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
+
36
120
  function timeoutForPick(pick, resolvedTimeoutSec) {
37
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;
38
125
  return PROVIDER_TIMEOUT_MS[pick.id] ?? DEFAULT_TIMEOUT_MS;
39
126
  }
40
127
 
@@ -119,11 +206,72 @@ function angleFor(mode, id) {
119
206
  // silently pick up an unrelated gcloud project (cloudaicompanion.googleapis.com
120
207
  // billing collisions). Reproduced by Kat in issue #9.
121
208
  //
122
- // 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:
123
219
  // GEMINI_API_KEY (kept) > GOOGLE_APPLICATION_CREDENTIALS (dropped)
124
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
+
125
260
  export function buildSpawnEnv(pick, baseEnv) {
126
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.
127
275
  if (pick && pick.id === 'gemini' && env.GEMINI_API_KEY) {
128
276
  delete env.GOOGLE_APPLICATION_CREDENTIALS;
129
277
  delete env.GOOGLE_CLOUD_PROJECT;
@@ -253,23 +401,150 @@ async function fireExternal(pick, request, timeoutMs, env = process.env, signal
253
401
  return { stdout: '', stderr: apiResult.error, exitCode: null, status: 'failed', source: 'none', elapsedMs: elapsed() };
254
402
  }
255
403
 
256
- const raw = await spawnCli(pick, request, timeoutMs, signal, env);
404
+ let raw = await spawnCli(pick, request, timeoutMs, signal, env);
257
405
 
258
406
  // Aborted by runAc
259
407
  if (raw && raw.aborted) {
260
408
  return { stdout: '', stderr: 'aborted', exitCode: null, status: 'aborted', source: 'none', elapsedMs: elapsed() };
261
409
  }
262
410
 
263
- // Explicit timeout -- attempt API fallback before giving up.
264
- if (raw && raw.timedOut) {
265
- 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
+
266
446
  const { mode, angle, target } = extractApiParams();
267
- 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
+ }
268
540
  if (apiResult.status === 'ok') {
269
541
  return { stdout: apiResult.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
270
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() };
271
547
  }
272
- return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
273
548
  }
274
549
 
275
550
  // CLI failed -- try API fallback
@@ -381,6 +656,164 @@ function countItems(p) {
381
656
  return 0;
382
657
  }
383
658
 
659
+ // ---------------------------------------------------------------------------
660
+ // phase-e-auto helpers (v1.4.4 N10)
661
+ // ---------------------------------------------------------------------------
662
+
663
+ // Resolve the next CROSS-AUDIT-r<N>.md path under .planning/<phase>/.
664
+ // Scans for existing r<N> files and increments the highest N found.
665
+ function resolveAuditOutputPath(projectDir, phase) {
666
+ const planningDir = join(projectDir, '.planning', phase);
667
+ let maxN = 0;
668
+ if (existsSync(planningDir)) {
669
+ try {
670
+ const files = readdirSync(planningDir);
671
+ for (const f of files) {
672
+ const m = f.match(/^CROSS-AUDIT-r(\d+)\.md$/);
673
+ if (m) {
674
+ const n = parseInt(m[1], 10);
675
+ if (n > maxN) maxN = n;
676
+ }
677
+ }
678
+ } catch { /* non-fatal */ }
679
+ }
680
+ return join(planningDir, `CROSS-AUDIT-r${maxN + 1}.md`);
681
+ }
682
+
683
+ // Classify a merged audit result into PASS / CONDITIONAL / FAIL.
684
+ // HIGH severity finding → FAIL; any finding → CONDITIONAL; none → PASS.
685
+ function classifyVerdict(items) {
686
+ if (!Array.isArray(items) || items.length === 0) return 'PASS';
687
+ const hasHigh = items.some(item => {
688
+ const sev = (item.severity || item.level || '').toString().toUpperCase();
689
+ return sev === 'HIGH' || sev === 'CRITICAL';
690
+ });
691
+ return hasHigh ? 'FAIL' : 'CONDITIONAL';
692
+ }
693
+
694
+ // Pick the auditor roster for phase-e-auto from swarm.json (or defaults).
695
+ // Filters to entries that are reachable (CLI or API); missing CLI AND no
696
+ // apiFallback → skipped with a NOTE entry in the return value.
697
+ function resolvePhaseEAuditors(swarmConfig, env) {
698
+ const requestedIds = (Array.isArray(swarmConfig.auditors) && swarmConfig.auditors.length > 0)
699
+ ? swarmConfig.auditors
700
+ : [...DEFAULT_AUDITORS];
701
+
702
+ const picks = [];
703
+ const skipped = [];
704
+
705
+ for (const id of requestedIds) {
706
+ const entry = ROSTER.find(e => e.id === id);
707
+ if (!entry) {
708
+ skipped.push({ id, reason: 'not in roster' });
709
+ continue;
710
+ }
711
+ const reach = isReachable(id, env);
712
+ if (!reach.any) {
713
+ // CLI missing AND no apiFallback (or key not set) → skip with NOTE
714
+ skipped.push({ id, reason: 'CLI missing and no apiFallback configured' });
715
+ continue;
716
+ }
717
+ // Annotate API-only picks
718
+ const pick = (!reach.cli && reach.api) ? { ...entry, preferredSource: 'api' } : { ...entry };
719
+ picks.push(pick);
720
+ }
721
+
722
+ return { picks, skipped };
723
+ }
724
+
725
+ // Run the phase-e-auto branch. Does NOT use process.exit / uxGate / budget
726
+ // guard — it is a programmatic call from the orchestrator, not a CLI call.
727
+ async function runPhaseEAuto({ projectDir, phase, target, env, quiet }) {
728
+ const swarmConfig = loadSwarmConfig(projectDir);
729
+ const { picks, skipped } = resolvePhaseEAuditors(swarmConfig, env);
730
+
731
+ const notes = skipped.map(s => `NOTE: skipped auditor '${s.id}' — ${s.reason}`);
732
+
733
+ if (!quiet && notes.length > 0) {
734
+ process.stderr.write(notes.join('\n') + '\n');
735
+ }
736
+
737
+ if (picks.length === 0) {
738
+ const outputPath = resolveAuditOutputPath(projectDir, phase);
739
+ const content = `# Cross-Audit Phase E\n\nNo auditors available.\n\n${notes.map(n => `- ${n}`).join('\n')}\n`;
740
+ const dir = dirname(outputPath);
741
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
742
+ writeFileSync(outputPath, content, 'utf8');
743
+ return { verdict: 'CONDITIONAL', findings: [], outputPath, notes };
744
+ }
745
+
746
+ const auditTarget = target || 'HEAD~1..HEAD';
747
+ const resolvedTimeoutSec = null;
748
+
749
+ const requests = picks.map(pick => ({
750
+ pick,
751
+ payload: buildRequest('audit', auditTarget, pick.id, 'general', null),
752
+ }));
753
+
754
+ const tasks = requests.map(({ pick, payload }) => () =>
755
+ fireExternal(pick, payload, timeoutForPick(pick, resolvedTimeoutSec), env)
756
+ );
757
+ const rawResults = await fanOut(tasks, 3);
758
+
759
+ const auditorResults = rawResults.map((raw, i) => {
760
+ const pick = picks[i];
761
+ if (raw === null) {
762
+ return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
763
+ }
764
+ const { stdout, exitCode, status: rawStatus } = raw;
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]` } };
771
+ if (rawStatus === 'fallback-used') {
772
+ const p = parseResponse('audit', stdout);
773
+ return { status: 'fallback-used', counted: true, parsed: p };
774
+ }
775
+ if (exitCode !== 0) return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
776
+ const p = parseResponse('audit', stdout);
777
+ return { status: 'ok', counted: true, parsed: p };
778
+ });
779
+
780
+ const productive = auditorResults.filter(r => r.counted);
781
+ const parsed = productive.map(r => r.parsed);
782
+ const merged = mergeResponses('audit', parsed);
783
+ const items = Array.isArray(merged) ? merged : [];
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);
787
+
788
+ // Write synthesis to .planning/<phase>/CROSS-AUDIT-r<N>.md
789
+ const outputPath = resolveAuditOutputPath(projectDir, phase);
790
+ const dir = dirname(outputPath);
791
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
792
+
793
+ const auditorSummary = picks.map((p, i) => `- ${p.id}: ${auditorResults[i].status}`).join('\n');
794
+ const findingsSection = items.length > 0
795
+ ? items.map(item => `- [${(item.severity || item.level || 'INFO').toUpperCase()}] ${item.text || item.description || JSON.stringify(item)}`).join('\n')
796
+ : '_(none)_';
797
+ const content = [
798
+ `# Cross-Audit Phase E — ${phase}`,
799
+ '',
800
+ `**Verdict:** ${verdict}`,
801
+ `**Auditors:** ${picks.map(p => p.id).join(', ')}`,
802
+ '',
803
+ '## Auditor Status',
804
+ auditorSummary,
805
+ '',
806
+ '## Findings',
807
+ findingsSection,
808
+ '',
809
+ ...(notes.length > 0 ? ['## Notes', ...notes, ''] : []),
810
+ ].join('\n');
811
+
812
+ writeFileSync(outputPath, content, 'utf8');
813
+
814
+ return { verdict, findings: items, outputPath, notes };
815
+ }
816
+
384
817
  export async function runCrossOp({
385
818
  mode,
386
819
  target,
@@ -400,6 +833,14 @@ export async function runCrossOp({
400
833
  runStamp = runStamp ?? new Date().toISOString();
401
834
  env = env ?? process.env;
402
835
 
836
+ // v1.4.4 N10: phase-e-auto branch — programmatic orchestrator call.
837
+ // Reads .ijfw/swarm.json for auditor roster; graceful CLI-missing skip;
838
+ // writes .planning/<phase>/CROSS-AUDIT-r<N>.md; returns {verdict, findings, outputPath}.
839
+ if (mode === 'phase-e-auto') {
840
+ const phase = target || 'current';
841
+ return runPhaseEAuto({ projectDir, phase, target, env, quiet });
842
+ }
843
+
403
844
  const start = Date.now();
404
845
 
405
846
  // Shared abort controller for this run -- used by minResponsesFanOut to kill stragglers.
@@ -604,3 +1045,723 @@ export async function runCrossOp({
604
1045
  accept_degraded: !!accept_degraded,
605
1046
  };
606
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
+ // Returns:
1188
+ // { verdict, iterations, findings, divergence?, stalled?, perIteration,
1189
+ // timedOutTotal?, lensesOverBudget?, lensCosts }
1190
+ export async function runPhaseEConverge({
1191
+ commitRange,
1192
+ lenses = DEFAULT_LENSES,
1193
+ maxIterations = 3,
1194
+ dispatch,
1195
+ projectRoot,
1196
+ projectDir, // v1.5.0 audit-H4.5 — receipts destination (defaults to projectRoot)
1197
+ runStamp, // v1.5.0 audit-H4.5 — caller-supplied stamp; auto if absent
1198
+ totalTimeoutMs, // v1.5.0 audit-MED-trident-M6 — cumulative timeout
1199
+ perLensBudgetUsd, // v1.5.0 audit-MED-trident-M5 — per-lens USD cap
1200
+ keepaliveOnTick, // v1.5.0 wire-W1.B — caller-supplied keepalive heartbeat
1201
+ env = process.env,
1202
+ } = {}) {
1203
+ if (typeof dispatch !== 'function') {
1204
+ throw new Error('runPhaseEConverge: dispatch function is required');
1205
+ }
1206
+ if (!Array.isArray(lenses) || lenses.length === 0) {
1207
+ throw new Error('runPhaseEConverge: lenses must be a non-empty array');
1208
+ }
1209
+ // v1.5.0 audit-H4.1 — clamp to [1, MAX_CONVERGE_ITERATIONS]. Anything above
1210
+ // the cap is silently coerced; one-line stderr warning per unique requested
1211
+ // value (Set dedup) so a caller running the same misconfigured tool 100
1212
+ // times doesn't spam the log.
1213
+ const requested = Math.max(1, Math.floor(maxIterations));
1214
+ const cap = Math.min(requested, MAX_CONVERGE_ITERATIONS);
1215
+ if (requested > MAX_CONVERGE_ITERATIONS) {
1216
+ const key = String(requested);
1217
+ if (!_MAX_ITER_WARN_LOG.has(key)) {
1218
+ _MAX_ITER_WARN_LOG.add(key);
1219
+ process.stderr.write(
1220
+ `runPhaseEConverge: maxIterations=${requested} clamped to MAX_CONVERGE_ITERATIONS=${MAX_CONVERGE_ITERATIONS}.\n`
1221
+ );
1222
+ }
1223
+ }
1224
+
1225
+ // v1.5.0 audit-H4.5 — receipts wiring. The flagship converge tool was
1226
+ // previously invisible to `ijfw status` and the dashboard because no receipt
1227
+ // was written. Capture start time + per-cycle finding counts here, write
1228
+ // ONE summary receipt before each return path (writeReceiptIfPossible).
1229
+ const _startMs = Date.now();
1230
+ const _receiptCycles = []; // [{ iteration, findingCount, lensVerdicts: {lens:verdict} }]
1231
+ let _totalInvocations = 0; // dispatch calls across all iterations
1232
+
1233
+ const _resolvedProjectDir = projectDir ?? projectRoot ?? process.cwd();
1234
+ const _resolvedRunStamp = runStamp ?? new Date().toISOString();
1235
+
1236
+ // v1.5.0 T21 — telemetry accumulators for the three metrics published via
1237
+ // the state-SDK `telemetry.record` verb at finalize time.
1238
+ // * _telemetryAlarms — count of (lens, cycle) observations whose verdict
1239
+ // was FAIL or CONDITIONAL (i.e. "raised an alarm"). The false-positive
1240
+ // rate compares this against the final consensus verdict.
1241
+ // * _telemetryReachableObs — count of reachable (lens, cycle) observations
1242
+ // (excludes UNREACHABLE/budget-capped) — the denominator for the rate.
1243
+ // Cost is read off `_lensCosts` (already accumulated) at finalize.
1244
+ let _telemetryAlarms = 0;
1245
+ let _telemetryReachableObs = 0;
1246
+
1247
+ // v1.5.0 audit-MED-trident-M6 — cumulative-timeout AbortController.
1248
+ // Either an arg-supplied totalTimeoutMs or env var; >0 enables. The signal
1249
+ // is checked between iterations + passed to dispatch (dispatchers that
1250
+ // honor `signal` will tear down in-flight lens calls).
1251
+ const _envTotalSec = env && env.IJFW_AUDIT_CONVERGE_TOTAL_TIMEOUT_SEC;
1252
+ const _resolvedTotalMs = (typeof totalTimeoutMs === 'number' && totalTimeoutMs > 0)
1253
+ ? totalTimeoutMs
1254
+ : (Number.isFinite(Number(_envTotalSec)) && Number(_envTotalSec) > 0
1255
+ ? Math.floor(Number(_envTotalSec) * 1000)
1256
+ : null);
1257
+ const _cycleAc = new AbortController();
1258
+ let _totalTimedOut = false;
1259
+ let _totalTimer = null;
1260
+ if (_resolvedTotalMs) {
1261
+ _totalTimer = setTimeout(() => {
1262
+ _totalTimedOut = true;
1263
+ try { _cycleAc.abort(); } catch { /* ignore */ }
1264
+ }, _resolvedTotalMs);
1265
+ // Don't keep the event loop alive purely for this timer.
1266
+ if (typeof _totalTimer.unref === 'function') _totalTimer.unref();
1267
+ }
1268
+
1269
+ // v1.5.0 wire-W1.B — Anthropic ephemeral-cache keepalive heartbeat.
1270
+ // Production wiring for `mcp-server/src/lib/cache-keepalive.js`. A long
1271
+ // Trident convergence (5+ minutes of sequential lens calls separated by
1272
+ // CLI / API latency) can lose cache hits mid-wave because the 5-min TTL
1273
+ // expires between calls even though every call re-sends the same cache-
1274
+ // eligible prefix. The lib provides an opt-in heartbeat that fires a
1275
+ // no-op on a configurable interval to keep the cache warm.
1276
+ //
1277
+ // Default OFF (intervalMs=0 when IJFW_CACHE_KEEPALIVE_MS env is unset).
1278
+ // When the env var is set in the [1000, 300000] range, the heartbeat
1279
+ // fires every N ms until convergence completes or the cumulative-timeout
1280
+ // signal aborts (signal is passed through so the cap cascades into
1281
+ // keepalive too).
1282
+ //
1283
+ // Every tick increments a counter (surfaced on the return value + receipt
1284
+ // for observability). Production callers can additionally supply their own
1285
+ // onTick via `keepaliveOnTick` arg (e.g. to ping an API endpoint that
1286
+ // re-warms the cache prefix); it runs alongside the counter, not instead.
1287
+ let _keepaliveTicks = 0;
1288
+ const _keepalive = startKeepaliveFromEnv({
1289
+ // r21-LOW: count every tick regardless of whether a custom onTick is
1290
+ // supplied. Previously the counter only ran on the default path, so
1291
+ // runs that passed keepaliveOnTick under-reported as zero ticks.
1292
+ onTick: () => {
1293
+ _keepaliveTicks += 1;
1294
+ if (typeof keepaliveOnTick === 'function') {
1295
+ try { keepaliveOnTick(); } catch { /* keepalive errors must never crash the wave */ }
1296
+ }
1297
+ },
1298
+ onError: () => { /* keepalive errors must never crash the wave */ },
1299
+ signal: _cycleAc.signal,
1300
+ env,
1301
+ });
1302
+
1303
+ // v1.5.0 audit-MED-trident-M5 — per-lens budget. Tracks cumulative cost
1304
+ // attributed to each lens across this converge cycle; abort that lens once
1305
+ // its accumulated cost > cap. Cost source priority on dispatch return:
1306
+ // 1. result.cost_usd
1307
+ // 2. result.usage?.cost_usd
1308
+ // 3. 0 (unknown — counted as 0, can still hit cap via N iterations)
1309
+ const _envPerLensCap = env && env.IJFW_AUDIT_BUDGET_USD_PER_LENS;
1310
+ const _resolvedPerLensCap = (typeof perLensBudgetUsd === 'number' && perLensBudgetUsd > 0)
1311
+ ? perLensBudgetUsd
1312
+ : (Number.isFinite(Number(_envPerLensCap)) && Number(_envPerLensCap) > 0
1313
+ ? Number(_envPerLensCap)
1314
+ : null);
1315
+ const _lensCosts = Object.create(null);
1316
+ const _lensOverBudget = new Set();
1317
+ for (const lens of lenses) _lensCosts[lens] = 0;
1318
+
1319
+ async function _finalize(returnVal) {
1320
+ // Clear cumulative-timeout timer so the process can exit cleanly.
1321
+ if (_totalTimer) {
1322
+ clearTimeout(_totalTimer);
1323
+ _totalTimer = null;
1324
+ }
1325
+ // v1.5.0 wire-W1.B — sample the active flag BEFORE cancel. isActive()
1326
+ // returns false once cancelled, so reading it after teardown (r21-MED)
1327
+ // would under-report a heartbeat that was wired and running this wave.
1328
+ const _keepaliveActive = _keepalive.isActive();
1329
+ // Cancel keepalive (idempotent; no-op when never started).
1330
+ try { _keepalive.cancel(); } catch { /* never throws */ }
1331
+ // M5/M6: surface lens-budget + total-timeout posture on the return value
1332
+ // so callers can branch on partial-completion. lensCosts is always
1333
+ // present (even when no cap was set) so observability is consistent.
1334
+ const enriched = {
1335
+ ...returnVal,
1336
+ lensCosts: { ..._lensCosts },
1337
+ ...(_lensOverBudget.size > 0 ? { lensesOverBudget: [..._lensOverBudget] } : {}),
1338
+ ...(_totalTimedOut ? { timedOutTotal: true } : {}),
1339
+ // wire-W1.B observability: tick count + whether the heartbeat was wired
1340
+ // at all for this run (proves the lib is live, not just imported).
1341
+ keepaliveTicks: _keepaliveTicks,
1342
+ keepaliveWired: _keepaliveActive || _keepaliveTicks > 0,
1343
+ };
1344
+ // Build + write the converge receipt. Failure to write must NOT clobber
1345
+ // the orchestrator return value (defensive — receipts are observability,
1346
+ // not correctness).
1347
+ try {
1348
+ const record = {
1349
+ v: 1,
1350
+ timestamp: new Date().toISOString(),
1351
+ run_stamp: _resolvedRunStamp,
1352
+ mode: 'converge',
1353
+ target: commitRange,
1354
+ // Receipt-compatible auditor list (renderReceipt reads .id from each).
1355
+ auditors: lenses.map(id => ({ id })),
1356
+ // findings shape matches renderReceipt's array branch.
1357
+ findings: { items: Array.isArray(enriched.findings) ? enriched.findings : [] },
1358
+ duration_ms: Date.now() - _startMs,
1359
+ // Converge-specific fields (new — minimal addition; renderReceipt
1360
+ // ignores unknown keys, so existing receipt renderers keep working).
1361
+ converge: {
1362
+ iterations: enriched.iterations,
1363
+ verdict: enriched.verdict,
1364
+ stalled: !!enriched.stalled,
1365
+ divergent: Array.isArray(enriched.divergence) && enriched.divergence.length > 0,
1366
+ total_invocations: _totalInvocations,
1367
+ cycles: _receiptCycles,
1368
+ requested_max_iterations: requested,
1369
+ effective_max_iterations: cap,
1370
+ // M5/M6 observability.
1371
+ total_timeout_ms: _resolvedTotalMs,
1372
+ timed_out_total: _totalTimedOut,
1373
+ per_lens_budget_usd: _resolvedPerLensCap,
1374
+ lens_costs: _lensCosts,
1375
+ lenses_over_budget: [..._lensOverBudget],
1376
+ // wire-W1.B observability: how many keepalive heartbeats fired
1377
+ // during this convergence; 0 means the lib was wired but the env
1378
+ // opt-in was off, >0 proves the heartbeat ran in production.
1379
+ keepalive_ticks: _keepaliveTicks,
1380
+ },
1381
+ };
1382
+ writeReceipt(_resolvedProjectDir, record);
1383
+ } catch {
1384
+ // Receipts are observability; never fail the converge run on a write error.
1385
+ }
1386
+
1387
+ // v1.5.0 T21 (W4) — convergence telemetry via state-SDK `telemetry.record`.
1388
+ //
1389
+ // NOTE — this `_finalize` IIFE inside an async function chain: the
1390
+ // telemetry recorder needs to publish BEFORE the function returns so
1391
+ // callers (and tests) can read `.ijfw/telemetry/convergence.json`
1392
+ // synchronously after `await runPhaseEConverge(...)`. We therefore
1393
+ // shape `_finalize` to be async and await the telemetry write here.
1394
+ // The await is inside a try/catch that swallows everything — failure
1395
+ // to write telemetry never changes the convergence verdict.
1396
+ //
1397
+ // The three metrics are computed here AFTER the final verdict is known so
1398
+ // the false-positive rate has a stable consensus reference point. Failure
1399
+ // to record telemetry MUST NOT corrupt or override the convergence verdict:
1400
+ // wrapped in try/catch with full swallow per the off-the-critical-path
1401
+ // discipline. The convergence return value (`enriched`) is unchanged.
1402
+ //
1403
+ // Metric definitions (locked here so downstream dashboards and the
1404
+ // STATE-SDK contract §7 telemetry.record consumers can rely on them):
1405
+ //
1406
+ // 1. cyclesToConverge (integer ≥ 1, ≤ MAX_CONVERGE_ITERATIONS):
1407
+ // The iteration count at which the loop terminated. Equals
1408
+ // `enriched.iterations` — i.e. the number of dispatch rounds
1409
+ // actually executed before a stop condition (PASS consensus,
1410
+ // non-PASS consensus short-circuit, byte-identical stall,
1411
+ // cap reached, total-timeout, or all-budget-capped) was met.
1412
+ // A single-cycle PASS reports 1; a 3-iter stalemate that ends
1413
+ // in `consensus_failed` reports 3.
1414
+ //
1415
+ // 2. falsePositiveRate (decimal in [0, 1]):
1416
+ // Numerator: (lens, cycle) observations where a lens raised an
1417
+ // alarm (verdict FAIL or CONDITIONAL) but the FINAL consensus
1418
+ // verdict was PASS. UNREACHABLE observations are excluded.
1419
+ // Denominator: total reachable (lens, cycle) observations across
1420
+ // all cycles in this run.
1421
+ // Rationale: a "false positive" is a lens that cried wolf — it
1422
+ // said "this change is broken" against a run the swarm
1423
+ // ultimately blessed. When the final verdict is NOT PASS (FAIL,
1424
+ // CONDITIONAL, consensus_failed, UNREACHABLE), no alarm can be
1425
+ // retro-classified as false — the numerator is 0 and the rate
1426
+ // collapses to 0. When the denominator is 0 (no reachable
1427
+ // observations ever fired — all lenses budget-capped or all
1428
+ // dispatch threw), the rate is reported as 0 (no signal to
1429
+ // divide by). 0 ≤ rate ≤ 1 by construction.
1430
+ //
1431
+ // 3. costUsd (decimal ≥ 0):
1432
+ // Sum across all lenses of cumulative cost attributed to this
1433
+ // convergence run. Source: `_lensCosts[lens]`, which is fed from
1434
+ // `dispatch().cost_usd` (or `dispatch().usage.cost_usd`) on every
1435
+ // per-cycle settlement. Lenses that never returned a cost field
1436
+ // contribute 0 (their wall-clock time isn't a "USD" cost — that's
1437
+ // already captured in the receipt's `duration_ms` field). When
1438
+ // no lens reports cost, costUsd is 0.0; this is the truthful
1439
+ // signal that the swarm ran on CLI credentials with no per-call
1440
+ // billing surface, not a missing-data sentinel.
1441
+ try {
1442
+ const finalVerdict = enriched.verdict;
1443
+ const finalIsPass = finalVerdict === VERDICT_PASS;
1444
+ // Numerator: alarms only count as FALSE positives when consensus PASS'd.
1445
+ const _falsePositives = finalIsPass ? _telemetryAlarms : 0;
1446
+ const falsePositiveRate = _telemetryReachableObs > 0
1447
+ ? _falsePositives / _telemetryReachableObs
1448
+ : 0;
1449
+ let _summedCost = 0;
1450
+ for (const v of Object.values(_lensCosts)) {
1451
+ if (typeof v === 'number' && Number.isFinite(v)) _summedCost += v;
1452
+ }
1453
+ const metrics = {
1454
+ cyclesToConverge: enriched.iterations,
1455
+ falsePositiveRate,
1456
+ costUsd: _summedCost,
1457
+ // Diagnostics — not part of the locked three but useful to readers
1458
+ // of `.ijfw/telemetry/convergence.json`. The SDK verb stores the
1459
+ // metrics object opaquely so extra keys are non-breaking.
1460
+ verdict: finalVerdict,
1461
+ commitRange: typeof commitRange === 'string' ? commitRange : null,
1462
+ lensCount: lenses.length,
1463
+ reachableObservations: _telemetryReachableObs,
1464
+ alarmObservations: _telemetryAlarms,
1465
+ durationMs: Date.now() - _startMs,
1466
+ };
1467
+ // Stable, run-scoped dedup key. The verb's append-idempotency (§4)
1468
+ // requires a string; the runStamp is the canonical per-run identity
1469
+ // and the commitRange disambiguates concurrent runs against different
1470
+ // targets. Same (stamp, range) → same key → second record is dedup'd
1471
+ // by the verb (deterministic for tests + safe under double-fire).
1472
+ const dedupKey = `convergence:${_resolvedRunStamp}:${typeof commitRange === 'string' ? commitRange : 'nocommitrange'}`;
1473
+ // Off the critical path: the await is wrapped + swallowed. The verdict
1474
+ // has already been computed; this is observability only.
1475
+ await _stateQuery('telemetry.record', {
1476
+ kind: 'convergence',
1477
+ metrics,
1478
+ dedupKey,
1479
+ }, {
1480
+ projectRoot: _resolvedProjectDir,
1481
+ }).catch(() => {
1482
+ // Telemetry failures must NEVER affect the convergence verdict.
1483
+ // (e.g. read-only FS, missing .ijfw/, lock contention) — swallow.
1484
+ });
1485
+ } catch {
1486
+ // Defensive — should never throw before the `.catch` chain, but the
1487
+ // metric computation itself (e.g. exotic NaN in _lensCosts) must not
1488
+ // break the orchestrator return value.
1489
+ }
1490
+
1491
+ return enriched;
1492
+ }
1493
+
1494
+ let cycleSummary = null;
1495
+ let prior = null;
1496
+ let priorFindingsKey = null;
1497
+ const perIteration = [];
1498
+
1499
+ for (let iter = 1; iter <= cap; iter++) {
1500
+ // M6: stop the loop if the cumulative wall-clock budget has elapsed.
1501
+ if (_totalTimedOut || _cycleAc.signal.aborted) {
1502
+ return _finalize({
1503
+ verdict: VERDICT_CONSENSUS_FAIL,
1504
+ iterations: Math.max(1, iter - 1),
1505
+ findings: [],
1506
+ perIteration,
1507
+ });
1508
+ }
1509
+
1510
+ // M5: filter out lenses that have exceeded the per-lens USD cap.
1511
+ const activeLenses = lenses.filter(l => !_lensOverBudget.has(l));
1512
+ if (activeLenses.length === 0) {
1513
+ // All lenses budget-capped — return what we have with consensus_failed.
1514
+ return _finalize({
1515
+ verdict: VERDICT_CONSENSUS_FAIL,
1516
+ iterations: Math.max(1, iter - 1),
1517
+ findings: [],
1518
+ perIteration,
1519
+ });
1520
+ }
1521
+
1522
+ // Fire all (still-active) lenses in parallel.
1523
+ _totalInvocations += activeLenses.length;
1524
+ const settlements = await Promise.all(activeLenses.map(lens =>
1525
+ Promise.resolve()
1526
+ .then(() => dispatch({
1527
+ lens,
1528
+ commitRange,
1529
+ iteration: iter,
1530
+ cycleSummary,
1531
+ projectRoot,
1532
+ // M6: pass the cumulative AbortController signal to dispatchers
1533
+ // that honor it. Existing tests inject a dispatcher that ignores
1534
+ // `signal`; production dispatchers should pass it through to
1535
+ // fireExternal so in-flight CLI/API calls tear down on deadline.
1536
+ signal: _cycleAc.signal,
1537
+ }))
1538
+ .catch(err => ({
1539
+ lens,
1540
+ verdict: VERDICT_UNREACHABLE,
1541
+ findings: [],
1542
+ error: err && err.message ? err.message : String(err),
1543
+ }))
1544
+ ));
1545
+
1546
+ // M5: accrue per-lens cost + flag over-budget lenses for the next iter.
1547
+ if (_resolvedPerLensCap) {
1548
+ for (const s of settlements) {
1549
+ if (!s || !s.lens) continue;
1550
+ const cost = (typeof s.cost_usd === 'number' && Number.isFinite(s.cost_usd))
1551
+ ? s.cost_usd
1552
+ : (s.usage && typeof s.usage.cost_usd === 'number' ? s.usage.cost_usd : 0);
1553
+ _lensCosts[s.lens] = (_lensCosts[s.lens] || 0) + cost;
1554
+ if (_lensCosts[s.lens] > _resolvedPerLensCap) {
1555
+ _lensOverBudget.add(s.lens);
1556
+ }
1557
+ }
1558
+ } else {
1559
+ // Even without a cap, still track cumulative cost for observability.
1560
+ for (const s of settlements) {
1561
+ if (!s || !s.lens) continue;
1562
+ const cost = (typeof s.cost_usd === 'number' && Number.isFinite(s.cost_usd))
1563
+ ? s.cost_usd
1564
+ : (s.usage && typeof s.usage.cost_usd === 'number' ? s.usage.cost_usd : 0);
1565
+ _lensCosts[s.lens] = (_lensCosts[s.lens] || 0) + cost;
1566
+ }
1567
+ }
1568
+
1569
+ // Map back to the full lens list: lenses we filtered out (budget-capped)
1570
+ // synthesize a stub UNREACHABLE result so downstream divergence/stall
1571
+ // detection sees the same lens set every iteration.
1572
+ const settlementByLens = new Map();
1573
+ for (let i = 0; i < settlements.length; i++) {
1574
+ const r = settlements[i];
1575
+ const lensId = r && r.lens ? r.lens : activeLenses[i];
1576
+ settlementByLens.set(lensId, r);
1577
+ }
1578
+ const lensResults = lenses.map(lensId => {
1579
+ const r = settlementByLens.get(lensId);
1580
+ if (!r) {
1581
+ // M5: budget-capped lens — present as UNREACHABLE with explanatory error.
1582
+ return {
1583
+ lens: lensId,
1584
+ verdict: VERDICT_UNREACHABLE,
1585
+ findings: [],
1586
+ error: 'over per-lens budget',
1587
+ };
1588
+ }
1589
+ return {
1590
+ lens: r.lens || lensId,
1591
+ verdict: r.verdict ? r.verdict : VERDICT_UNREACHABLE,
1592
+ findings: Array.isArray(r.findings) ? r.findings : [],
1593
+ ...(r.error ? { error: r.error } : {}),
1594
+ };
1595
+ });
1596
+
1597
+ // Build per-lens findings map for stall detection.
1598
+ const perLensFindings = {};
1599
+ for (const r of lensResults) perLensFindings[r.lens] = r.findings;
1600
+ const findingsKey = stableFindingsKey(perLensFindings);
1601
+
1602
+ const reachable = lensResults.filter(r => r.verdict !== VERDICT_UNREACHABLE);
1603
+ const divergence = detectDivergence(lensResults);
1604
+
1605
+ perIteration.push({
1606
+ iteration: iter,
1607
+ lensResults,
1608
+ divergent: divergence.divergent,
1609
+ });
1610
+
1611
+ const mergedFindings = lensResults.flatMap(r => r.findings.map(f => ({ ...f, _lens: r.lens })));
1612
+
1613
+ // Receipt cycle metadata — per-cycle finding count + verdict map.
1614
+ const lensVerdicts = {};
1615
+ for (const r of lensResults) lensVerdicts[r.lens] = r.verdict;
1616
+ _receiptCycles.push({
1617
+ iteration: iter,
1618
+ findingCount: mergedFindings.length,
1619
+ lensVerdicts,
1620
+ });
1621
+
1622
+ // T21 — false-positive accounting. Every reachable lens observation is
1623
+ // a denominator tick; every FAIL/CONDITIONAL vote is a numerator tick
1624
+ // (it "raised an alarm"). Whether each alarm was a true or false positive
1625
+ // is decided at finalize against the final consensus verdict (see
1626
+ // `_finalize`). UNREACHABLE lenses are excluded from both numerator and
1627
+ // denominator — a CLI that never replied is neither a true nor false
1628
+ // positive, it's a no-data point.
1629
+ for (const r of lensResults) {
1630
+ if (r.verdict === VERDICT_UNREACHABLE) continue;
1631
+ _telemetryReachableObs += 1;
1632
+ if (r.verdict === VERDICT_FAIL || r.verdict === VERDICT_CONDITIONAL) {
1633
+ _telemetryAlarms += 1;
1634
+ }
1635
+ }
1636
+
1637
+ // Stop conditions, in priority order.
1638
+
1639
+ // 1. maxIterations === 1 — cap the loop after first iter no matter what.
1640
+ if (cap === 1) {
1641
+ const verdict = reachable.length === 0
1642
+ ? VERDICT_UNREACHABLE
1643
+ : (divergence.divergent ? VERDICT_CONSENSUS_FAIL : reachable[0].verdict);
1644
+ return _finalize({
1645
+ verdict,
1646
+ iterations: 1,
1647
+ findings: mergedFindings,
1648
+ ...(divergence.divergent ? { divergence: divergence.axes } : {}),
1649
+ perIteration,
1650
+ });
1651
+ }
1652
+
1653
+ // 2. All reachable lenses PASS → done.
1654
+ if (reachable.length > 0 && !divergence.divergent && reachable[0].verdict === VERDICT_PASS) {
1655
+ return _finalize({
1656
+ verdict: VERDICT_PASS,
1657
+ iterations: iter,
1658
+ findings: mergedFindings,
1659
+ perIteration,
1660
+ });
1661
+ }
1662
+
1663
+ // 3. Consensus on non-PASS (e.g. all FAIL) — short-circuit, no point looping.
1664
+ if (reachable.length > 0 && !divergence.divergent) {
1665
+ return _finalize({
1666
+ verdict: reachable[0].verdict,
1667
+ iterations: iter,
1668
+ findings: mergedFindings,
1669
+ perIteration,
1670
+ });
1671
+ }
1672
+
1673
+ // 4. Stall breaker: byte-identical findings to prior iter → halt.
1674
+ if (priorFindingsKey !== null && findingsKey === priorFindingsKey) {
1675
+ return _finalize({
1676
+ verdict: VERDICT_CONSENSUS_FAIL,
1677
+ iterations: iter,
1678
+ findings: mergedFindings,
1679
+ divergence: divergence.axes,
1680
+ stalled: true,
1681
+ perIteration,
1682
+ });
1683
+ }
1684
+
1685
+ // 5. Cap hit while still divergent → consensus_failed.
1686
+ if (iter === cap) {
1687
+ return _finalize({
1688
+ verdict: VERDICT_CONSENSUS_FAIL,
1689
+ iterations: iter,
1690
+ findings: mergedFindings,
1691
+ divergence: divergence.axes,
1692
+ perIteration,
1693
+ });
1694
+ }
1695
+
1696
+ // Otherwise: stage next iteration with cycle summary.
1697
+ prior = { lensResults, divergence };
1698
+ cycleSummary = buildCycleSummary(iter + 1, prior);
1699
+ priorFindingsKey = findingsKey;
1700
+ }
1701
+
1702
+ // Unreachable; loop must exit via one of the return paths above.
1703
+ /* c8 ignore next */
1704
+ return _finalize({ verdict: VERDICT_CONSENSUS_FAIL, iterations: cap, findings: [], perIteration });
1705
+ }
1706
+
1707
+ // Default production dispatcher. Wraps the existing single-lens spawn path
1708
+ // (audit-roster pick → buildRequest → fireExternal → parseResponse →
1709
+ // classifyVerdict). Tests pass their own dispatcher; production callers can
1710
+ // either pass this one or supply a customized wrapper.
1711
+ //
1712
+ // One quirk: this dispatcher embeds the cycleSummary into the audit target
1713
+ // string (prefixed) so the lens sees prior-round context in its prompt.
1714
+ // Lens stdout shape: same as parseResponse('audit', stdout).
1715
+ export async function defaultConvergeDispatch({ lens, commitRange, iteration, cycleSummary, projectRoot, signal } = {}) {
1716
+ const env = process.env;
1717
+ const entry = ROSTER.find(e => e.id === lens);
1718
+ if (!entry) {
1719
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `lens "${lens}" not in roster` };
1720
+ }
1721
+ // v1.5.0 wire-W1.F — honor a pre-aborted cumulative-timeout signal before
1722
+ // any CLI/API spawn so runPhaseEConverge's totalTimeoutMs cap actually
1723
+ // bounds wall-clock time when the deadline expired between iterations.
1724
+ if (signal && signal.aborted) {
1725
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: 'aborted' };
1726
+ }
1727
+ const reach = isReachable(lens, env);
1728
+ if (!reach.any) {
1729
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `lens "${lens}" CLI missing and no apiFallback` };
1730
+ }
1731
+ const pick = (!reach.cli && reach.api) ? { ...entry, preferredSource: 'api' } : { ...entry };
1732
+
1733
+ // v1.5.0 audit-H4.2 — cycleSummary MUST come AFTER any cache_control-eligible
1734
+ // block. Cached content (the stable commitRange/target) goes FIRST so user-
1735
+ // message cache_control (planned for v1.5.0 ADJUDICATION-1 cap-layer work)
1736
+ // hits the same prefix on every iteration; the iteration-varying summary
1737
+ // goes LAST so it never busts the cache. The previous prefix layout would
1738
+ // invalidate the cache on every iteration ≥ 2. See ADJUDICATIONS.md
1739
+ // DISPUTED-1 (cache_control ordering invariant).
1740
+ const target = (iteration > 1 && cycleSummary)
1741
+ ? `${commitRange}\n\n---\n\n${cycleSummary}`
1742
+ : commitRange;
1743
+
1744
+ const request = buildRequest('audit', target, pick.id, 'general', null);
1745
+ const timeoutMs = timeoutForPick(pick, null);
1746
+ try {
1747
+ // v1.5.0 wire-W1.F — forward cumulative-timeout signal so in-flight CLI
1748
+ // spawn + API fallback both cascade their AbortControllers on deadline.
1749
+ const raw = await fireExternal(pick, request, timeoutMs, env, signal || null);
1750
+ if (!raw || raw.status === 'timeout' || raw.status === 'failed' || raw.status === 'aborted') {
1751
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: (raw && raw.stderr) || 'no output' };
1752
+ }
1753
+ if (raw.exitCode !== 0 && raw.status !== 'fallback-used') {
1754
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `exit ${raw.exitCode}` };
1755
+ }
1756
+ const parsed = parseResponse('audit', raw.stdout);
1757
+ const items = Array.isArray(parsed.items) ? parsed.items : [];
1758
+ const verdict = classifyVerdict(items);
1759
+ return { lens, verdict, findings: items };
1760
+ } catch (err) {
1761
+ return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: err && err.message ? err.message : String(err) };
1762
+ }
1763
+ /* projectRoot reserved for future per-project dispatcher overrides */
1764
+ // eslint-disable-next-line no-unreachable
1765
+ void projectRoot;
1766
+ }
1767
+