@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
@@ -0,0 +1,570 @@
1
+ /**
2
+ * debug-trident.js — v1.5.0 T29 (W2): Trident-powered debug loop.
3
+ *
4
+ * The ijfw-debug stack (claude/agents/ijfw-debug-session-manager.md +
5
+ * claude/agents/ijfw-debugger.md) drives a multi-cycle scientific-method
6
+ * investigation: reproduce → hypothesise → falsify → resolve. Until W2 the
7
+ * loop was *single-lens*: when the active hypothesis stalled (either
8
+ * INVESTIGATION_INCONCLUSIVE or a byte-identical hypothesis tree two cycles
9
+ * running), the only escape was to surface NEEDS_CONTEXT and ask the user
10
+ * for more evidence. That collapses the debug strength of cross-AI
11
+ * disagreement — a stuck Claude hypothesis tree is exactly where Codex's
12
+ * dependency-call-graph reading or Gemini's broader-context cross-file
13
+ * search would have produced a competing hypothesis the single lens missed.
14
+ *
15
+ * Moat #3 — "Trident-powered debug" — closes the gap by dispatching codex
16
+ * and gemini lenses, in parallel, to generate competing hypotheses against
17
+ * the same evidence pack. The lens responses are appended to the
18
+ * hypothesis tree as new rows (status:'open') and the next cycle resumes
19
+ * with a refreshed candidate set. A campaign exit telemetry record is
20
+ * written via the state-SDK telemetry.record verb (kind:'debug-campaign').
21
+ *
22
+ * --------------------------------------------------------------------------
23
+ * SCOPE — what this module is
24
+ * --------------------------------------------------------------------------
25
+ *
26
+ * * A pluggable orchestration shell (`runDebugCampaign`) that wraps an
27
+ * opaque per-cycle `dispatch` function — the same dependency-injection
28
+ * contract that runPhaseEConverge uses (cross-orchestrator.js §1190).
29
+ * The dispatch function returns one of the structured terminators the
30
+ * ijfw-debugger agent emits: ROOT_CAUSE_FOUND, INVESTIGATION_INCONCLUSIVE,
31
+ * CHECKPOINT_REACHED, TDD_CHECKPOINT, DEBUG_COMPLETE.
32
+ *
33
+ * * Stall detection — two adjacent cycles whose hypothesis-tree signature
34
+ * is byte-identical OR an INVESTIGATION_INCONCLUSIVE terminator => the
35
+ * loop escalates to a Trident hypothesis-gen sub-step.
36
+ *
37
+ * * Cross-lens hypothesis generation — calls `tridentDispatch({ lens,
38
+ * evidencePack, currentHypotheses })` for each non-stalled lens
39
+ * (default: codex + gemini, claude excluded because it owns the stall).
40
+ * The dispatcher is injected; production wires it through the existing
41
+ * cross-dispatcher.js MCP CLI surface, tests stub it.
42
+ *
43
+ * * Hypothesis-tree merge — new candidates are appended with provenance
44
+ * `from:'trident:codex'` / `from:'trident:gemini'` so the receipt
45
+ * captures which lens contributed which hypothesis.
46
+ *
47
+ * * Telemetry — `telemetry.record({ kind:'debug-campaign', metrics })`
48
+ * where metrics ⊇ { cycles, stalls, tridentInvocations, hypothesesAdded,
49
+ * hypothesesCompetingCount, resolved, resolutionLens }.
50
+ *
51
+ * --------------------------------------------------------------------------
52
+ * NON-SCOPE — what this module is NOT
53
+ * --------------------------------------------------------------------------
54
+ *
55
+ * * The investigator. ijfw-debugger.md owns scientific method; this
56
+ * module orchestrates lens dispatch around the existing investigator
57
+ * contract.
58
+ *
59
+ * * The state writer for checkpoint files. The session-manager agent
60
+ * persists `<session_id>.state.json` and `HYPOTHESES.md`; this module
61
+ * accepts them as opaque structured input.
62
+ *
63
+ * * A consumer of state-sdk write-locks beyond `telemetry.record`. The
64
+ * campaign itself is in-memory; only the exit telemetry is persisted.
65
+ *
66
+ * Zero new prod deps. ESM. Node ≥18. No emoji.
67
+ */
68
+
69
+ import { query as stateQuery } from './state-sdk.js';
70
+
71
+ /**
72
+ * Reasons the campaign loop terminates. Surfaced on the return value as
73
+ * `outcome` so callers can branch and the receipt can render a one-line
74
+ * narrative without re-deriving from cycle history.
75
+ */
76
+ export const DEBUG_OUTCOMES = Object.freeze({
77
+ RESOLVED: 'resolved',
78
+ ROOT_CAUSE: 'root_cause_found',
79
+ CHECKPOINT: 'awaiting_context',
80
+ EXHAUSTED: 'cycles_exhausted',
81
+ TRIDENT_DRY: 'trident_no_new_hypotheses',
82
+ FAILED: 'campaign_failed',
83
+ });
84
+
85
+ /**
86
+ * Stall signature — a stable, order-independent serialization of the
87
+ * current open hypothesis tree. Used to detect "no new ground covered" two
88
+ * cycles in a row. Sorting both the row list and the per-row key set means
89
+ * two trees with identical content but different insertion order produce
90
+ * the same signature.
91
+ *
92
+ * Input shape (matches HYPOTHESES.md table):
93
+ * [{ id:'H1', hypothesis:'…', status:'open|testing|confirmed|refuted',
94
+ * evidence:'…', refuted_by:'…' }, …]
95
+ */
96
+ export function hypothesisTreeSignature(hypotheses) {
97
+ if (!Array.isArray(hypotheses) || hypotheses.length === 0) return '';
98
+ const rows = hypotheses.map((row) => {
99
+ if (!row || typeof row !== 'object') return '';
100
+ const keys = Object.keys(row).sort();
101
+ const parts = keys.map((k) => `${k}=${JSON.stringify(row[k] ?? '')}`);
102
+ return parts.join('|');
103
+ });
104
+ rows.sort();
105
+ return rows.join('||');
106
+ }
107
+
108
+ /**
109
+ * Decide whether the most recent cycle qualifies as a STALL — the trigger
110
+ * for Trident hypothesis generation.
111
+ *
112
+ * Stall conditions (any one):
113
+ * (a) The just-returned terminator was INVESTIGATION_INCONCLUSIVE.
114
+ * (b) The hypothesis tree signature is byte-identical to the prior
115
+ * cycle's signature AND no terminal status (DEBUG_COMPLETE /
116
+ * ROOT_CAUSE_FOUND) was returned.
117
+ * (c) Caller forced via `forceTrident:true` (test hook).
118
+ *
119
+ * Returns a small object `{ stalled, reason }` so the receipt can record
120
+ * which condition fired.
121
+ */
122
+ export function detectStall({ terminator, signature, priorSignature, forceTrident = false } = {}) {
123
+ if (forceTrident) {
124
+ return { stalled: true, reason: 'forced' };
125
+ }
126
+ const term = String(terminator || '').toUpperCase();
127
+ if (term === 'INVESTIGATION_INCONCLUSIVE') {
128
+ return { stalled: true, reason: 'inconclusive_terminator' };
129
+ }
130
+ if (term === 'DEBUG_COMPLETE' || term === 'ROOT_CAUSE_FOUND') {
131
+ return { stalled: false, reason: 'progress' };
132
+ }
133
+ if (priorSignature && signature && priorSignature === signature) {
134
+ return { stalled: true, reason: 'byte_identical_tree' };
135
+ }
136
+ return { stalled: false, reason: 'progress' };
137
+ }
138
+
139
+ /**
140
+ * Validate a Trident-lens hypothesis-gen response. Each lens returns:
141
+ * { lens, hypotheses: [ { hypothesis: string, rationale?: string } ] }
142
+ *
143
+ * A malformed response is treated as zero contributions (defensive: a
144
+ * single bad lens never crashes the campaign).
145
+ */
146
+ export function normaliseLensResponse(raw, lens) {
147
+ if (!raw || typeof raw !== 'object') {
148
+ return { lens, hypotheses: [], ok: false, reason: 'non-object' };
149
+ }
150
+ const list = Array.isArray(raw.hypotheses) ? raw.hypotheses : [];
151
+ const cleaned = [];
152
+ for (const h of list) {
153
+ if (!h || typeof h !== 'object') continue;
154
+ const text = typeof h.hypothesis === 'string' ? h.hypothesis.trim() : '';
155
+ if (!text) continue;
156
+ cleaned.push({
157
+ hypothesis: text,
158
+ rationale: typeof h.rationale === 'string' ? h.rationale.trim() : '',
159
+ });
160
+ }
161
+ return { lens, hypotheses: cleaned, ok: true };
162
+ }
163
+
164
+ /**
165
+ * Run the Trident hypothesis-generation sub-step.
166
+ *
167
+ * The DI hook `tridentDispatch` is invoked once per lens IN PARALLEL with
168
+ * an evidence pack + the current hypothesis tree (so the lens can avoid
169
+ * proposing duplicates of what's already been refuted). A lens that
170
+ * throws is captured as `ok:false`; a lens that returns malformed JSON is
171
+ * normalised to zero hypotheses — neither crashes the campaign.
172
+ *
173
+ * Returns `{ perLens, totalAdded, novelHypotheses }`:
174
+ * - perLens: array of { lens, hypotheses, ok, reason? }
175
+ * - totalAdded: count of novel hypothesis rows after dedup
176
+ * - novelHypotheses: the merged-in rows (with provenance)
177
+ *
178
+ * "Novel" = the hypothesis text (case-insensitive, whitespace-collapsed)
179
+ * does not match any existing row's hypothesis. Refuted rows still count
180
+ * as "existing" — a lens proposing the same refuted theory does NOT get
181
+ * to re-raise it; the orchestrator's job is to break out of stuck space,
182
+ * not loop.
183
+ */
184
+ export async function generateCompetingHypotheses({
185
+ evidencePack,
186
+ currentHypotheses,
187
+ lenses,
188
+ tridentDispatch,
189
+ abortSignal,
190
+ } = {}) {
191
+ if (typeof tridentDispatch !== 'function') {
192
+ throw new Error('generateCompetingHypotheses: tridentDispatch is required');
193
+ }
194
+ if (!Array.isArray(lenses) || lenses.length === 0) {
195
+ throw new Error('generateCompetingHypotheses: lenses must be non-empty');
196
+ }
197
+ // Normalise the existing-hypothesis text set for dedup.
198
+ const existingNorm = new Set();
199
+ for (const row of currentHypotheses || []) {
200
+ if (row && typeof row.hypothesis === 'string') {
201
+ existingNorm.add(row.hypothesis.trim().toLowerCase().replace(/\s+/g, ' '));
202
+ }
203
+ }
204
+
205
+ // Dispatch lenses in parallel — defensive Promise.all that converts a
206
+ // thrown dispatch into { ok:false, error } rather than rejecting the
207
+ // whole batch (one stuck lens can't kill the campaign).
208
+ const perLens = await Promise.all(
209
+ lenses.map(async (lens) => {
210
+ try {
211
+ const raw = await tridentDispatch({
212
+ lens,
213
+ evidencePack,
214
+ currentHypotheses: Array.isArray(currentHypotheses) ? currentHypotheses : [],
215
+ signal: abortSignal,
216
+ });
217
+ return normaliseLensResponse(raw, lens);
218
+ } catch (err) {
219
+ return {
220
+ lens,
221
+ hypotheses: [],
222
+ ok: false,
223
+ reason: err && err.message ? err.message : String(err),
224
+ };
225
+ }
226
+ })
227
+ );
228
+
229
+ // Merge — append novel rows with provenance. Two lenses can independently
230
+ // converge on the same hypothesis text; the first lens wins the row, the
231
+ // second is dropped (recorded in the receipt as a consensus signal).
232
+ const novelHypotheses = [];
233
+ // Determine the next id by scanning existing row ids of form H<N>.
234
+ const idsTaken = (currentHypotheses || [])
235
+ .map((r) => (r && typeof r.id === 'string' ? r.id : ''))
236
+ .filter((s) => /^H\d+$/.test(s))
237
+ .map((s) => parseInt(s.slice(1), 10));
238
+ let nextId = (idsTaken.length === 0 ? 0 : Math.max(...idsTaken)) + 1;
239
+ const seenThisRound = new Set();
240
+ for (const lensRow of perLens) {
241
+ for (const h of lensRow.hypotheses) {
242
+ const norm = h.hypothesis.trim().toLowerCase().replace(/\s+/g, ' ');
243
+ if (existingNorm.has(norm)) continue;
244
+ if (seenThisRound.has(norm)) continue;
245
+ seenThisRound.add(norm);
246
+ novelHypotheses.push({
247
+ id: `H${nextId++}`,
248
+ hypothesis: h.hypothesis.trim(),
249
+ status: 'open',
250
+ evidence: '',
251
+ refuted_by: '',
252
+ from: `trident:${lensRow.lens}`,
253
+ rationale: h.rationale || '',
254
+ });
255
+ }
256
+ }
257
+ return {
258
+ perLens,
259
+ totalAdded: novelHypotheses.length,
260
+ novelHypotheses,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Run a Trident-powered debug campaign.
266
+ *
267
+ * @param {object} opts
268
+ * @param {string} opts.sessionId slug — e.g. 'auth-redirect-loop'
269
+ * @param {string} opts.symptoms one-line expected vs actual
270
+ * @param {Array<object>} [opts.hypotheses] initial hypothesis tree (rows)
271
+ * @param {Function} opts.dispatch async ({ cycle, hypotheses, evidencePack }) =>
272
+ * { terminator, hypothesesPatch?, fix?, rootCause? }
273
+ * terminator ∈ ROOT_CAUSE_FOUND | DEBUG_COMPLETE |
274
+ * INVESTIGATION_INCONCLUSIVE | CHECKPOINT_REACHED |
275
+ * TDD_CHECKPOINT
276
+ * @param {Function} opts.tridentDispatch async ({ lens, evidencePack, currentHypotheses }) =>
277
+ * { lens, hypotheses: [ { hypothesis, rationale? } ] }
278
+ * @param {Array<string>} [opts.tridentLenses] default ['codex','gemini']; claude
279
+ * omitted because it owns the stall
280
+ * @param {number} [opts.maxCycles] default 6
281
+ * @param {string} [opts.evidencePack] opaque blob forwarded to dispatches
282
+ * @param {string} [opts.projectRoot] forwarded to telemetry.record
283
+ * @param {string} [opts.runStamp] ISO timestamp seed (deterministic in tests)
284
+ * @param {AbortSignal} [opts.abortSignal] propagates into dispatch calls
285
+ * @param {boolean} [opts.recordTelemetry] default true; off in unit tests that
286
+ * don't supply a projectRoot
287
+ * @returns {Promise<object>} campaign record (see CAMPAIGN_RECORD_SHAPE)
288
+ *
289
+ * CAMPAIGN_RECORD_SHAPE:
290
+ * {
291
+ * sessionId, symptoms, outcome (DEBUG_OUTCOMES), cycles, stalls,
292
+ * tridentInvocations, hypothesesAdded, resolutionLens, rootCause,
293
+ * fix, cyclesLog: [...], hypothesesFinal: [...], duration_ms
294
+ * }
295
+ */
296
+ export async function runDebugCampaign({
297
+ sessionId,
298
+ symptoms,
299
+ hypotheses = [],
300
+ dispatch,
301
+ tridentDispatch,
302
+ tridentLenses = ['codex', 'gemini'],
303
+ maxCycles = 6,
304
+ evidencePack = '',
305
+ projectRoot,
306
+ runStamp,
307
+ abortSignal,
308
+ recordTelemetry = true,
309
+ } = {}) {
310
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
311
+ throw new Error('runDebugCampaign: sessionId is required');
312
+ }
313
+ if (typeof symptoms !== 'string' || !symptoms.trim()) {
314
+ throw new Error('runDebugCampaign: symptoms is required');
315
+ }
316
+ if (typeof dispatch !== 'function') {
317
+ throw new Error('runDebugCampaign: dispatch is required');
318
+ }
319
+ if (typeof tridentDispatch !== 'function') {
320
+ throw new Error('runDebugCampaign: tridentDispatch is required');
321
+ }
322
+ if (!Array.isArray(tridentLenses) || tridentLenses.length === 0) {
323
+ throw new Error('runDebugCampaign: tridentLenses must be a non-empty array');
324
+ }
325
+ if (!Number.isFinite(maxCycles) || maxCycles < 1) {
326
+ throw new Error('runDebugCampaign: maxCycles must be a positive integer');
327
+ }
328
+
329
+ const t0 = Date.now();
330
+ const _runStamp = runStamp || new Date().toISOString();
331
+
332
+ // Mutable campaign state. The hypotheses array is the source of truth —
333
+ // dispatch may return a `hypothesesPatch` (replacement or delta) that the
334
+ // orchestrator merges; Trident escalation also mutates it.
335
+ let workingHypotheses = Array.isArray(hypotheses) ? [...hypotheses] : [];
336
+ let priorSignature = hypothesisTreeSignature(workingHypotheses);
337
+ let outcome = DEBUG_OUTCOMES.EXHAUSTED;
338
+ let resolutionLens = null;
339
+ let rootCause = null;
340
+ let fix = null;
341
+ let stalls = 0;
342
+ let tridentInvocations = 0;
343
+ let hypothesesAdded = 0;
344
+ let lastError = null;
345
+ const cyclesLog = [];
346
+
347
+ // The main investigation loop.
348
+ for (let cycle = 1; cycle <= maxCycles; cycle++) {
349
+ if (abortSignal && abortSignal.aborted) {
350
+ outcome = DEBUG_OUTCOMES.FAILED;
351
+ lastError = 'aborted';
352
+ break;
353
+ }
354
+
355
+ let dispatchResult;
356
+ try {
357
+ dispatchResult = await dispatch({
358
+ cycle,
359
+ hypotheses: workingHypotheses,
360
+ evidencePack,
361
+ sessionId,
362
+ symptoms,
363
+ signal: abortSignal,
364
+ });
365
+ } catch (err) {
366
+ outcome = DEBUG_OUTCOMES.FAILED;
367
+ lastError = err && err.message ? err.message : String(err);
368
+ cyclesLog.push({
369
+ cycle,
370
+ terminator: 'DISPATCH_THREW',
371
+ error: lastError,
372
+ });
373
+ break;
374
+ }
375
+
376
+ const terminator = String(dispatchResult?.terminator || '').toUpperCase();
377
+
378
+ // Merge dispatcher-supplied hypothesis updates BEFORE stall detection so
379
+ // we evaluate the post-cycle tree (mirrors what HYPOTHESES.md would
380
+ // look like on disk after the investigator wrote its row updates).
381
+ if (Array.isArray(dispatchResult?.hypothesesPatch)) {
382
+ workingHypotheses = mergeHypothesesPatch(workingHypotheses, dispatchResult.hypothesesPatch);
383
+ } else if (Array.isArray(dispatchResult?.hypothesesReplacement)) {
384
+ workingHypotheses = [...dispatchResult.hypothesesReplacement];
385
+ }
386
+
387
+ const signature = hypothesisTreeSignature(workingHypotheses);
388
+ const cycleEntry = {
389
+ cycle,
390
+ terminator,
391
+ hypothesisCount: workingHypotheses.length,
392
+ signature: signature.slice(0, 64),
393
+ };
394
+
395
+ // Terminal happy paths.
396
+ if (terminator === 'DEBUG_COMPLETE') {
397
+ outcome = DEBUG_OUTCOMES.RESOLVED;
398
+ fix = typeof dispatchResult.fix === 'string' ? dispatchResult.fix : null;
399
+ rootCause = typeof dispatchResult.rootCause === 'string' ? dispatchResult.rootCause : null;
400
+ resolutionLens = dispatchResult.resolutionLens || 'claude';
401
+ cyclesLog.push(cycleEntry);
402
+ break;
403
+ }
404
+ if (terminator === 'ROOT_CAUSE_FOUND') {
405
+ outcome = DEBUG_OUTCOMES.ROOT_CAUSE;
406
+ rootCause = typeof dispatchResult.rootCause === 'string' ? dispatchResult.rootCause : null;
407
+ resolutionLens = dispatchResult.resolutionLens || 'claude';
408
+ cyclesLog.push(cycleEntry);
409
+ break;
410
+ }
411
+ if (terminator === 'CHECKPOINT_REACHED') {
412
+ outcome = DEBUG_OUTCOMES.CHECKPOINT;
413
+ cyclesLog.push(cycleEntry);
414
+ break;
415
+ }
416
+
417
+ // Stall detection.
418
+ const stall = detectStall({
419
+ terminator,
420
+ signature,
421
+ priorSignature,
422
+ forceTrident: !!dispatchResult?.forceTrident,
423
+ });
424
+
425
+ if (stall.stalled) {
426
+ stalls += 1;
427
+ cycleEntry.stalled = true;
428
+ cycleEntry.stallReason = stall.reason;
429
+
430
+ // Trident escalation — generate competing cross-lens hypotheses.
431
+ tridentInvocations += 1;
432
+ let tridentResult;
433
+ try {
434
+ tridentResult = await generateCompetingHypotheses({
435
+ evidencePack,
436
+ currentHypotheses: workingHypotheses,
437
+ lenses: tridentLenses,
438
+ tridentDispatch,
439
+ abortSignal,
440
+ });
441
+ } catch (err) {
442
+ // A throw from the Trident sub-step itself (NOT from a per-lens
443
+ // dispatch — those are already caught above) is a campaign-level
444
+ // failure: we cannot recover the stall without competing hypotheses
445
+ // and continuing would just loop on the same signature.
446
+ outcome = DEBUG_OUTCOMES.FAILED;
447
+ lastError = err && err.message ? err.message : String(err);
448
+ cycleEntry.tridentError = lastError;
449
+ cyclesLog.push(cycleEntry);
450
+ break;
451
+ }
452
+ cycleEntry.trident = {
453
+ lensesInvoked: tridentResult.perLens.map((p) => p.lens),
454
+ lensesOk: tridentResult.perLens.filter((p) => p.ok).map((p) => p.lens),
455
+ lensesFailed: tridentResult.perLens.filter((p) => !p.ok).map((p) => ({
456
+ lens: p.lens,
457
+ reason: p.reason,
458
+ })),
459
+ hypothesesAdded: tridentResult.totalAdded,
460
+ novelHypothesesPreview: tridentResult.novelHypotheses
461
+ .slice(0, 3)
462
+ .map((h) => ({ id: h.id, from: h.from, hypothesis: h.hypothesis })),
463
+ };
464
+ hypothesesAdded += tridentResult.totalAdded;
465
+
466
+ if (tridentResult.totalAdded === 0) {
467
+ // No new ground from Trident either — terminate as exhausted (the
468
+ // single-lens AND the cross-lens swarm both stalled on the same
469
+ // hypothesis space).
470
+ outcome = DEBUG_OUTCOMES.TRIDENT_DRY;
471
+ cyclesLog.push(cycleEntry);
472
+ break;
473
+ }
474
+
475
+ // Append novel hypotheses; refresh the signature so the NEXT cycle
476
+ // doesn't immediately re-trigger stall detection on the merged tree.
477
+ workingHypotheses = workingHypotheses.concat(tridentResult.novelHypotheses);
478
+ }
479
+
480
+ cyclesLog.push(cycleEntry);
481
+ priorSignature = hypothesisTreeSignature(workingHypotheses);
482
+ }
483
+
484
+ // -------- Telemetry: write campaign exit metrics via state-SDK.
485
+ // OFF the critical path — failure must NEVER alter the returned outcome
486
+ // (mirrors cross-orchestrator.js T21 convergence-telemetry discipline).
487
+ if (recordTelemetry && projectRoot) {
488
+ const metrics = {
489
+ sessionId,
490
+ outcome,
491
+ cycles: cyclesLog.length,
492
+ stalls,
493
+ tridentInvocations,
494
+ hypothesesAdded,
495
+ hypothesesCompetingCount: workingHypotheses.filter((h) => typeof h?.from === 'string' && h.from.startsWith('trident:')).length,
496
+ resolved: outcome === DEBUG_OUTCOMES.RESOLVED,
497
+ resolutionLens,
498
+ durationMs: Date.now() - t0,
499
+ runStamp: _runStamp,
500
+ };
501
+ const dedupKey = `debug-campaign:${sessionId}:${_runStamp}`;
502
+ try {
503
+ await stateQuery('telemetry.record', {
504
+ kind: 'debug-campaign',
505
+ metrics,
506
+ dedupKey,
507
+ }, { projectRoot });
508
+ } catch {
509
+ // Swallow — telemetry never alters campaign verdict.
510
+ }
511
+ }
512
+
513
+ return {
514
+ sessionId,
515
+ symptoms,
516
+ outcome,
517
+ cycles: cyclesLog.length,
518
+ stalls,
519
+ tridentInvocations,
520
+ hypothesesAdded,
521
+ resolutionLens,
522
+ rootCause,
523
+ fix,
524
+ cyclesLog,
525
+ hypothesesFinal: workingHypotheses,
526
+ duration_ms: Date.now() - t0,
527
+ runStamp: _runStamp,
528
+ lastError,
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Merge a hypothesis-tree patch from the investigator. The patch is a list
534
+ * of rows: rows whose id matches an existing row REPLACE that row; rows
535
+ * whose id is new are APPENDED. A row with no id is appended with a fresh
536
+ * H<N> id. Stable, no-throw — a malformed patch entry is silently dropped.
537
+ */
538
+ export function mergeHypothesesPatch(existing, patch) {
539
+ const out = Array.isArray(existing) ? [...existing] : [];
540
+ if (!Array.isArray(patch)) return out;
541
+ const byId = new Map();
542
+ out.forEach((row, idx) => {
543
+ if (row && typeof row.id === 'string') byId.set(row.id, idx);
544
+ });
545
+ const idsTaken = out
546
+ .map((r) => (r && typeof r.id === 'string' ? r.id : ''))
547
+ .filter((s) => /^H\d+$/.test(s))
548
+ .map((s) => parseInt(s.slice(1), 10));
549
+ let nextId = (idsTaken.length === 0 ? 0 : Math.max(...idsTaken)) + 1;
550
+ for (const row of patch) {
551
+ if (!row || typeof row !== 'object') continue;
552
+ if (typeof row.id === 'string' && byId.has(row.id)) {
553
+ out[byId.get(row.id)] = { ...out[byId.get(row.id)], ...row };
554
+ } else if (typeof row.id === 'string') {
555
+ out.push({ ...row });
556
+ byId.set(row.id, out.length - 1);
557
+ // Advance nextId past any explicitly-appended H<N> id so that
558
+ // subsequent auto-id rows don't collide with it.
559
+ if (/^H\d+$/.test(row.id)) {
560
+ const n = parseInt(row.id.slice(1), 10);
561
+ if (n >= nextId) nextId = n + 1;
562
+ }
563
+ } else {
564
+ const id = `H${nextId++}`;
565
+ out.push({ id, ...row });
566
+ byId.set(id, out.length - 1);
567
+ }
568
+ }
569
+ return out;
570
+ }