@ijfw/memory-server 1.4.4 → 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 (232) 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 +1016 -17
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-waves.html +304 -0
  131. package/src/dashboard-client.html +5 -1
  132. package/src/dashboard-server.js +73 -0
  133. package/src/deploy-alerts.js +150 -0
  134. package/src/design/iframe-bridge.js +242 -0
  135. package/src/design-companion.js +144 -0
  136. package/src/dispatch/checkpoint-cli.js +97 -0
  137. package/src/dispatch/colon-syntax.js +81 -1
  138. package/src/dispatch/extension.js +26 -2
  139. package/src/dispatch/registry-cli.js +4 -1
  140. package/src/dispatch/wave-cli.js +201 -6
  141. package/src/dispatch/worktree-cli.js +40 -0
  142. package/src/dispatch-planner.js +97 -2
  143. package/src/dream/runner.mjs +47 -11
  144. package/src/dream/stage-runner.js +40 -0
  145. package/src/dream/state-file.js +102 -0
  146. package/src/extension-installer.js +70 -24
  147. package/src/extension-quota-tracker.js +4 -2
  148. package/src/extension-registry.js +289 -35
  149. package/src/feedback-detector.js +26 -0
  150. package/src/fs-lock.js +259 -7
  151. package/src/gate-result.js +95 -1
  152. package/src/hero-line.js +86 -5
  153. package/src/intent-router.js +35 -0
  154. package/src/lib/a11y-contract.js +117 -0
  155. package/src/lib/atomic-io.js +29 -8
  156. package/src/lib/cache-keepalive.js +150 -0
  157. package/src/lib/jsonl-rotation.js +104 -0
  158. package/src/lib/lighthouse-pillar.js +121 -0
  159. package/src/lib/llm-call.js +121 -0
  160. package/src/lib/playwright-baseline.js +205 -0
  161. package/src/lib/rekor-bridge.js +221 -0
  162. package/src/lib/repo-map.js +392 -0
  163. package/src/lib/shasum-verify.js +164 -0
  164. package/src/lib/sketches-gc.js +132 -0
  165. package/src/lib/tmp-suffix.js +62 -0
  166. package/src/lib/ui-review-runner.js +554 -0
  167. package/src/lib/uispec-drift.js +301 -0
  168. package/src/lib/uispec-intake.js +381 -0
  169. package/src/lib/worktree-guards.js +118 -0
  170. package/src/lib/worktree-recovery.js +100 -0
  171. package/src/memory/auto-linker.js +152 -0
  172. package/src/memory/benchmark.js +498 -0
  173. package/src/memory/dedup.js +126 -0
  174. package/src/memory/embedding-cache.js +136 -0
  175. package/src/memory/fact-extractor.js +168 -0
  176. package/src/memory/fts5.js +65 -1
  177. package/src/memory/migrations/004-bitemporal.js +91 -0
  178. package/src/memory/migrations/005-vector-cache.js +61 -0
  179. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  180. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  181. package/src/memory/migrations/008-write-provenance.js +41 -0
  182. package/src/memory/obsidian-parser.js +91 -0
  183. package/src/memory/query-dataview.js +86 -0
  184. package/src/memory/search.js +10 -0
  185. package/src/memory/temporal.js +529 -0
  186. package/src/memory/tokenize.js +10 -0
  187. package/src/memory-facts-handler.js +37 -0
  188. package/src/memory-feedback.js +260 -2
  189. package/src/model-refresh.js +292 -0
  190. package/src/observability/cost-anomaly.js +166 -0
  191. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  192. package/src/observability/trace-id.js +163 -0
  193. package/src/orchestrator/agents-md-blackboard.js +152 -0
  194. package/src/orchestrator/checkpoint-contract.md +140 -0
  195. package/src/orchestrator/debug-trident.js +570 -0
  196. package/src/orchestrator/merge-block-aware.js +350 -0
  197. package/src/orchestrator/plan-checker.js +475 -0
  198. package/src/orchestrator/post-done-runner.js +249 -0
  199. package/src/orchestrator/review.js +38 -3
  200. package/src/orchestrator/runtime-loop.js +430 -0
  201. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  202. package/src/orchestrator/skill-telemetry.js +37 -0
  203. package/src/orchestrator/state-events.js +459 -0
  204. package/src/orchestrator/state-sdk.js +1764 -0
  205. package/src/orchestrator/status-protocol.js +84 -17
  206. package/src/orchestrator/subagent-telemetry.js +452 -0
  207. package/src/orchestrator/termination.js +160 -0
  208. package/src/orchestrator/verification-gate.js +200 -16
  209. package/src/orchestrator/wave-state.js +332 -23
  210. package/src/orchestrator/worktree-provision.js +77 -0
  211. package/src/override-use-registry.js +111 -5
  212. package/src/receipts.js +36 -4
  213. package/src/recovery/checkpoint.js +56 -3
  214. package/src/recovery/code-fixer.js +656 -0
  215. package/src/recovery/truncation.js +317 -0
  216. package/src/redactor.js +75 -6
  217. package/src/runtime-mediator.js +15 -0
  218. package/src/sanitizer.js +10 -0
  219. package/src/search-hybrid.js +139 -0
  220. package/src/server.js +603 -59
  221. package/src/swarm/worktree.js +27 -4
  222. package/src/swarm-config.js +94 -17
  223. package/src/team/domain-templates/book.json +51 -0
  224. package/src/team/domain-templates/business.json +41 -0
  225. package/src/team/domain-templates/content.json +50 -0
  226. package/src/team/domain-templates/design.json +44 -0
  227. package/src/team/domain-templates/research.json +41 -0
  228. package/src/team/domain-templates/software.json +40 -0
  229. package/src/team/generator.js +278 -3
  230. package/src/team/modify.js +203 -0
  231. package/src/team/schemas.js +48 -0
  232. package/src/update-apply.js +19 -3
@@ -0,0 +1,656 @@
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
+ * Zero new prod deps. ESM. Node ≥18.
25
+ */
26
+
27
+ import { existsSync } from 'node:fs';
28
+ import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
29
+ import { join, extname, relative, isAbsolute, resolve as resolvePath } from 'node:path';
30
+ import { tmpdir } from 'node:os';
31
+ import { execFile, spawnSync } from 'node:child_process';
32
+ import { promisify } from 'node:util';
33
+
34
+ import { withRecoverySentinel } from '../lib/worktree-recovery.js';
35
+
36
+ const execFileAsync = promisify(execFile);
37
+
38
+ /* ────────────────────────────── status codes ────────────────────────────── */
39
+
40
+ export const STATUS = Object.freeze({
41
+ VERIFIED: 'VERIFIED',
42
+ DEFERRED: 'DEFERRED',
43
+ STALE: 'STALE',
44
+ VERIFY_FAIL: 'VERIFY_FAIL',
45
+ SYNTAX_FAIL: 'SYNTAX_FAIL',
46
+ FALLBACK_FAIL: 'FALLBACK_FAIL',
47
+ TRIDENT_FAIL: 'TRIDENT_FAIL',
48
+ COMMIT_FAIL: 'COMMIT_FAIL',
49
+ });
50
+
51
+ /* ────────────────────────────── logic-bug heuristic ─────────────────────── */
52
+
53
+ // Phrases that signal a logic bug a mechanical fixer can't responsibly patch.
54
+ // Lifted from the v1.5.0 G4 brief + the ijfw-code-fixer agent's "DO NOT" list.
55
+ const LOGIC_BUG_PHRASES = [
56
+ 'off-by-one',
57
+ 'off by one',
58
+ 'incorrect condition',
59
+ 'wrong condition',
60
+ 'wrong order',
61
+ 'wrong logic',
62
+ 'race condition',
63
+ 'business logic',
64
+ 'semantic bug',
65
+ 'edge case',
66
+ 'intent unclear',
67
+ 'may not be correct',
68
+ 'might be wrong',
69
+ ];
70
+
71
+ /**
72
+ * isLogicBug(finding) — return {logic: boolean, reason: string}.
73
+ *
74
+ * Two-layer check:
75
+ * 1. Explicit category tag (`category: logic-bug`) — exact match.
76
+ * 2. Substring scan of `description` against LOGIC_BUG_PHRASES.
77
+ *
78
+ * Conservative-by-design: false positives are cheap (we defer instead of
79
+ * patching), false negatives are expensive (we patch a real logic bug).
80
+ */
81
+ export function isLogicBug(finding) {
82
+ if (!finding || typeof finding !== 'object') {
83
+ return { logic: false, reason: '' };
84
+ }
85
+ const cat = String(finding.category || '').toLowerCase().trim();
86
+ if (cat === 'logic-bug' || cat === 'logic_bug' || cat === 'logic') {
87
+ return { logic: true, reason: `category=${cat}` };
88
+ }
89
+ const desc = String(finding.description || '').toLowerCase();
90
+ for (const phrase of LOGIC_BUG_PHRASES) {
91
+ if (desc.includes(phrase)) {
92
+ return { logic: true, reason: `phrase="${phrase}"` };
93
+ }
94
+ }
95
+ // `missing-await` with no concrete line/range = ambiguous → defer.
96
+ if (cat === 'missing-await' && (!finding.line || !finding.fix)) {
97
+ return { logic: true, reason: 'missing-await without concrete boundary' };
98
+ }
99
+ return { logic: false, reason: '' };
100
+ }
101
+
102
+ /* ────────────────────────────── triage ──────────────────────────────────── */
103
+
104
+ /**
105
+ * triage(finding) — pre-flight gate. Returns one of:
106
+ * { proceed: true } — go ahead and patch
107
+ * { proceed: false, status, reason } — short-circuit with DEFERRED|STALE
108
+ */
109
+ export function triage(finding) {
110
+ if (!finding || typeof finding !== 'object') {
111
+ return { proceed: false, status: STATUS.DEFERRED, reason: 'malformed-finding' };
112
+ }
113
+ if (!finding.file || typeof finding.file !== 'string') {
114
+ return { proceed: false, status: STATUS.DEFERRED, reason: 'no-file-path' };
115
+ }
116
+ // Logic bug → defer (the contract is explicit: humans only).
117
+ const lb = isLogicBug(finding);
118
+ if (lb.logic) {
119
+ return { proceed: false, status: STATUS.DEFERRED, reason: `logic-bug: ${lb.reason}` };
120
+ }
121
+ // Need a concrete edit operation. Either { fix: { old_string, new_string } }
122
+ // or a precise suggested_fix string we can wrap. Without it the fixer is
123
+ // guessing, and guessing is logic-bug territory.
124
+ const fix = finding.fix || finding.suggested_fix;
125
+ if (!fix || (typeof fix === 'object' && (!fix.old_string || fix.new_string === undefined))) {
126
+ return { proceed: false, status: STATUS.DEFERRED, reason: 'no-concrete-fix' };
127
+ }
128
+ return { proceed: true };
129
+ }
130
+
131
+ /* ────────────────────────────── tier 1 — re-read ────────────────────────── */
132
+
133
+ /**
134
+ * verifyTier1(filePath, newString) — confirm the edit actually landed.
135
+ * Returns { ok: boolean, evidence: string }.
136
+ */
137
+ export async function verifyTier1(filePath, newString) {
138
+ try {
139
+ const content = await readFile(filePath, 'utf8');
140
+ if (newString === '' || content.includes(newString)) {
141
+ return { ok: true, evidence: '' };
142
+ }
143
+ return {
144
+ ok: false,
145
+ evidence: `tier-1: expected substring not present in ${filePath}`,
146
+ };
147
+ } catch (err) {
148
+ return { ok: false, evidence: `tier-1: read failed: ${err.message}` };
149
+ }
150
+ }
151
+
152
+ /* ────────────────────────────── tier 2 — syntax check ───────────────────── */
153
+
154
+ /**
155
+ * tier2SyntaxCheckCmd(filePath) — return the per-language check command, or
156
+ * null if the extension isn't on the supported list (caller SKIPs tier 2).
157
+ *
158
+ * Per ijfw-code-fixer.md contract:
159
+ * .js/.mjs/.cjs → node --check
160
+ * .ts/.tsx → tsc --noEmit --allowJs (only if tsc on PATH; else SKIP)
161
+ * .py → python3 -m py_compile
162
+ * .json → node -e JSON.parse
163
+ * .sh/.bash → bash -n
164
+ * others → SKIP
165
+ */
166
+ export function tier2SyntaxCheckCmd(filePath) {
167
+ const ext = extname(filePath).toLowerCase();
168
+ switch (ext) {
169
+ case '.js':
170
+ case '.mjs':
171
+ case '.cjs':
172
+ return { cmd: 'node', args: ['--check', filePath] };
173
+ case '.json':
174
+ return {
175
+ cmd: 'node',
176
+ args: [
177
+ '-e',
178
+ `JSON.parse(require('fs').readFileSync(${JSON.stringify(filePath)},'utf8'))`,
179
+ ],
180
+ };
181
+ case '.py':
182
+ return { cmd: 'python3', args: ['-m', 'py_compile', filePath] };
183
+ case '.sh':
184
+ case '.bash':
185
+ return { cmd: 'bash', args: ['-n', filePath] };
186
+ case '.ts':
187
+ case '.tsx': {
188
+ // Only if tsc on PATH. The agent contract says SKIP when absent.
189
+ const which = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['tsc'], {
190
+ encoding: 'utf8',
191
+ });
192
+ if (which.status === 0 && which.stdout.trim()) {
193
+ return { cmd: 'tsc', args: ['--noEmit', '--allowJs', filePath] };
194
+ }
195
+ return null;
196
+ }
197
+ default:
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * verifyTier2(filePath) — per-language syntax check. Returns one of:
204
+ * { ok: true, skipped: false }
205
+ * { ok: true, skipped: true } — extension not on the list
206
+ * { ok: false, evidence: string } — syntax error captured
207
+ */
208
+ export async function verifyTier2(filePath) {
209
+ const spec = tier2SyntaxCheckCmd(filePath);
210
+ if (!spec) return { ok: true, skipped: true };
211
+ try {
212
+ await execFileAsync(spec.cmd, spec.args, { timeout: 15_000 });
213
+ return { ok: true, skipped: false };
214
+ } catch (err) {
215
+ const stderr = err.stderr || err.stdout || err.message || '';
216
+ return {
217
+ ok: false,
218
+ evidence: `tier-2 (${spec.cmd}): ${String(stderr).split('\n').slice(0, 5).join('\n')}`,
219
+ };
220
+ }
221
+ }
222
+
223
+ /* ────────────────────────────── tier 3 — fallback project verify ────────── */
224
+
225
+ /**
226
+ * resolveProjectVerifyCmd(projectRoot, verifyCmdOverride) — pick the verify
227
+ * command. Priority: explicit override → package.json.scripts.test → Makefile
228
+ * `test:` target → null (caller SKIPs tier 3).
229
+ */
230
+ async function resolveProjectVerifyCmd(projectRoot, verifyCmdOverride) {
231
+ if (verifyCmdOverride && typeof verifyCmdOverride === 'string' && verifyCmdOverride.trim()) {
232
+ return verifyCmdOverride.trim();
233
+ }
234
+ // package.json.scripts.test
235
+ const pkgPath = join(projectRoot, 'package.json');
236
+ if (existsSync(pkgPath)) {
237
+ try {
238
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
239
+ if (pkg.scripts && typeof pkg.scripts.test === 'string' && pkg.scripts.test.trim()) {
240
+ return 'npm test --silent';
241
+ }
242
+ } catch { /* swallow malformed package.json */ }
243
+ }
244
+ // Makefile test target
245
+ const mkPath = join(projectRoot, 'Makefile');
246
+ if (existsSync(mkPath)) {
247
+ try {
248
+ const mk = await readFile(mkPath, 'utf8');
249
+ if (/^test\s*:/m.test(mk)) return 'make test';
250
+ } catch { /* swallow */ }
251
+ }
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * verifyTier3(projectRoot, verifyCmdOverride) — run the project's documented
257
+ * verify command. Returns one of:
258
+ * { ok: true, skipped: false }
259
+ * { ok: true, skipped: true } — no command discovered
260
+ * { ok: false, evidence: string } — first 20 lines of failure output
261
+ */
262
+ export async function verifyTier3(projectRoot, verifyCmdOverride) {
263
+ const cmd = await resolveProjectVerifyCmd(projectRoot, verifyCmdOverride);
264
+ if (!cmd) return { ok: true, skipped: true };
265
+ // Run the command via `sh -c` so script lines like `npm test --silent` work
266
+ // verbatim. Timeout is generous (5 min) because real test suites can be slow.
267
+ return new Promise((resolve) => {
268
+ execFile('sh', ['-c', cmd], { cwd: projectRoot, timeout: 5 * 60_000 }, (err, stdout, stderr) => {
269
+ if (!err) return resolve({ ok: true, skipped: false });
270
+ const blob = String(stderr || stdout || err.message || '');
271
+ const evidence = blob.split('\n').slice(0, 20).join('\n');
272
+ resolve({ ok: false, evidence: `tier-3 (${cmd}): ${evidence}` });
273
+ });
274
+ });
275
+ }
276
+
277
+ /* ────────────────────────────── trident verify ──────────────────────────── */
278
+
279
+ /**
280
+ * runTridentVerify({ commitRange, dispatch, projectRoot, lenses }) — wrap
281
+ * cross-orchestrator's runPhaseEConverge with the code-fixer's PASS-required
282
+ * contract. The verdict shape from runPhaseEConverge is:
283
+ * { verdict: 'PASS' | 'consensus_failed' | ..., iterations, findings, ... }
284
+ *
285
+ * The fixer only commits on `verdict === 'PASS'`. Anything else rolls back.
286
+ *
287
+ * `dispatch` is injectable (and required) so tests can drive scripted lens
288
+ * responses without spawning the real codex/gemini/claude CLIs.
289
+ */
290
+ export async function runTridentVerify({
291
+ commitRange,
292
+ dispatch,
293
+ projectRoot,
294
+ lenses,
295
+ maxIterations = 3,
296
+ }) {
297
+ if (typeof dispatch !== 'function') {
298
+ return {
299
+ verdict: 'TRIDENT_DISPATCH_MISSING',
300
+ passed: false,
301
+ evidence: 'no dispatch function supplied',
302
+ };
303
+ }
304
+ // Lazy-import to keep this module loadable in environments where
305
+ // cross-orchestrator's transitive deps (state-sdk, receipts) aren't wired.
306
+ // Test fixtures can always supply a custom `dispatch`, so the orchestrator
307
+ // call works against scripted lens responses with no real CLI spawn.
308
+ const { runPhaseEConverge } = await import('../cross-orchestrator.js');
309
+ const result = await runPhaseEConverge({
310
+ commitRange: commitRange || 'HEAD~1..HEAD',
311
+ dispatch,
312
+ lenses: Array.isArray(lenses) && lenses.length ? lenses : undefined,
313
+ maxIterations,
314
+ projectRoot,
315
+ projectDir: projectRoot,
316
+ });
317
+ return {
318
+ verdict: result.verdict,
319
+ passed: result.verdict === 'PASS',
320
+ iterations: result.iterations,
321
+ evidence: result.verdict === 'PASS' ? '' : `trident: verdict=${result.verdict}`,
322
+ raw: result,
323
+ };
324
+ }
325
+
326
+ /* ────────────────────────────── git helpers ─────────────────────────────── */
327
+
328
+ function git(cwd, args, { allowFail = false } = {}) {
329
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
330
+ if (res.status !== 0 && !allowFail) {
331
+ const err = new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`);
332
+ err.stdout = res.stdout;
333
+ err.stderr = res.stderr;
334
+ throw err;
335
+ }
336
+ return { status: res.status, stdout: res.stdout, stderr: res.stderr };
337
+ }
338
+
339
+ /**
340
+ * atomicCommit({ projectRoot, file, finding }) — stage the one edited file +
341
+ * commit. Per-finding atomicity is the wrapping-loop guarantee from the agent
342
+ * contract; bundling defeats the rollback story.
343
+ *
344
+ * Returns { ok: boolean, sha?: string, evidence?: string }.
345
+ */
346
+ export function atomicCommit({ projectRoot, file, finding }) {
347
+ try {
348
+ // Explicit-file stage; never `git add -A` (catches stray edits).
349
+ const rel = isAbsolute(file) ? relative(projectRoot, file) : file;
350
+ git(projectRoot, ['add', '--', rel]);
351
+ const id = finding.finding_id || finding.id || 'unknown';
352
+ const sev = (finding.severity || 'unknown').toUpperCase();
353
+ const desc = String(finding.description || '').split('\n')[0].slice(0, 120);
354
+ const msg = `fix(code-fixer): ${id} [${sev}] ${desc}`.trim();
355
+ git(projectRoot, ['commit', '-m', msg]);
356
+ const sha = git(projectRoot, ['rev-parse', 'HEAD']).stdout.trim();
357
+ return { ok: true, sha };
358
+ } catch (err) {
359
+ return { ok: false, evidence: err.message };
360
+ }
361
+ }
362
+
363
+ /* ────────────────────────────── apply + rollback ────────────────────────── */
364
+
365
+ /**
366
+ * applyEdit(filePath, fix) — one minimal substitution, captures pre-edit
367
+ * content for rollback. Supports the two fix shapes the contract recognises:
368
+ * { old_string, new_string } — exact substring substitution
369
+ * string — interpreted as the new full content (rare)
370
+ */
371
+ async function applyEdit(filePath, fix) {
372
+ const before = await readFile(filePath, 'utf8');
373
+ let after;
374
+ if (typeof fix === 'object' && fix.old_string !== undefined) {
375
+ if (!before.includes(fix.old_string)) {
376
+ return { ok: false, evidence: 'old_string not found in file', before };
377
+ }
378
+ // Exactly-one occurrence guarantee — same rule the Edit tool uses.
379
+ const occurrences = before.split(fix.old_string).length - 1;
380
+ if (occurrences > 1 && !fix.replace_all) {
381
+ return { ok: false, evidence: `old_string occurs ${occurrences}×; ambiguous`, before };
382
+ }
383
+ after = fix.replace_all
384
+ ? before.split(fix.old_string).join(fix.new_string)
385
+ : before.replace(fix.old_string, fix.new_string);
386
+ } else if (typeof fix === 'string') {
387
+ after = fix;
388
+ } else {
389
+ return { ok: false, evidence: 'unsupported fix shape', before };
390
+ }
391
+ if (after === before) {
392
+ return { ok: false, evidence: 'edit was a no-op (after === before)', before };
393
+ }
394
+ await writeFile(filePath, after, 'utf8');
395
+ return { ok: true, before, after };
396
+ }
397
+
398
+ async function rollback(filePath, originalContent) {
399
+ try {
400
+ await writeFile(filePath, originalContent, 'utf8');
401
+ return { ok: true };
402
+ } catch (err) {
403
+ return { ok: false, error: err.message };
404
+ }
405
+ }
406
+
407
+ /* ────────────────────────────── main entry point ────────────────────────── */
408
+
409
+ /**
410
+ * fixFinding({ finding, projectRoot, dispatch, verifyCmd, dryRun, lenses,
411
+ * commitRange, maxConvergeIter, skipTrident, skipCommit })
412
+ *
413
+ * The flagship loop. Returns a structured record:
414
+ * {
415
+ * status: STATUS.*,
416
+ * tier_reached: 1 | 2 | 3 | 'trident' | 'commit' | 'n/a',
417
+ * finding_id, file, evidence?, sha?, trident?
418
+ * }
419
+ *
420
+ * `dispatch` is required for Trident verify; tests inject scripted responses.
421
+ * `skipTrident` is for unit-test-only flows that exercise the 3-tier matrix
422
+ * in isolation; production callers MUST run Trident.
423
+ *
424
+ * `skipCommit` is for dry-run / unit-test flows; production callers MUST
425
+ * commit (the recovery-sentinel is wrapped around the commit boundary).
426
+ */
427
+ export async function fixFinding({
428
+ finding,
429
+ projectRoot,
430
+ dispatch,
431
+ verifyCmd,
432
+ dryRun = false,
433
+ lenses,
434
+ commitRange,
435
+ maxConvergeIter = 3,
436
+ skipTrident = false,
437
+ skipCommit = false,
438
+ } = {}) {
439
+ const findingId = finding?.finding_id || finding?.id || 'unknown';
440
+ const base = { finding_id: findingId, file: finding?.file };
441
+
442
+ // 1. triage
443
+ const t = triage(finding);
444
+ if (!t.proceed) {
445
+ return { ...base, status: t.status, tier_reached: 'n/a', deferred_reason: t.reason };
446
+ }
447
+
448
+ // 2. confirm target exists + snapshot
449
+ const filePath = isAbsolute(finding.file)
450
+ ? finding.file
451
+ : resolvePath(projectRoot || process.cwd(), finding.file);
452
+ if (!existsSync(filePath)) {
453
+ return { ...base, status: STATUS.STALE, tier_reached: 'n/a',
454
+ evidence: `target file does not exist: ${filePath}` };
455
+ }
456
+
457
+ if (dryRun) {
458
+ return { ...base, status: STATUS.DEFERRED, tier_reached: 'n/a',
459
+ deferred_reason: 'dry-run' };
460
+ }
461
+
462
+ // 3. apply
463
+ const fix = finding.fix || finding.suggested_fix;
464
+ const edit = await applyEdit(filePath, fix);
465
+ if (!edit.ok) {
466
+ return { ...base, status: STATUS.VERIFY_FAIL, tier_reached: 1, evidence: edit.evidence };
467
+ }
468
+ const originalContent = edit.before;
469
+ const expectedNewString = typeof fix === 'object' ? fix.new_string : null;
470
+
471
+ // 4. tier 1 — re-read
472
+ const t1 = await verifyTier1(
473
+ filePath,
474
+ expectedNewString !== null && expectedNewString !== undefined ? expectedNewString : '',
475
+ );
476
+ if (!t1.ok) {
477
+ await rollback(filePath, originalContent);
478
+ return { ...base, status: STATUS.VERIFY_FAIL, tier_reached: 1, evidence: t1.evidence };
479
+ }
480
+
481
+ // 5. tier 2 — syntax check (per-language)
482
+ const t2 = await verifyTier2(filePath);
483
+ if (!t2.ok) {
484
+ await rollback(filePath, originalContent);
485
+ return { ...base, status: STATUS.SYNTAX_FAIL, tier_reached: 2, evidence: t2.evidence };
486
+ }
487
+
488
+ // 6. tier 3 — fallback project verify
489
+ // Only run if tier 2 passed (or skipped). If tier 2 was non-skipped + passed
490
+ // we still run tier 3 as a deeper net.
491
+ const t3 = await verifyTier3(projectRoot || process.cwd(), verifyCmd);
492
+ if (!t3.ok) {
493
+ await rollback(filePath, originalContent);
494
+ return { ...base, status: STATUS.FALLBACK_FAIL, tier_reached: 3, evidence: t3.evidence };
495
+ }
496
+
497
+ // 7. trident verify
498
+ let tridentRec = null;
499
+ if (!skipTrident) {
500
+ const trident = await runTridentVerify({
501
+ commitRange,
502
+ dispatch,
503
+ projectRoot: projectRoot || process.cwd(),
504
+ lenses,
505
+ maxIterations: maxConvergeIter,
506
+ });
507
+ tridentRec = {
508
+ verdict: trident.verdict,
509
+ iterations: trident.iterations,
510
+ passed: trident.passed,
511
+ };
512
+ if (!trident.passed) {
513
+ await rollback(filePath, originalContent);
514
+ return {
515
+ ...base,
516
+ status: STATUS.TRIDENT_FAIL,
517
+ tier_reached: 'trident',
518
+ evidence: trident.evidence,
519
+ trident: tridentRec,
520
+ };
521
+ }
522
+ }
523
+
524
+ // 8. atomic commit (sentinel-wrapped — the destructive crash window).
525
+ if (skipCommit) {
526
+ return {
527
+ ...base,
528
+ status: STATUS.VERIFIED,
529
+ tier_reached: skipTrident ? 3 : 'trident',
530
+ trident: tridentRec,
531
+ };
532
+ }
533
+
534
+ try {
535
+ const sentinelData = {
536
+ op: 'code-fixer-commit',
537
+ finding_id: findingId,
538
+ file: filePath,
539
+ worktreePath: projectRoot, // re-used for surface compat with recoverPending
540
+ };
541
+ const commitRes = await withRecoverySentinel(
542
+ sentinelData,
543
+ async () => atomicCommit({ projectRoot, file: filePath, finding }),
544
+ projectRoot || process.cwd(),
545
+ );
546
+ if (!commitRes.ok) {
547
+ await rollback(filePath, originalContent);
548
+ return {
549
+ ...base,
550
+ status: STATUS.COMMIT_FAIL,
551
+ tier_reached: 'commit',
552
+ evidence: commitRes.evidence,
553
+ trident: tridentRec,
554
+ };
555
+ }
556
+ return {
557
+ ...base,
558
+ status: STATUS.VERIFIED,
559
+ tier_reached: skipTrident ? 3 : 'trident',
560
+ sha: commitRes.sha,
561
+ trident: tridentRec,
562
+ };
563
+ } catch (err) {
564
+ // Sentinel remains on disk for next-run recovery (worktree-recovery.js).
565
+ await rollback(filePath, originalContent);
566
+ return {
567
+ ...base,
568
+ status: STATUS.COMMIT_FAIL,
569
+ tier_reached: 'commit',
570
+ evidence: `sentinel-wrapped commit threw: ${err.message}`,
571
+ sentinel_path: err.sentinelPath,
572
+ trident: tridentRec,
573
+ };
574
+ }
575
+ }
576
+
577
+ /* ────────────────────────────── batch runner ────────────────────────────── */
578
+
579
+ /**
580
+ * fixFindings(findings, opts) — sequential per-finding atomic loop.
581
+ *
582
+ * Sequential is intentional: each fix mutates the same worktree HEAD and
583
+ * the Trident step needs a stable commit range. Parallel fixes would shred
584
+ * the atomicity guarantee.
585
+ *
586
+ * Returns { results: Array<fixFinding-record>, summary: { verified, deferred,
587
+ * stale, verify_fail, syntax_fail, fallback_fail, trident_fail,
588
+ * commit_fail } }.
589
+ */
590
+ export async function fixFindings(findings, opts = {}) {
591
+ const results = [];
592
+ const summary = {
593
+ verified: 0, deferred: 0, stale: 0,
594
+ verify_fail: 0, syntax_fail: 0, fallback_fail: 0,
595
+ trident_fail: 0, commit_fail: 0,
596
+ };
597
+ for (const finding of (findings || [])) {
598
+ // eslint-disable-next-line no-await-in-loop -- sequential is the contract
599
+ const r = await fixFinding({ ...opts, finding });
600
+ results.push(r);
601
+ const k = String(r.status || '').toLowerCase();
602
+ if (k in summary) summary[k] += 1;
603
+ }
604
+ return { results, summary };
605
+ }
606
+
607
+ /* ────────────────────────────── test helpers ────────────────────────────── */
608
+
609
+ /**
610
+ * _makeTridentDispatch — convenience builder for unit + e2e tests.
611
+ *
612
+ * Mode 'pass' → every lens returns PASS, empty findings, first iter.
613
+ * Mode 'fail-then-pass' → first iter FAIL on one lens, second PASS.
614
+ * Mode 'fail' → every iter has codex FAIL; convergence stalls.
615
+ *
616
+ * Production callers MUST inject a real lens dispatcher. This is exported
617
+ * (underscore-prefixed) only for the test harness.
618
+ */
619
+ export function _makeTridentDispatch(mode = 'pass') {
620
+ const scripts = {
621
+ pass: () => ({ verdict: 'PASS', findings: [] }),
622
+ };
623
+ if (mode === 'pass') {
624
+ return async ({ lens }) => ({ lens, ...scripts.pass() });
625
+ }
626
+ if (mode === 'fail-then-pass') {
627
+ return async ({ lens, iteration }) => {
628
+ if (lens === 'codex' && iteration === 1) {
629
+ return { lens, verdict: 'FAIL', findings: [{ severity: 'high', text: 'demo' }] };
630
+ }
631
+ return { lens, verdict: 'PASS', findings: [] };
632
+ };
633
+ }
634
+ if (mode === 'fail') {
635
+ let counter = 0;
636
+ return async ({ lens }) => {
637
+ counter += 1;
638
+ if (lens === 'codex') {
639
+ return { lens, verdict: 'FAIL', findings: [{ severity: 'high', text: `t-${counter}` }] };
640
+ }
641
+ return { lens, verdict: 'PASS', findings: [{ text: `c-${counter}` }] };
642
+ };
643
+ }
644
+ throw new Error(`_makeTridentDispatch: unknown mode "${mode}"`);
645
+ }
646
+
647
+ /**
648
+ * _freshTmpRoot(prefix) — mkdtemp helper for tests; returns absolute path.
649
+ */
650
+ export async function _freshTmpRoot(prefix = 'ijfw-code-fixer-test-') {
651
+ return mkdtemp(join(tmpdir(), prefix));
652
+ }
653
+
654
+ export async function _cleanupTmpRoot(root) {
655
+ try { await rm(root, { recursive: true, force: true }); } catch { /* swallow */ }
656
+ }