@ijfw/memory-server 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  23. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  24. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  26. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  27. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  28. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  29. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  47. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  48. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  49. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  50. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  52. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  53. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  54. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  68. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  69. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  71. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  72. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  73. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  74. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  92. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  93. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  94. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  95. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  96. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  97. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  98. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  99. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  115. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  116. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  117. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  118. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  119. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  120. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  121. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  122. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  123. package/package.json +6 -3
  124. package/src/active-extension-writer.js +144 -64
  125. package/src/api-client.js +43 -5
  126. package/src/audit-roster.js +80 -5
  127. package/src/blackboard.js +298 -6
  128. package/src/cli-run.js +33 -5
  129. package/src/codex-agents.js +96 -5
  130. package/src/cost/aggregator.js +39 -9
  131. package/src/cost/pricing.js +57 -0
  132. package/src/cost/readers/gemini.js +1 -1
  133. package/src/cross-audit-chunker.js +189 -0
  134. package/src/cross-dispatcher.js +124 -21
  135. package/src/cross-orchestrator-cli.js +754 -159
  136. package/src/cross-orchestrator.js +1065 -17
  137. package/src/cross-project-search.js +195 -9
  138. package/src/dashboard-client-waves.html +304 -0
  139. package/src/dashboard-client.html +5 -1
  140. package/src/dashboard-server.js +73 -0
  141. package/src/deploy-alerts.js +150 -0
  142. package/src/design/iframe-bridge.js +242 -0
  143. package/src/design-companion.js +144 -0
  144. package/src/dispatch/checkpoint-cli.js +97 -0
  145. package/src/dispatch/colon-syntax.js +81 -1
  146. package/src/dispatch/extension.js +26 -2
  147. package/src/dispatch/registry-cli.js +4 -1
  148. package/src/dispatch/wave-cli.js +201 -6
  149. package/src/dispatch/worktree-cli.js +40 -0
  150. package/src/dispatch-planner.js +97 -2
  151. package/src/dream/runner.mjs +47 -11
  152. package/src/dream/stage-runner.js +40 -0
  153. package/src/dream/state-file.js +102 -0
  154. package/src/extension-installer.js +70 -24
  155. package/src/extension-quota-tracker.js +4 -2
  156. package/src/extension-registry.js +289 -35
  157. package/src/feedback-detector.js +26 -0
  158. package/src/fs-lock.js +259 -7
  159. package/src/gate-result.js +95 -1
  160. package/src/hardware-signer.js +4 -2
  161. package/src/hero-line.js +86 -5
  162. package/src/intent-router.js +35 -0
  163. package/src/lib/a11y-contract.js +117 -0
  164. package/src/lib/atomic-io.js +29 -8
  165. package/src/lib/cache-keepalive.js +150 -0
  166. package/src/lib/jsonl-rotation.js +104 -0
  167. package/src/lib/lighthouse-pillar.js +121 -0
  168. package/src/lib/llm-call.js +121 -0
  169. package/src/lib/playwright-baseline.js +205 -0
  170. package/src/lib/rekor-bridge.js +221 -0
  171. package/src/lib/repo-map.js +392 -0
  172. package/src/lib/shasum-verify.js +164 -0
  173. package/src/lib/sketches-gc.js +132 -0
  174. package/src/lib/tmp-suffix.js +62 -0
  175. package/src/lib/ui-review-runner.js +595 -0
  176. package/src/lib/uispec-drift.js +301 -0
  177. package/src/lib/uispec-intake.js +381 -0
  178. package/src/lib/worktree-guards.js +118 -0
  179. package/src/lib/worktree-recovery.js +100 -0
  180. package/src/memory/auto-linker.js +267 -0
  181. package/src/memory/benchmark.js +498 -0
  182. package/src/memory/dedup.js +126 -0
  183. package/src/memory/embedding-cache.js +136 -0
  184. package/src/memory/fact-extractor.js +168 -0
  185. package/src/memory/fts5.js +65 -1
  186. package/src/memory/migration-runner.js +6 -1
  187. package/src/memory/migrations/004-bitemporal.js +91 -0
  188. package/src/memory/migrations/005-vector-cache.js +61 -0
  189. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  190. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  191. package/src/memory/migrations/008-write-provenance.js +41 -0
  192. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  193. package/src/memory/obsidian-parser.js +152 -0
  194. package/src/memory/query-dataview.js +86 -0
  195. package/src/memory/search.js +46 -15
  196. package/src/memory/temporal.js +529 -0
  197. package/src/memory/tokenize.js +10 -0
  198. package/src/memory-facts-handler.js +37 -0
  199. package/src/memory-feedback.js +260 -2
  200. package/src/model-refresh.js +292 -0
  201. package/src/observability/cost-anomaly.js +166 -0
  202. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  203. package/src/observability/trace-id.js +163 -0
  204. package/src/orchestrator/agents-md-blackboard.js +152 -0
  205. package/src/orchestrator/checkpoint-contract.md +140 -0
  206. package/src/orchestrator/debug-trident-trigger.js +374 -0
  207. package/src/orchestrator/debug-trident.js +570 -0
  208. package/src/orchestrator/merge-block-aware.js +350 -0
  209. package/src/orchestrator/plan-checker.js +475 -0
  210. package/src/orchestrator/post-done-runner.js +277 -0
  211. package/src/orchestrator/review.js +38 -3
  212. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  213. package/src/orchestrator/skill-telemetry.js +37 -0
  214. package/src/orchestrator/state-events.js +459 -0
  215. package/src/orchestrator/state-sdk.js +1932 -0
  216. package/src/orchestrator/status-protocol.js +84 -17
  217. package/src/orchestrator/subagent-telemetry.js +471 -0
  218. package/src/orchestrator/termination.js +160 -0
  219. package/src/orchestrator/verification-gate.js +200 -16
  220. package/src/orchestrator/wave-state.js +332 -23
  221. package/src/orchestrator/worktree-provision.js +77 -0
  222. package/src/override-resolver.js +5 -3
  223. package/src/override-use-registry.js +111 -5
  224. package/src/receipts.js +36 -4
  225. package/src/recovery/checkpoint.js +56 -3
  226. package/src/recovery/code-fixer.js +961 -0
  227. package/src/recovery/truncation.js +317 -0
  228. package/src/redactor.js +75 -6
  229. package/src/runtime-mediator.js +15 -1
  230. package/src/sanitizer.js +10 -0
  231. package/src/search-hybrid.js +139 -0
  232. package/src/server.js +795 -112
  233. package/src/swarm/worktree.js +27 -4
  234. package/src/swarm-config.js +102 -17
  235. package/src/team/domain-templates/book.json +51 -0
  236. package/src/team/domain-templates/business.json +44 -0
  237. package/src/team/domain-templates/content.json +50 -0
  238. package/src/team/domain-templates/design.json +44 -0
  239. package/src/team/domain-templates/research.json +44 -0
  240. package/src/team/domain-templates/software.json +40 -0
  241. package/src/team/generator.js +440 -3
  242. package/src/team/modify.js +203 -0
  243. package/src/team/schemas.js +48 -0
  244. package/src/update-apply.js +19 -3
  245. package/src/dashboard-charts.js +0 -239
@@ -0,0 +1,961 @@
1
+ /**
2
+ * code-fixer.js — v1.5.0 T27 / G4: cross-AI consensus code-fixer loop.
3
+ *
4
+ * Wraps the ijfw-code-fixer agent (claude/agents/ijfw-code-fixer.md, T24) with
5
+ * the flagship Trident-verified per-finding atomic-commit loop. This is Moat
6
+ * #1 of the v1.5.0 gap-closure brief: review → fix → Trident-verify → atomic
7
+ * commit, with crash-safe sentinel recovery on every destructive boundary.
8
+ *
9
+ * Contract (per finding):
10
+ * 1. Triage — DEFER logic-bugs, ambiguous findings, stale findings.
11
+ * 2. Snapshot — capture pre-edit file content for rollback.
12
+ * 3. Apply — minimal Edit (`old_string` → `new_string` in target file).
13
+ * 4. Tier-1 — re-read; confirm change landed character-for-character.
14
+ * 5. Tier-2 — per-language syntax check (node/python/bash/json/tsc).
15
+ * 6. Tier-3 — optional fallback (project verify_cmd / package.json test).
16
+ * 7. Trident — run runPhaseEConverge over the edited file; require PASS.
17
+ * 8. Commit — one atomic commit per finding in the isolated worktree.
18
+ * ✗ ANY failure rolls back the edit and emits a structured failure record.
19
+ *
20
+ * Worktree lifecycle is delegated to `lib/worktree-recovery.js`'s sentinel
21
+ * pattern — if the process crashes mid-fix the next run prunes the dangling
22
+ * worktree and reports the survivor.
23
+ *
24
+ * WIRE-UP (v1.5.1 C2): the consensus entry point `runConsensusFix` is invoked
25
+ * by `cross-orchestrator.js#runPhaseEConverge` when its caller passes
26
+ * `autoFix: true`. After a non-PASS convergence the orchestrator extracts the
27
+ * HIGH findings that 2+ lenses agreed on (`consensusHighFindings`) and runs
28
+ * the atomic per-finding fix loop over them — the "2+ lenses agree → fixer
29
+ * fires automatically" contract from the T27 brief. This module is therefore
30
+ * a live, reachable production path, not an orphan.
31
+ *
32
+ * Zero new prod deps. ESM. Node ≥18.
33
+ */
34
+
35
+ import { existsSync, realpathSync } from 'node:fs';
36
+ import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
37
+ import {
38
+ join, extname, relative, isAbsolute, resolve as resolvePath, dirname,
39
+ } from 'node:path';
40
+ import { tmpdir } from 'node:os';
41
+ import { execFile, spawnSync } from 'node:child_process';
42
+ import { promisify } from 'node:util';
43
+
44
+ import { withRecoverySentinel } from '../lib/worktree-recovery.js';
45
+
46
+ const execFileAsync = promisify(execFile);
47
+
48
+ /* ────────────────────── R5-1.10: auto-fix safety boundary ────────────────── */
49
+
50
+ /**
51
+ * Default ceiling on the number of distinct files a single `runConsensusFix`
52
+ * call may write. Auto-fix is opt-in, but even when enabled it must not be
53
+ * able to mass-rewrite a repository — past this cap the loop stops and
54
+ * reports the remainder rather than continuing to mutate. Callers can lower
55
+ * (never silently raise past sanity) this via `maxAutoFixFiles`.
56
+ */
57
+ export const DEFAULT_MAX_AUTOFIX_FILES = 10;
58
+ // Hard ceiling — a caller cannot pass `maxAutoFixFiles` above this.
59
+ const MAX_AUTOFIX_FILES_CEILING = 50;
60
+
61
+ /**
62
+ * isPathContained(filePath, projectRoot) — true iff `filePath` resolves to a
63
+ * location at or under `projectRoot`. Symlinks are resolved on BOTH sides
64
+ * (realpath) so a symlink inside the repo that points outside it is rejected.
65
+ *
66
+ * Mirrors the containment prior art in cross-project-search.js#isUnder /
67
+ * safeResolveProjectPath: realpath the root, realpath the entry (falling back
68
+ * to the un-resolved absolute when the entry doesn't exist yet — e.g. a fix
69
+ * that would create a file — and in that case realpath the parent dir), then
70
+ * a trailing-separator boundary check so `/repo-evil` is NOT inside `/repo`.
71
+ *
72
+ * Returns { ok, reason, canonical? }.
73
+ */
74
+ export function isPathContained(filePath, projectRoot) {
75
+ if (!filePath || typeof filePath !== 'string') {
76
+ return { ok: false, reason: 'no-file-path' };
77
+ }
78
+ if (!projectRoot || typeof projectRoot !== 'string') {
79
+ return { ok: false, reason: 'no-project-root' };
80
+ }
81
+ // Canonicalise the root. If it doesn't resolve, fall back to absolute.
82
+ let canonRoot;
83
+ try {
84
+ canonRoot = realpathSync(resolvePath(projectRoot));
85
+ } catch {
86
+ canonRoot = resolvePath(projectRoot);
87
+ }
88
+ // Canonicalise the target. The file may not exist yet (a creating fix), so
89
+ // realpath the deepest existing ancestor and re-join the missing tail.
90
+ const absTarget = isAbsolute(filePath)
91
+ ? filePath
92
+ : resolvePath(canonRoot, filePath);
93
+ let canonTarget;
94
+ try {
95
+ canonTarget = realpathSync(absTarget);
96
+ } catch {
97
+ let probe = absTarget;
98
+ const tail = [];
99
+ // Walk up until we hit something that exists (or the fs root).
100
+ while (probe && !existsSync(probe) && dirname(probe) !== probe) {
101
+ tail.unshift(probe.slice(dirname(probe).length).replace(/^[\\/]/, ''));
102
+ probe = dirname(probe);
103
+ }
104
+ let canonProbe;
105
+ try { canonProbe = realpathSync(probe); }
106
+ catch { canonProbe = resolvePath(probe); }
107
+ canonTarget = tail.length ? join(canonProbe, ...tail) : canonProbe;
108
+ }
109
+ // Boundary check with trailing separator so siblings can't impersonate.
110
+ const rel = relative(canonRoot, canonTarget);
111
+ const escapes = rel === '..'
112
+ || rel.startsWith(`..${'/'}`) || rel.startsWith(`..${'\\'}`)
113
+ || isAbsolute(rel);
114
+ if (escapes) {
115
+ return {
116
+ ok: false,
117
+ reason: `path escapes project root (${canonTarget} not under ${canonRoot})`,
118
+ canonical: canonTarget,
119
+ };
120
+ }
121
+ return { ok: true, reason: '', canonical: canonTarget };
122
+ }
123
+
124
+ /* ────────────────────────────── status codes ────────────────────────────── */
125
+
126
+ export const STATUS = Object.freeze({
127
+ VERIFIED: 'VERIFIED',
128
+ DEFERRED: 'DEFERRED',
129
+ STALE: 'STALE',
130
+ VERIFY_FAIL: 'VERIFY_FAIL',
131
+ SYNTAX_FAIL: 'SYNTAX_FAIL',
132
+ FALLBACK_FAIL: 'FALLBACK_FAIL',
133
+ TRIDENT_FAIL: 'TRIDENT_FAIL',
134
+ COMMIT_FAIL: 'COMMIT_FAIL',
135
+ // R5-1.10 — finding's target file resolves outside the audited project
136
+ // root. The fixer refuses to touch it (path-containment guard).
137
+ OUT_OF_ROOT: 'OUT_OF_ROOT',
138
+ // R5-1.10 — the per-run change cap was reached; this finding (and any
139
+ // after it) was skipped without being applied.
140
+ CAP_REACHED: 'CAP_REACHED',
141
+ });
142
+
143
+ /* ────────────────────────────── logic-bug heuristic ─────────────────────── */
144
+
145
+ // Phrases that signal a logic bug a mechanical fixer can't responsibly patch.
146
+ // Lifted from the v1.5.0 G4 brief + the ijfw-code-fixer agent's "DO NOT" list.
147
+ const LOGIC_BUG_PHRASES = [
148
+ 'off-by-one',
149
+ 'off by one',
150
+ 'incorrect condition',
151
+ 'wrong condition',
152
+ 'wrong order',
153
+ 'wrong logic',
154
+ 'race condition',
155
+ 'business logic',
156
+ 'semantic bug',
157
+ 'edge case',
158
+ 'intent unclear',
159
+ 'may not be correct',
160
+ 'might be wrong',
161
+ ];
162
+
163
+ /**
164
+ * isLogicBug(finding) — return {logic: boolean, reason: string}.
165
+ *
166
+ * Two-layer check:
167
+ * 1. Explicit category tag (`category: logic-bug`) — exact match.
168
+ * 2. Substring scan of `description` against LOGIC_BUG_PHRASES.
169
+ *
170
+ * Conservative-by-design: false positives are cheap (we defer instead of
171
+ * patching), false negatives are expensive (we patch a real logic bug).
172
+ */
173
+ export function isLogicBug(finding) {
174
+ if (!finding || typeof finding !== 'object') {
175
+ return { logic: false, reason: '' };
176
+ }
177
+ const cat = String(finding.category || '').toLowerCase().trim();
178
+ if (cat === 'logic-bug' || cat === 'logic_bug' || cat === 'logic') {
179
+ return { logic: true, reason: `category=${cat}` };
180
+ }
181
+ const desc = String(finding.description || '').toLowerCase();
182
+ for (const phrase of LOGIC_BUG_PHRASES) {
183
+ if (desc.includes(phrase)) {
184
+ return { logic: true, reason: `phrase="${phrase}"` };
185
+ }
186
+ }
187
+ // `missing-await` with no concrete line/range = ambiguous → defer.
188
+ if (cat === 'missing-await' && (!finding.line || !finding.fix)) {
189
+ return { logic: true, reason: 'missing-await without concrete boundary' };
190
+ }
191
+ return { logic: false, reason: '' };
192
+ }
193
+
194
+ /* ────────────────────────────── triage ──────────────────────────────────── */
195
+
196
+ /**
197
+ * triage(finding) — pre-flight gate. Returns one of:
198
+ * { proceed: true } — go ahead and patch
199
+ * { proceed: false, status, reason } — short-circuit with DEFERRED|STALE
200
+ */
201
+ export function triage(finding) {
202
+ if (!finding || typeof finding !== 'object') {
203
+ return { proceed: false, status: STATUS.DEFERRED, reason: 'malformed-finding' };
204
+ }
205
+ if (!finding.file || typeof finding.file !== 'string') {
206
+ return { proceed: false, status: STATUS.DEFERRED, reason: 'no-file-path' };
207
+ }
208
+ // Logic bug → defer (the contract is explicit: humans only).
209
+ const lb = isLogicBug(finding);
210
+ if (lb.logic) {
211
+ return { proceed: false, status: STATUS.DEFERRED, reason: `logic-bug: ${lb.reason}` };
212
+ }
213
+ // Need a concrete edit operation. Either { fix: { old_string, new_string } }
214
+ // or a precise suggested_fix string we can wrap. Without it the fixer is
215
+ // guessing, and guessing is logic-bug territory.
216
+ const fix = finding.fix || finding.suggested_fix;
217
+ if (!fix || (typeof fix === 'object' && (!fix.old_string || fix.new_string === undefined))) {
218
+ return { proceed: false, status: STATUS.DEFERRED, reason: 'no-concrete-fix' };
219
+ }
220
+ return { proceed: true };
221
+ }
222
+
223
+ /* ────────────────────────────── tier 1 — re-read ────────────────────────── */
224
+
225
+ /**
226
+ * verifyTier1(filePath, newString) — confirm the edit actually landed.
227
+ * Returns { ok: boolean, evidence: string }.
228
+ */
229
+ export async function verifyTier1(filePath, newString) {
230
+ try {
231
+ const content = await readFile(filePath, 'utf8');
232
+ if (newString === '' || content.includes(newString)) {
233
+ return { ok: true, evidence: '' };
234
+ }
235
+ return {
236
+ ok: false,
237
+ evidence: `tier-1: expected substring not present in ${filePath}`,
238
+ };
239
+ } catch (err) {
240
+ return { ok: false, evidence: `tier-1: read failed: ${err.message}` };
241
+ }
242
+ }
243
+
244
+ /* ────────────────────────────── tier 2 — syntax check ───────────────────── */
245
+
246
+ /**
247
+ * tier2SyntaxCheckCmd(filePath) — return the per-language check command, or
248
+ * null if the extension isn't on the supported list (caller SKIPs tier 2).
249
+ *
250
+ * Per ijfw-code-fixer.md contract:
251
+ * .js/.mjs/.cjs → node --check
252
+ * .ts/.tsx → tsc --noEmit --allowJs (only if tsc on PATH; else SKIP)
253
+ * .py → python3 -m py_compile
254
+ * .json → node -e JSON.parse
255
+ * .sh/.bash → bash -n
256
+ * others → SKIP
257
+ */
258
+ export function tier2SyntaxCheckCmd(filePath) {
259
+ const ext = extname(filePath).toLowerCase();
260
+ switch (ext) {
261
+ case '.js':
262
+ case '.mjs':
263
+ case '.cjs':
264
+ return { cmd: 'node', args: ['--check', filePath] };
265
+ case '.json':
266
+ return {
267
+ cmd: 'node',
268
+ args: [
269
+ '-e',
270
+ `JSON.parse(require('fs').readFileSync(${JSON.stringify(filePath)},'utf8'))`,
271
+ ],
272
+ };
273
+ case '.py':
274
+ return { cmd: 'python3', args: ['-m', 'py_compile', filePath] };
275
+ case '.sh':
276
+ case '.bash':
277
+ return { cmd: 'bash', args: ['-n', filePath] };
278
+ case '.ts':
279
+ case '.tsx': {
280
+ // Only if tsc on PATH. The agent contract says SKIP when absent.
281
+ const which = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['tsc'], {
282
+ encoding: 'utf8',
283
+ });
284
+ if (which.status === 0 && which.stdout.trim()) {
285
+ return { cmd: 'tsc', args: ['--noEmit', '--allowJs', filePath] };
286
+ }
287
+ return null;
288
+ }
289
+ default:
290
+ return null;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * verifyTier2(filePath) — per-language syntax check. Returns one of:
296
+ * { ok: true, skipped: false }
297
+ * { ok: true, skipped: true } — extension not on the list
298
+ * { ok: false, evidence: string } — syntax error captured
299
+ */
300
+ export async function verifyTier2(filePath) {
301
+ const spec = tier2SyntaxCheckCmd(filePath);
302
+ if (!spec) return { ok: true, skipped: true };
303
+ try {
304
+ await execFileAsync(spec.cmd, spec.args, { timeout: 15_000 });
305
+ return { ok: true, skipped: false };
306
+ } catch (err) {
307
+ const stderr = err.stderr || err.stdout || err.message || '';
308
+ return {
309
+ ok: false,
310
+ evidence: `tier-2 (${spec.cmd}): ${String(stderr).split('\n').slice(0, 5).join('\n')}`,
311
+ };
312
+ }
313
+ }
314
+
315
+ /* ────────────────────────────── tier 3 — fallback project verify ────────── */
316
+
317
+ /**
318
+ * resolveProjectVerifyCmd(projectRoot, verifyCmdOverride) — pick the verify
319
+ * command. Priority: explicit override → package.json.scripts.test → Makefile
320
+ * `test:` target → null (caller SKIPs tier 3).
321
+ */
322
+ async function resolveProjectVerifyCmd(projectRoot, verifyCmdOverride) {
323
+ if (verifyCmdOverride && typeof verifyCmdOverride === 'string' && verifyCmdOverride.trim()) {
324
+ return verifyCmdOverride.trim();
325
+ }
326
+ // package.json.scripts.test
327
+ const pkgPath = join(projectRoot, 'package.json');
328
+ if (existsSync(pkgPath)) {
329
+ try {
330
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
331
+ if (pkg.scripts && typeof pkg.scripts.test === 'string' && pkg.scripts.test.trim()) {
332
+ return 'npm test --silent';
333
+ }
334
+ } catch { /* swallow malformed package.json */ }
335
+ }
336
+ // Makefile test target
337
+ const mkPath = join(projectRoot, 'Makefile');
338
+ if (existsSync(mkPath)) {
339
+ try {
340
+ const mk = await readFile(mkPath, 'utf8');
341
+ if (/^test\s*:/m.test(mk)) return 'make test';
342
+ } catch { /* swallow */ }
343
+ }
344
+ return null;
345
+ }
346
+
347
+ /**
348
+ * verifyTier3(projectRoot, verifyCmdOverride) — run the project's documented
349
+ * verify command. Returns one of:
350
+ * { ok: true, skipped: false }
351
+ * { ok: true, skipped: true } — no command discovered
352
+ * { ok: false, evidence: string } — first 20 lines of failure output
353
+ */
354
+ export async function verifyTier3(projectRoot, verifyCmdOverride) {
355
+ const cmd = await resolveProjectVerifyCmd(projectRoot, verifyCmdOverride);
356
+ if (!cmd) return { ok: true, skipped: true };
357
+ // Run the command via `sh -c` so script lines like `npm test --silent` work
358
+ // verbatim. Timeout is generous (5 min) because real test suites can be slow.
359
+ return new Promise((resolve) => {
360
+ execFile('sh', ['-c', cmd], { cwd: projectRoot, timeout: 5 * 60_000 }, (err, stdout, stderr) => {
361
+ if (!err) return resolve({ ok: true, skipped: false });
362
+ const blob = String(stderr || stdout || err.message || '');
363
+ const evidence = blob.split('\n').slice(0, 20).join('\n');
364
+ resolve({ ok: false, evidence: `tier-3 (${cmd}): ${evidence}` });
365
+ });
366
+ });
367
+ }
368
+
369
+ /* ────────────────────────────── trident verify ──────────────────────────── */
370
+
371
+ /**
372
+ * runTridentVerify({ commitRange, dispatch, projectRoot, lenses }) — wrap
373
+ * cross-orchestrator's runPhaseEConverge with the code-fixer's PASS-required
374
+ * contract. The verdict shape from runPhaseEConverge is:
375
+ * { verdict: 'PASS' | 'consensus_failed' | ..., iterations, findings, ... }
376
+ *
377
+ * The fixer only commits on `verdict === 'PASS'`. Anything else rolls back.
378
+ *
379
+ * `dispatch` is injectable (and required) so tests can drive scripted lens
380
+ * responses without spawning the real codex/gemini/claude CLIs.
381
+ */
382
+ export async function runTridentVerify({
383
+ commitRange,
384
+ dispatch,
385
+ projectRoot,
386
+ lenses,
387
+ maxIterations = 3,
388
+ }) {
389
+ if (typeof dispatch !== 'function') {
390
+ return {
391
+ verdict: 'TRIDENT_DISPATCH_MISSING',
392
+ passed: false,
393
+ evidence: 'no dispatch function supplied',
394
+ };
395
+ }
396
+ // Lazy-import to keep this module loadable in environments where
397
+ // cross-orchestrator's transitive deps (state-sdk, receipts) aren't wired.
398
+ // Test fixtures can always supply a custom `dispatch`, so the orchestrator
399
+ // call works against scripted lens responses with no real CLI spawn.
400
+ const { runPhaseEConverge } = await import('../cross-orchestrator.js');
401
+ const result = await runPhaseEConverge({
402
+ commitRange: commitRange || 'HEAD~1..HEAD',
403
+ dispatch,
404
+ lenses: Array.isArray(lenses) && lenses.length ? lenses : undefined,
405
+ maxIterations,
406
+ projectRoot,
407
+ projectDir: projectRoot,
408
+ });
409
+ return {
410
+ verdict: result.verdict,
411
+ passed: result.verdict === 'PASS',
412
+ iterations: result.iterations,
413
+ evidence: result.verdict === 'PASS' ? '' : `trident: verdict=${result.verdict}`,
414
+ raw: result,
415
+ };
416
+ }
417
+
418
+ /* ────────────────────────────── git helpers ─────────────────────────────── */
419
+
420
+ function git(cwd, args, { allowFail = false } = {}) {
421
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
422
+ if (res.status !== 0 && !allowFail) {
423
+ const err = new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`);
424
+ err.stdout = res.stdout;
425
+ err.stderr = res.stderr;
426
+ throw err;
427
+ }
428
+ return { status: res.status, stdout: res.stdout, stderr: res.stderr };
429
+ }
430
+
431
+ /**
432
+ * atomicCommit({ projectRoot, file, finding }) — stage the one edited file +
433
+ * commit. Per-finding atomicity is the wrapping-loop guarantee from the agent
434
+ * contract; bundling defeats the rollback story.
435
+ *
436
+ * Returns { ok: boolean, sha?: string, evidence?: string }.
437
+ */
438
+ export function atomicCommit({ projectRoot, file, finding }) {
439
+ try {
440
+ // Explicit-file stage; never `git add -A` (catches stray edits).
441
+ const rel = isAbsolute(file) ? relative(projectRoot, file) : file;
442
+ git(projectRoot, ['add', '--', rel]);
443
+ const id = finding.finding_id || finding.id || 'unknown';
444
+ const sev = (finding.severity || 'unknown').toUpperCase();
445
+ const desc = String(finding.description || '').split('\n')[0].slice(0, 120);
446
+ const msg = `fix(code-fixer): ${id} [${sev}] ${desc}`.trim();
447
+ git(projectRoot, ['commit', '-m', msg]);
448
+ const sha = git(projectRoot, ['rev-parse', 'HEAD']).stdout.trim();
449
+ return { ok: true, sha };
450
+ } catch (err) {
451
+ return { ok: false, evidence: err.message };
452
+ }
453
+ }
454
+
455
+ /* ────────────────────────────── apply + rollback ────────────────────────── */
456
+
457
+ /**
458
+ * applyEdit(filePath, fix) — one minimal substitution, captures pre-edit
459
+ * content for rollback. Supports the two fix shapes the contract recognises:
460
+ * { old_string, new_string } — exact substring substitution
461
+ * string — interpreted as the new full content (rare)
462
+ */
463
+ async function applyEdit(filePath, fix) {
464
+ const before = await readFile(filePath, 'utf8');
465
+ let after;
466
+ if (typeof fix === 'object' && fix.old_string !== undefined) {
467
+ if (!before.includes(fix.old_string)) {
468
+ return { ok: false, evidence: 'old_string not found in file', before };
469
+ }
470
+ // Exactly-one occurrence guarantee — same rule the Edit tool uses.
471
+ const occurrences = before.split(fix.old_string).length - 1;
472
+ if (occurrences > 1 && !fix.replace_all) {
473
+ return { ok: false, evidence: `old_string occurs ${occurrences}×; ambiguous`, before };
474
+ }
475
+ after = fix.replace_all
476
+ ? before.split(fix.old_string).join(fix.new_string)
477
+ : before.replace(fix.old_string, fix.new_string);
478
+ } else if (typeof fix === 'string') {
479
+ after = fix;
480
+ } else {
481
+ return { ok: false, evidence: 'unsupported fix shape', before };
482
+ }
483
+ if (after === before) {
484
+ return { ok: false, evidence: 'edit was a no-op (after === before)', before };
485
+ }
486
+ await writeFile(filePath, after, 'utf8');
487
+ return { ok: true, before, after };
488
+ }
489
+
490
+ async function rollback(filePath, originalContent) {
491
+ try {
492
+ await writeFile(filePath, originalContent, 'utf8');
493
+ return { ok: true };
494
+ } catch (err) {
495
+ return { ok: false, error: err.message };
496
+ }
497
+ }
498
+
499
+ /* ────────────────────────────── main entry point ────────────────────────── */
500
+
501
+ /**
502
+ * fixFinding({ finding, projectRoot, dispatch, verifyCmd, dryRun, lenses,
503
+ * commitRange, maxConvergeIter, skipTrident, skipCommit })
504
+ *
505
+ * The flagship loop. Returns a structured record:
506
+ * {
507
+ * status: STATUS.*,
508
+ * tier_reached: 1 | 2 | 3 | 'trident' | 'commit' | 'n/a',
509
+ * finding_id, file, evidence?, sha?, trident?
510
+ * }
511
+ *
512
+ * `dispatch` is required for Trident verify; tests inject scripted responses.
513
+ * `skipTrident` is for unit-test-only flows that exercise the 3-tier matrix
514
+ * in isolation; production callers MUST run Trident.
515
+ *
516
+ * `skipCommit` is for dry-run / unit-test flows; production callers MUST
517
+ * commit (the recovery-sentinel is wrapped around the commit boundary).
518
+ */
519
+ export async function fixFinding({
520
+ finding,
521
+ projectRoot,
522
+ dispatch,
523
+ verifyCmd,
524
+ dryRun = false,
525
+ lenses,
526
+ commitRange,
527
+ maxConvergeIter = 3,
528
+ skipTrident = false,
529
+ skipCommit = false,
530
+ } = {}) {
531
+ const findingId = finding?.finding_id || finding?.id || 'unknown';
532
+ const base = { finding_id: findingId, file: finding?.file };
533
+
534
+ // 1. triage
535
+ const t = triage(finding);
536
+ if (!t.proceed) {
537
+ return { ...base, status: t.status, tier_reached: 'n/a', deferred_reason: t.reason };
538
+ }
539
+
540
+ // 2. confirm target exists + snapshot
541
+ const root = projectRoot || process.cwd();
542
+ const filePath = isAbsolute(finding.file)
543
+ ? finding.file
544
+ : resolvePath(root, finding.file);
545
+
546
+ // R5-1.10 — PATH CONTAINMENT. Auto-fix mutates the working tree; it must
547
+ // only ever touch files inside the project root being audited. A finding
548
+ // whose `file` resolves outside the root (absolute escape, `../` traversal,
549
+ // or a symlink pointing out) is REFUSED before any read/write happens.
550
+ // This is checked before existsSync so an out-of-root path can't even be
551
+ // probed for existence.
552
+ const contained = isPathContained(filePath, root);
553
+ if (!contained.ok) {
554
+ return {
555
+ ...base,
556
+ status: STATUS.OUT_OF_ROOT,
557
+ tier_reached: 'n/a',
558
+ evidence: `auto-fix refused: ${contained.reason}`,
559
+ };
560
+ }
561
+
562
+ if (!existsSync(filePath)) {
563
+ return { ...base, status: STATUS.STALE, tier_reached: 'n/a',
564
+ evidence: `target file does not exist: ${filePath}` };
565
+ }
566
+
567
+ if (dryRun) {
568
+ return { ...base, status: STATUS.DEFERRED, tier_reached: 'n/a',
569
+ deferred_reason: 'dry-run' };
570
+ }
571
+
572
+ // 3. apply
573
+ const fix = finding.fix || finding.suggested_fix;
574
+ const edit = await applyEdit(filePath, fix);
575
+ if (!edit.ok) {
576
+ return { ...base, status: STATUS.VERIFY_FAIL, tier_reached: 1, evidence: edit.evidence };
577
+ }
578
+ const originalContent = edit.before;
579
+ const expectedNewString = typeof fix === 'object' ? fix.new_string : null;
580
+
581
+ // 4. tier 1 — re-read
582
+ const t1 = await verifyTier1(
583
+ filePath,
584
+ expectedNewString !== null && expectedNewString !== undefined ? expectedNewString : '',
585
+ );
586
+ if (!t1.ok) {
587
+ await rollback(filePath, originalContent);
588
+ return { ...base, status: STATUS.VERIFY_FAIL, tier_reached: 1, evidence: t1.evidence };
589
+ }
590
+
591
+ // 5. tier 2 — syntax check (per-language)
592
+ const t2 = await verifyTier2(filePath);
593
+ if (!t2.ok) {
594
+ await rollback(filePath, originalContent);
595
+ return { ...base, status: STATUS.SYNTAX_FAIL, tier_reached: 2, evidence: t2.evidence };
596
+ }
597
+
598
+ // 6. tier 3 — fallback project verify
599
+ // Only run if tier 2 passed (or skipped). If tier 2 was non-skipped + passed
600
+ // we still run tier 3 as a deeper net.
601
+ const t3 = await verifyTier3(projectRoot || process.cwd(), verifyCmd);
602
+ if (!t3.ok) {
603
+ await rollback(filePath, originalContent);
604
+ return { ...base, status: STATUS.FALLBACK_FAIL, tier_reached: 3, evidence: t3.evidence };
605
+ }
606
+
607
+ // 7. trident verify
608
+ let tridentRec = null;
609
+ if (!skipTrident) {
610
+ const trident = await runTridentVerify({
611
+ commitRange,
612
+ dispatch,
613
+ projectRoot: projectRoot || process.cwd(),
614
+ lenses,
615
+ maxIterations: maxConvergeIter,
616
+ });
617
+ tridentRec = {
618
+ verdict: trident.verdict,
619
+ iterations: trident.iterations,
620
+ passed: trident.passed,
621
+ };
622
+ if (!trident.passed) {
623
+ await rollback(filePath, originalContent);
624
+ return {
625
+ ...base,
626
+ status: STATUS.TRIDENT_FAIL,
627
+ tier_reached: 'trident',
628
+ evidence: trident.evidence,
629
+ trident: tridentRec,
630
+ };
631
+ }
632
+ }
633
+
634
+ // 8. atomic commit (sentinel-wrapped — the destructive crash window).
635
+ if (skipCommit) {
636
+ return {
637
+ ...base,
638
+ status: STATUS.VERIFIED,
639
+ tier_reached: skipTrident ? 3 : 'trident',
640
+ trident: tridentRec,
641
+ };
642
+ }
643
+
644
+ try {
645
+ const sentinelData = {
646
+ op: 'code-fixer-commit',
647
+ finding_id: findingId,
648
+ file: filePath,
649
+ worktreePath: projectRoot, // re-used for surface compat with recoverPending
650
+ };
651
+ const commitRes = await withRecoverySentinel(
652
+ sentinelData,
653
+ async () => atomicCommit({ projectRoot, file: filePath, finding }),
654
+ projectRoot || process.cwd(),
655
+ );
656
+ if (!commitRes.ok) {
657
+ await rollback(filePath, originalContent);
658
+ return {
659
+ ...base,
660
+ status: STATUS.COMMIT_FAIL,
661
+ tier_reached: 'commit',
662
+ evidence: commitRes.evidence,
663
+ trident: tridentRec,
664
+ };
665
+ }
666
+ return {
667
+ ...base,
668
+ status: STATUS.VERIFIED,
669
+ tier_reached: skipTrident ? 3 : 'trident',
670
+ sha: commitRes.sha,
671
+ trident: tridentRec,
672
+ };
673
+ } catch (err) {
674
+ // Sentinel remains on disk for next-run recovery (worktree-recovery.js).
675
+ await rollback(filePath, originalContent);
676
+ return {
677
+ ...base,
678
+ status: STATUS.COMMIT_FAIL,
679
+ tier_reached: 'commit',
680
+ evidence: `sentinel-wrapped commit threw: ${err.message}`,
681
+ sentinel_path: err.sentinelPath,
682
+ trident: tridentRec,
683
+ };
684
+ }
685
+ }
686
+
687
+ /* ────────────────────────────── batch runner ────────────────────────────── */
688
+
689
+ /**
690
+ * fixFindings(findings, opts) — sequential per-finding atomic loop.
691
+ *
692
+ * Sequential is intentional: each fix mutates the same worktree HEAD and
693
+ * the Trident step needs a stable commit range. Parallel fixes would shred
694
+ * the atomicity guarantee.
695
+ *
696
+ * R5-1.10 — CHANGE CAP. `opts.maxAutoFixFiles` (default
697
+ * DEFAULT_MAX_AUTOFIX_FILES = 10, hard-ceilinged at 50) bounds the number of
698
+ * DISTINCT files this batch may successfully apply a fix to. Once that many
699
+ * files have been touched, every remaining finding that targets a not-yet-
700
+ * seen file is short-circuited with status CAP_REACHED (no read, no write) —
701
+ * the loop stops mutating and reports the remainder instead of mass-
702
+ * rewriting the repo. Findings that re-target an already-fixed file are
703
+ * still allowed through (they don't grow the blast radius). Statuses that
704
+ * don't write a file (DEFERRED / STALE / OUT_OF_ROOT / *_FAIL) never count
705
+ * against the cap.
706
+ *
707
+ * Returns { results: Array<fixFinding-record>, summary: { verified, deferred,
708
+ * stale, verify_fail, syntax_fail, fallback_fail, trident_fail,
709
+ * commit_fail, out_of_root, cap_reached }, capped: boolean,
710
+ * filesTouched: number, maxAutoFixFiles: number }.
711
+ */
712
+ export async function fixFindings(findings, opts = {}) {
713
+ const results = [];
714
+ const summary = {
715
+ verified: 0, deferred: 0, stale: 0,
716
+ verify_fail: 0, syntax_fail: 0, fallback_fail: 0,
717
+ trident_fail: 0, commit_fail: 0,
718
+ out_of_root: 0, cap_reached: 0,
719
+ };
720
+
721
+ // Resolve the per-run change cap. Clamp to [1, MAX_AUTOFIX_FILES_CEILING];
722
+ // a non-positive or non-numeric value falls back to the default.
723
+ const reqCap = Number(opts.maxAutoFixFiles);
724
+ const cap = Number.isFinite(reqCap) && reqCap > 0
725
+ ? Math.min(Math.floor(reqCap), MAX_AUTOFIX_FILES_CEILING)
726
+ : DEFAULT_MAX_AUTOFIX_FILES;
727
+
728
+ // Distinct files this batch has successfully written to. A fix counts as
729
+ // "touching" a file only if it actually applied an edit — VERIFIED or any
730
+ // failure mode that happens AFTER applyEdit (VERIFY_FAIL/SYNTAX_FAIL/
731
+ // FALLBACK_FAIL/TRIDENT_FAIL/COMMIT_FAIL all roll the file back, but the
732
+ // file WAS written then reverted, so they still count toward blast radius).
733
+ const APPLIED = new Set([
734
+ STATUS.VERIFIED, STATUS.VERIFY_FAIL, STATUS.SYNTAX_FAIL,
735
+ STATUS.FALLBACK_FAIL, STATUS.TRIDENT_FAIL, STATUS.COMMIT_FAIL,
736
+ ]);
737
+ const filesTouched = new Set();
738
+ let capped = false;
739
+
740
+ for (const finding of (findings || [])) {
741
+ const targetFile = finding && typeof finding.file === 'string'
742
+ ? finding.file : null;
743
+ const alreadyTouched = targetFile && filesTouched.has(targetFile);
744
+
745
+ // Cap gate: if we've hit the ceiling AND this finding would touch a NEW
746
+ // file, refuse it without reading/writing anything.
747
+ if (filesTouched.size >= cap && !alreadyTouched) {
748
+ capped = true;
749
+ results.push({
750
+ finding_id: finding?.finding_id || finding?.id || 'unknown',
751
+ file: targetFile,
752
+ status: STATUS.CAP_REACHED,
753
+ tier_reached: 'n/a',
754
+ evidence: `auto-fix change cap reached (${cap} files); skipped without applying`,
755
+ });
756
+ summary.cap_reached += 1;
757
+ continue;
758
+ }
759
+
760
+ // eslint-disable-next-line no-await-in-loop -- sequential is the contract
761
+ const r = await fixFinding({ ...opts, finding });
762
+ results.push(r);
763
+ const k = String(r.status || '').toLowerCase();
764
+ if (k in summary) summary[k] += 1;
765
+
766
+ // Record blast radius: any status that got past applyEdit touched a file.
767
+ if (APPLIED.has(r.status) && r.file) {
768
+ filesTouched.add(r.file);
769
+ }
770
+ }
771
+ return {
772
+ results,
773
+ summary,
774
+ capped,
775
+ filesTouched: filesTouched.size,
776
+ maxAutoFixFiles: cap,
777
+ };
778
+ }
779
+
780
+ /* ────────────────────── consensus-HIGH extraction (T27 wire-up) ──────────── */
781
+
782
+ /**
783
+ * Normalise a finding's severity to a lowercase canonical token.
784
+ * Auditors emit `high` / `HIGH` / `High` / sometimes `severity: { level }`.
785
+ */
786
+ function _severityOf(finding) {
787
+ if (!finding || typeof finding !== 'object') return '';
788
+ const raw = finding.severity ?? finding.level ?? '';
789
+ if (raw && typeof raw === 'object') {
790
+ return String(raw.level || raw.severity || '').toLowerCase().trim();
791
+ }
792
+ return String(raw).toLowerCase().trim();
793
+ }
794
+
795
+ /**
796
+ * A stable identity key for cross-lens finding agreement. Two lenses "agree"
797
+ * on the same HIGH when their findings collapse to the same key. We use the
798
+ * file path + a normalised description prefix (whitespace-folded, lowercased,
799
+ * first 80 chars) — precise enough to cluster genuine duplicates, loose enough
800
+ * to survive trivial wording drift between lenses.
801
+ */
802
+ function _consensusKey(finding) {
803
+ const file = String(finding.file || finding.location || finding.path || '').trim();
804
+ const descSource =
805
+ finding.description || finding.issue || finding.text ||
806
+ finding.message || finding.finding || finding.detail || '';
807
+ const desc = String(descSource).toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 80);
808
+ return `${file}::${desc}`;
809
+ }
810
+
811
+ /**
812
+ * consensusHighFindings(perIteration, opts) — given the `perIteration` array
813
+ * that `runPhaseEConverge` returns, extract the HIGH-severity findings on
814
+ * which `minLenses` (default 2) or more lenses agree.
815
+ *
816
+ * This is the bridge T27 was designed for: "when 2+ lenses agree on the same
817
+ * HIGH, the fixer fires automatically." The convergence loop produces
818
+ * `perIteration[*].lensResults[*].findings`; this collapses the final
819
+ * iteration's findings into per-lens-deduped consensus clusters.
820
+ *
821
+ * Returns Array<finding> — each carries `_consensusLenses` (the set of lens
822
+ * ids that flagged it) and `_consensusCount`. Only the LAST iteration is
823
+ * considered: convergence already re-evaluated earlier rounds, so the final
824
+ * round is the swarm's settled position.
825
+ */
826
+ export function consensusHighFindings(perIteration, opts = {}) {
827
+ const minLenses = Number.isInteger(opts.minLenses) && opts.minLenses > 0
828
+ ? opts.minLenses
829
+ : 2;
830
+ if (!Array.isArray(perIteration) || perIteration.length === 0) return [];
831
+ const last = perIteration[perIteration.length - 1];
832
+ if (!last || !Array.isArray(last.lensResults)) return [];
833
+
834
+ // key → { finding, lenses:Set }
835
+ const clusters = new Map();
836
+ for (const lr of last.lensResults) {
837
+ const lens = lr && lr.lens ? lr.lens : 'unknown';
838
+ const findings = Array.isArray(lr && lr.findings) ? lr.findings : [];
839
+ // De-dup within a single lens first so one lens flagging the same issue
840
+ // twice can't manufacture false consensus.
841
+ const seenThisLens = new Set();
842
+ for (const f of findings) {
843
+ if (_severityOf(f) !== 'high' && _severityOf(f) !== 'critical') continue;
844
+ const key = _consensusKey(f);
845
+ if (seenThisLens.has(key)) continue;
846
+ seenThisLens.add(key);
847
+ if (!clusters.has(key)) clusters.set(key, { finding: f, lenses: new Set() });
848
+ clusters.get(key).lenses.add(lens);
849
+ }
850
+ }
851
+
852
+ const out = [];
853
+ for (const { finding, lenses } of clusters.values()) {
854
+ if (lenses.size >= minLenses) {
855
+ out.push({ ...finding, _consensusLenses: [...lenses], _consensusCount: lenses.size });
856
+ }
857
+ }
858
+ return out;
859
+ }
860
+
861
+ /**
862
+ * runConsensusFix({ perIteration, projectRoot, dispatch, ...fixOpts }) —
863
+ * the T27 auto-fix entry point. Extracts consensus HIGHs from a completed
864
+ * `runPhaseEConverge` run and runs the per-finding atomic fixer loop over
865
+ * them.
866
+ *
867
+ * R5-1.10 SAFETY BOUNDARY — auto-fix mutates code, so two hard guards apply
868
+ * (both inherited from `fixFindings` / `fixFinding`, surfaced here):
869
+ * • Path containment — any finding whose target file resolves outside
870
+ * `projectRoot` is REFUSED (status OUT_OF_ROOT); the fixer can never
871
+ * write outside the audited project.
872
+ * • Change cap — `maxAutoFixFiles` (default 10, ceiling 50) bounds the
873
+ * distinct files a single run may touch; beyond it the loop STOPS and
874
+ * reports the remainder (status CAP_REACHED) instead of mass-rewriting.
875
+ * Pass `dryRun: true` for detect-only (reports what it WOULD fix, no edits).
876
+ *
877
+ * Returns:
878
+ * { triggered: false, reason } — nothing to fix
879
+ * { triggered: true, consensusCount, results, summary, capped,
880
+ * filesTouched, maxAutoFixFiles } — fixer ran
881
+ *
882
+ * `dispatch` is required so each fix's Trident re-verify can run.
883
+ */
884
+ export async function runConsensusFix({
885
+ perIteration,
886
+ projectRoot,
887
+ dispatch,
888
+ minLenses = 2,
889
+ ...fixOpts
890
+ } = {}) {
891
+ const consensus = consensusHighFindings(perIteration, { minLenses });
892
+ if (consensus.length === 0) {
893
+ return { triggered: false, reason: 'no consensus HIGH findings' };
894
+ }
895
+ const { results, summary, capped, filesTouched, maxAutoFixFiles } =
896
+ await fixFindings(consensus, {
897
+ ...fixOpts,
898
+ projectRoot,
899
+ dispatch,
900
+ });
901
+ return {
902
+ triggered: true,
903
+ consensusCount: consensus.length,
904
+ results,
905
+ summary,
906
+ capped,
907
+ filesTouched,
908
+ maxAutoFixFiles,
909
+ };
910
+ }
911
+
912
+ /* ────────────────────────────── test helpers ────────────────────────────── */
913
+
914
+ /**
915
+ * _makeTridentDispatch — convenience builder for unit + e2e tests.
916
+ *
917
+ * Mode 'pass' → every lens returns PASS, empty findings, first iter.
918
+ * Mode 'fail-then-pass' → first iter FAIL on one lens, second PASS.
919
+ * Mode 'fail' → every iter has codex FAIL; convergence stalls.
920
+ *
921
+ * Production callers MUST inject a real lens dispatcher. This is exported
922
+ * (underscore-prefixed) only for the test harness.
923
+ */
924
+ export function _makeTridentDispatch(mode = 'pass') {
925
+ const scripts = {
926
+ pass: () => ({ verdict: 'PASS', findings: [] }),
927
+ };
928
+ if (mode === 'pass') {
929
+ return async ({ lens }) => ({ lens, ...scripts.pass() });
930
+ }
931
+ if (mode === 'fail-then-pass') {
932
+ return async ({ lens, iteration }) => {
933
+ if (lens === 'codex' && iteration === 1) {
934
+ return { lens, verdict: 'FAIL', findings: [{ severity: 'high', text: 'demo' }] };
935
+ }
936
+ return { lens, verdict: 'PASS', findings: [] };
937
+ };
938
+ }
939
+ if (mode === 'fail') {
940
+ let counter = 0;
941
+ return async ({ lens }) => {
942
+ counter += 1;
943
+ if (lens === 'codex') {
944
+ return { lens, verdict: 'FAIL', findings: [{ severity: 'high', text: `t-${counter}` }] };
945
+ }
946
+ return { lens, verdict: 'PASS', findings: [{ text: `c-${counter}` }] };
947
+ };
948
+ }
949
+ throw new Error(`_makeTridentDispatch: unknown mode "${mode}"`);
950
+ }
951
+
952
+ /**
953
+ * _freshTmpRoot(prefix) — mkdtemp helper for tests; returns absolute path.
954
+ */
955
+ export async function _freshTmpRoot(prefix = 'ijfw-code-fixer-test-') {
956
+ return mkdtemp(join(tmpdir(), prefix));
957
+ }
958
+
959
+ export async function _cleanupTmpRoot(root) {
960
+ try { await rm(root, { recursive: true, force: true }); } catch { /* swallow */ }
961
+ }