@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
@@ -12,13 +12,32 @@
12
12
  * counts, never IDs or full receipt content.
13
13
  */
14
14
 
15
- import { readdir, readFile, lstat } from 'node:fs/promises';
16
- import { join } from 'node:path';
15
+ import { readdir, readFile, lstat, mkdir, appendFile } from 'node:fs/promises';
16
+ import { join, dirname } from 'node:path';
17
+ import { createHash } from 'node:crypto';
17
18
 
18
19
  const RECEIPTS_SUBPATH = join('.ijfw', 'memory', 'gate-receipts');
20
+ const DEVIATIONS_SUBPATH = join('.ijfw', 'memory', 'deviations.jsonl');
19
21
  const MAX_FILE_BYTES = 64 * 1024;
22
+ const MAX_DEVIATIONS_FILE_BYTES = 4 * 1024 * 1024; // 4MB JSONL cap
20
23
  const FAIL_VERDICTS = new Set(['FAIL', 'FLAG']);
21
24
 
25
+ // W12-C N04 — deviation pattern derivation heuristics (ordered: most specific first).
26
+ const DEVIATION_PATTERN_RULES = [
27
+ { label: 'test-fixture-drift', rx: /test.*fail|expected.*got/i },
28
+ { label: 'flaky-infra', rx: /timeout|EBUSY|ECONNRESET/i },
29
+ { label: 'missing-dep', rx: /Cannot find module|MODULE_NOT_FOUND/i },
30
+ { label: 'fs-permissions', rx: /permission denied|EACCES/i },
31
+ { label: 'git-cache-corruption', rx: /cache-tree|fatal: unable to read/i },
32
+ { label: 'branch-collision', rx: /branch.*already exists|cannot create branch/i },
33
+ ];
34
+
35
+ const VALID_DEVIATION_EVENTS = new Set([
36
+ '3-attempt-cap-hit',
37
+ 'BLOCKED',
38
+ 'cross-ai-divergence',
39
+ ]);
40
+
22
41
  /**
23
42
  * readRecentReceipts(projectRoot, limit)
24
43
  *
@@ -370,3 +389,242 @@ export async function getFeedbackSuggestions(projectRoot, opts = {}) {
370
389
  return [];
371
390
  }
372
391
  }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // W12-C N04 — memory-backed deviation patterns (lock-in #48: memory feeds forward)
395
+ // ---------------------------------------------------------------------------
396
+
397
+ /**
398
+ * derivePattern(errorText) -> string
399
+ *
400
+ * Maps free-form error / failure text to one of seven labels (or 'unclassified').
401
+ * Rules are tried in order; first match wins so e.g. an "EACCES timeout" string
402
+ * resolves to 'flaky-infra' only when the test-fixture and timeout patterns
403
+ * don't match earlier — the rule order encodes priority.
404
+ *
405
+ * Pure. Safe to call with any input (null, undefined, non-strings → 'unclassified').
406
+ *
407
+ * @param {string} errorText
408
+ * @returns {string}
409
+ */
410
+ export function derivePattern(errorText) {
411
+ if (typeof errorText !== 'string' || errorText.length === 0) return 'unclassified';
412
+ for (const rule of DEVIATION_PATTERN_RULES) {
413
+ if (rule.rx.test(errorText)) return rule.label;
414
+ }
415
+ return 'unclassified';
416
+ }
417
+
418
+ /**
419
+ * Build a short, stable hash of a payload for idempotency keys.
420
+ * SHA-256 over a sorted-key JSON of {event, payload}, truncated to 12 hex chars.
421
+ */
422
+ function payloadHash(event, payload) {
423
+ const stable = stableStringify({ event, payload });
424
+ return createHash('sha256').update(stable).digest('hex').slice(0, 12);
425
+ }
426
+
427
+ /** Deterministic JSON: object keys sorted recursively. */
428
+ function stableStringify(value) {
429
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
430
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
431
+ const keys = Object.keys(value).sort();
432
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
433
+ }
434
+
435
+ /** Extract a short task summary from a payload object (best-effort, never throws). */
436
+ function summarizeTask(payload) {
437
+ if (!payload || typeof payload !== 'object') return 'unknown task';
438
+ const s = payload.task || payload.task_summary || payload.title || payload.name;
439
+ if (typeof s === 'string' && s.length > 0) return s.slice(0, 120);
440
+ return 'unknown task';
441
+ }
442
+
443
+ /** Extract last_error text from a payload object (best-effort, never throws). */
444
+ function extractError(payload) {
445
+ if (!payload || typeof payload !== 'object') return '';
446
+ const e = payload.last_error || payload.error || payload.message || '';
447
+ if (typeof e === 'string') return e;
448
+ return '';
449
+ }
450
+
451
+ /** Build the canonical key for an event. ts is required so repeated calls
452
+ * with the SAME ts produce the same key (idempotency); different ts ⇒ new key
453
+ * (but the payload hash guards against duplicate writes regardless of ts).
454
+ */
455
+ function buildKey(event, payload, ts) {
456
+ const hash = payloadHash(event, payload);
457
+ switch (event) {
458
+ case '3-attempt-cap-hit':
459
+ return `deviation_${hash}_3attempt_${ts}`;
460
+ case 'BLOCKED':
461
+ return `deviation_${hash}_blocked_${ts}`;
462
+ case 'cross-ai-divergence': {
463
+ const commit =
464
+ payload && typeof payload.commit === 'string' && payload.commit.length > 0
465
+ ? payload.commit.slice(0, 12)
466
+ : 'nocommit';
467
+ return `deviation_consensus_${commit}_${ts}`;
468
+ }
469
+ default:
470
+ return `deviation_${hash}_${event}_${ts}`;
471
+ }
472
+ }
473
+
474
+ /** Build the human-readable value text for an event. */
475
+ function buildValue(event, payload, pattern) {
476
+ if (event === 'cross-ai-divergence') {
477
+ const v = (payload && payload.verdicts) || {};
478
+ const codex = v.codex || 'n/a';
479
+ const gemini = v.gemini || 'n/a';
480
+ const claude = v.claude || 'n/a';
481
+ return `codex: ${codex}, gemini: ${gemini}, claude: ${claude} — pattern: ${pattern}`;
482
+ }
483
+ const task = summarizeTask(payload);
484
+ const err = extractError(payload);
485
+ const attempts = (payload && typeof payload.attempts === 'number') ? payload.attempts : 3;
486
+ if (event === '3-attempt-cap-hit') {
487
+ return `${task} failed at attempt ${attempts} — last error: ${err}. Pattern: ${pattern}`;
488
+ }
489
+ if (event === 'BLOCKED') {
490
+ return `${task} BLOCKED — last error: ${err}. Pattern: ${pattern}`;
491
+ }
492
+ return `${task} (${event}) — ${err}. Pattern: ${pattern}`;
493
+ }
494
+
495
+ /**
496
+ * recordDeviation({ event, payload, projectRoot, ts? }) → { key, pattern, written }
497
+ *
498
+ * Writes one JSONL entry to <projectRoot>/.ijfw/memory/deviations.jsonl.
499
+ * Idempotent: if an entry with the same payload_hash already exists in the
500
+ * file, returns { written: false } without appending.
501
+ *
502
+ * @param {{event: string, payload: object, projectRoot: string, ts?: string}} opts
503
+ * @returns {Promise<{ key: string, pattern: string, written: boolean, type: 'feedback' }>}
504
+ */
505
+ export async function recordDeviation({ event, payload, projectRoot, ts } = {}) {
506
+ if (typeof event !== 'string' || !VALID_DEVIATION_EVENTS.has(event)) {
507
+ throw new Error(`recordDeviation: event must be one of ${Array.from(VALID_DEVIATION_EVENTS).join(', ')}`);
508
+ }
509
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
510
+ throw new Error('recordDeviation: projectRoot is required');
511
+ }
512
+ payload = payload && typeof payload === 'object' ? payload : {};
513
+ const timestamp = typeof ts === 'string' && ts.length > 0 ? ts : new Date().toISOString();
514
+
515
+ const errorText =
516
+ event === 'cross-ai-divergence'
517
+ ? (payload && typeof payload.divergence_summary === 'string' ? payload.divergence_summary : '')
518
+ : extractError(payload);
519
+ const pattern = derivePattern(errorText);
520
+ const hash = payloadHash(event, payload);
521
+ const key = buildKey(event, payload, timestamp);
522
+ const value = buildValue(event, payload, pattern);
523
+
524
+ const entry = {
525
+ key,
526
+ value,
527
+ ts: timestamp,
528
+ pattern,
529
+ event,
530
+ type: 'feedback',
531
+ payload_hash: hash,
532
+ };
533
+
534
+ const filePath = join(projectRoot, DEVIATIONS_SUBPATH);
535
+
536
+ // Idempotency: scan existing entries for matching payload_hash.
537
+ const existing = await readDeviationsFile(filePath);
538
+ for (const e of existing) {
539
+ if (e && e.payload_hash === hash) {
540
+ return { key: e.key, pattern: e.pattern, written: false, type: 'feedback' };
541
+ }
542
+ }
543
+
544
+ try {
545
+ await mkdir(dirname(filePath), { recursive: true });
546
+ await appendFile(filePath, JSON.stringify(entry) + '\n', 'utf8');
547
+ } catch (err) {
548
+ throw new Error(`recordDeviation: failed to write ${filePath}: ${err.message}`);
549
+ }
550
+
551
+ return { key, pattern, written: true, type: 'feedback' };
552
+ }
553
+
554
+ /**
555
+ * Read & parse the JSONL deviations file. Skips malformed lines. Never throws.
556
+ */
557
+ async function readDeviationsFile(filePath) {
558
+ let info;
559
+ try {
560
+ info = await lstat(filePath);
561
+ } catch {
562
+ return [];
563
+ }
564
+ if (!info.isFile()) return [];
565
+ if (info.size > MAX_DEVIATIONS_FILE_BYTES) return [];
566
+
567
+ let raw;
568
+ try {
569
+ raw = await readFile(filePath, 'utf8');
570
+ } catch {
571
+ return [];
572
+ }
573
+
574
+ const out = [];
575
+ for (const line of raw.split('\n')) {
576
+ const trimmed = line.trim();
577
+ if (trimmed.length === 0) continue;
578
+ try {
579
+ const obj = JSON.parse(trimmed);
580
+ if (obj && typeof obj === 'object' && typeof obj.key === 'string') {
581
+ out.push(obj);
582
+ }
583
+ } catch {
584
+ // skip malformed line
585
+ }
586
+ }
587
+ return out;
588
+ }
589
+
590
+ /**
591
+ * readDeviationPatterns({ patternLabel?, sinceISO?, event?, projectRoot })
592
+ * -> Array<{key, value, ts, pattern, event, type, payload_hash}>
593
+ *
594
+ * Query function so the planner can ask "all `git-cache-corruption` events
595
+ * from the last 30 days" and surface the warning in fresh dispatch briefs.
596
+ *
597
+ * Returns newest-first.
598
+ *
599
+ * @param {{patternLabel?: string, sinceISO?: string, event?: string, projectRoot: string}} opts
600
+ * @returns {Promise<object[]>}
601
+ */
602
+ export async function readDeviationPatterns(opts = {}) {
603
+ const { patternLabel, sinceISO, event, projectRoot } = opts;
604
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
605
+ throw new Error('readDeviationPatterns: projectRoot is required');
606
+ }
607
+
608
+ const filePath = join(projectRoot, DEVIATIONS_SUBPATH);
609
+ const all = await readDeviationsFile(filePath);
610
+
611
+ let sinceMs = null;
612
+ if (typeof sinceISO === 'string' && sinceISO.length > 0) {
613
+ const parsed = Date.parse(sinceISO);
614
+ if (!Number.isNaN(parsed)) sinceMs = parsed;
615
+ }
616
+
617
+ const filtered = all.filter((e) => {
618
+ if (patternLabel && e.pattern !== patternLabel) return false;
619
+ if (event && e.event !== event) return false;
620
+ if (sinceMs !== null) {
621
+ const eMs = Date.parse(e.ts);
622
+ if (Number.isNaN(eMs) || eMs < sinceMs) return false;
623
+ }
624
+ return true;
625
+ });
626
+
627
+ // Newest first by ts (string ISO compare works for valid ISO timestamps).
628
+ filtered.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
629
+ return filtered;
630
+ }
@@ -0,0 +1,292 @@
1
+ // model-refresh.js — 24h-cached latest-model resolver for audit-roster's
2
+ // HTTP API fallback path.
3
+ //
4
+ // Why: CLI dispatch (codex exec, gemini, claude -p) inherits whatever the
5
+ // CLI defaults to, so the model ID is always fresh on the CLI path. But
6
+ // when the CLI is NOT installed and we fall back to direct HTTP, the model
7
+ // name is OURS to pick. Hardcoded strings rot. This module caches the
8
+ // latest non-preview model per provider and refreshes once every 24 hours.
9
+ //
10
+ // Design:
11
+ // - getLatestModel(family) is SYNCHRONOUS — returns cached value (or
12
+ // hardcoded fallback) immediately. Never blocks on network.
13
+ // - When the cache is stale or missing, we kick off a non-blocking
14
+ // refresh via queueMicrotask. The CURRENT call still returns the
15
+ // last-known-good value. The fresh value lands by the next call.
16
+ // - Each provider probe is wrapped in try/catch. Probe failure leaves
17
+ // the previous cache entry in place.
18
+ // - Atomic writes: tmp-file + rename. No partial-write corruption.
19
+ // - Zero new deps. Node 18+ global fetch.
20
+ //
21
+ // Cache file shape:
22
+ // {
23
+ // "schema": 1,
24
+ // "updated_at": "2026-05-18T16:00:00.000Z",
25
+ // "models": {
26
+ // "openai": { "id": "gpt-5.5", "checked_at": "..." },
27
+ // "google": { "id": "gemini-3.1-pro", "checked_at": "..." },
28
+ // "anthropic": { "id": "claude-haiku-4-5-20251001","checked_at": "..." }
29
+ // }
30
+ // }
31
+
32
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { join, dirname } from 'node:path';
35
+
36
+ export const CACHE_SCHEMA = 1;
37
+ export const TTL_MS = 24 * 60 * 60 * 1000;
38
+
39
+ // Last-known-good defaults, kept in source so offline / no-API-key users
40
+ // get a working answer. Bumped 2026-05-18 to v1.5.0-major flagships.
41
+ export const HARDCODED_FALLBACKS = Object.freeze({
42
+ openai: 'gpt-5.5',
43
+ google: 'gemini-3.1-pro',
44
+ anthropic: 'claude-haiku-4-5-20251001',
45
+ });
46
+
47
+ // Resolve cache dir lazily so tests can override via env.
48
+ export function cachePath(env = process.env) {
49
+ const root = env.IJFW_CACHE_DIR || join(homedir(), '.ijfw', 'cache');
50
+ return join(root, 'model-roster.json');
51
+ }
52
+
53
+ // Returns true if `tsISO` is within `ttlMs` of `now`.
54
+ export function isCacheFresh(tsISO, now = Date.now(), ttlMs = TTL_MS) {
55
+ if (!tsISO) return false;
56
+ const t = Date.parse(tsISO);
57
+ if (!Number.isFinite(t)) return false;
58
+ return (now - t) < ttlMs;
59
+ }
60
+
61
+ // Read the cache file. Returns null on any error (missing / malformed).
62
+ export function readCache(env = process.env) {
63
+ try {
64
+ const raw = readFileSync(cachePath(env), 'utf8');
65
+ const parsed = JSON.parse(raw);
66
+ if (!parsed || typeof parsed !== 'object') return null;
67
+ if (parsed.schema !== CACHE_SCHEMA) return null;
68
+ if (!parsed.models || typeof parsed.models !== 'object') return null;
69
+ return parsed;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // Atomic write — tmp file + rename. Creates parent dir if needed.
76
+ export function writeCache(payload, env = process.env) {
77
+ const p = cachePath(env);
78
+ mkdirSync(dirname(p), { recursive: true });
79
+ const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
80
+ writeFileSync(tmp, JSON.stringify(payload, null, 2));
81
+ renameSync(tmp, p);
82
+ }
83
+
84
+ // Track in-flight refresh so concurrent calls don't fire N probes.
85
+ let inflightRefresh = null;
86
+
87
+ // Semver-aware comparison for model IDs.
88
+ //
89
+ // Why: Lexical sort breaks once a family hits double-digit majors:
90
+ // `gemini-10.0` sorts BEFORE `gemini-2.0` because "1" < "2" before
91
+ // length is considered. This compares dot-separated segments numerically
92
+ // (highest first when used as a max-picker), falling back to lexical
93
+ // for non-numeric tail segments like `-flash`, `-pro`, `-thinking`.
94
+ //
95
+ // Returns negative if a < b, positive if a > b, 0 if equal.
96
+ // Inputs are full model IDs, e.g. 'gemini-2.5-pro', 'gemini-10.0-pro'.
97
+ export function compareModelIds(a, b) {
98
+ // Split on hyphens AND dots so 'gemini-2.5-pro' → ['gemini','2','5','pro'].
99
+ const segs = (s) => String(s).split(/[-.]/);
100
+ const A = segs(a);
101
+ const B = segs(b);
102
+ const n = Math.max(A.length, B.length);
103
+ for (let i = 0; i < n; i++) {
104
+ const sa = A[i];
105
+ const sb = B[i];
106
+ if (sa === sb) continue;
107
+ if (sa === undefined) return -1; // shorter is "less than"
108
+ if (sb === undefined) return 1;
109
+ const na = /^\d+$/.test(sa) ? parseInt(sa, 10) : NaN;
110
+ const nb = /^\d+$/.test(sb) ? parseInt(sb, 10) : NaN;
111
+ const aIsNum = Number.isFinite(na);
112
+ const bIsNum = Number.isFinite(nb);
113
+ if (aIsNum && bIsNum) {
114
+ if (na !== nb) return na - nb;
115
+ continue;
116
+ }
117
+ // Numeric segments beat non-numeric at the same position (so
118
+ // 'gemini-2.5-pro' < 'gemini-2.5-pro-thinking' falls out via the
119
+ // length rule above; here we just need deterministic ordering for
120
+ // mixed types).
121
+ if (aIsNum !== bIsNum) return aIsNum ? 1 : -1;
122
+ // Both non-numeric — lexical fallback.
123
+ return sa < sb ? -1 : 1;
124
+ }
125
+ return 0;
126
+ }
127
+
128
+ // Synchronous resolver. Returns the cached model id, or the hardcoded
129
+ // fallback, NEVER null. Triggers a background refresh if stale.
130
+ // family: 'openai' | 'google' | 'anthropic'
131
+ // opts.now — inject for tests
132
+ // opts.env — inject for tests
133
+ // opts.fetch — inject for tests (defaults to globalThis.fetch)
134
+ export function getLatestModel(family, opts = {}) {
135
+ const env = opts.env || process.env;
136
+ const now = opts.now || Date.now();
137
+ const cache = readCache(env);
138
+ const cached = cache?.models?.[family]?.id;
139
+ const fresh = isCacheFresh(cache?.updated_at, now);
140
+
141
+ if (!fresh && !inflightRefresh) {
142
+ // Fire-and-forget refresh. Never throw out of the sync call.
143
+ inflightRefresh = refreshModelCache({ env, now, fetch: opts.fetch })
144
+ .catch(() => null)
145
+ .finally(() => { inflightRefresh = null; });
146
+ }
147
+
148
+ return cached || HARDCODED_FALLBACKS[family] || null;
149
+ }
150
+
151
+ // Per-provider model-list probe. Each returns either { id } on success or
152
+ // null on failure (network / auth / unexpected shape). NEVER throws.
153
+ //
154
+ // All three providers expose a list-models endpoint. We pick the latest
155
+ // non-preview, non-deprecated entry that looks like a current reasoning
156
+ // model. Heuristic, intentionally conservative.
157
+ //
158
+ // v1.5.0 audit-LOW-tok-L2: every probe is wrapped in an AbortController
159
+ // with a hard timeout so a hung TLS handshake / TCP half-open can't keep
160
+ // the event loop alive past process exit. Without this the Node runtime
161
+ // would leak open sockets when getLatestModel() schedules a background
162
+ // refresh and the host process tries to exit before the probes resolve.
163
+
164
+ const PROBE_TIMEOUT_MS = 5000;
165
+
166
+ // makeAbortable -> { signal, cancel } pair. Caller MUST call cancel() in
167
+ // a finally{} so we always clear the timer (cancel() is a no-op once the
168
+ // timeout has fired; we use it primarily to release the unref'd timer).
169
+ function makeAbortable(timeoutMs = PROBE_TIMEOUT_MS) {
170
+ const controller = new AbortController();
171
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
172
+ // unref so the timer alone never keeps the event loop alive.
173
+ if (typeof timer.unref === 'function') timer.unref();
174
+ return { signal: controller.signal, cancel: () => clearTimeout(timer) };
175
+ }
176
+
177
+ async function probeOpenAI(env, fetchImpl) {
178
+ const key = env.OPENAI_API_KEY;
179
+ if (!key) return null;
180
+ const { signal, cancel } = makeAbortable();
181
+ try {
182
+ const r = await fetchImpl('https://api.openai.com/v1/models', {
183
+ headers: { 'Authorization': `Bearer ${key}` },
184
+ signal,
185
+ });
186
+ if (!r.ok) return null;
187
+ const json = await r.json();
188
+ const list = Array.isArray(json.data) ? json.data : [];
189
+ const candidates = list
190
+ .filter(m => typeof m.id === 'string')
191
+ .filter(m => /^gpt-[5-9]/.test(m.id)) // gpt-5+ family
192
+ .filter(m => !/preview|deprecated|alpha/i.test(m.id))
193
+ .sort((a, b) => (b.created || 0) - (a.created || 0));
194
+ return candidates[0] ? { id: candidates[0].id } : null;
195
+ } catch {
196
+ return null;
197
+ } finally {
198
+ cancel();
199
+ }
200
+ }
201
+
202
+ async function probeAnthropic(env, fetchImpl) {
203
+ const key = env.ANTHROPIC_API_KEY;
204
+ if (!key) return null;
205
+ const { signal, cancel } = makeAbortable();
206
+ try {
207
+ const r = await fetchImpl('https://api.anthropic.com/v1/models', {
208
+ headers: {
209
+ 'x-api-key': key,
210
+ 'anthropic-version': '2023-06-01',
211
+ },
212
+ signal,
213
+ });
214
+ if (!r.ok) return null;
215
+ const json = await r.json();
216
+ const list = Array.isArray(json.data) ? json.data : [];
217
+ // Pick latest haiku (lowest-cost fallback for API path; quality is
218
+ // bounded by the use case — apiFallback is for short audit JSON).
219
+ const candidates = list
220
+ .filter(m => typeof m.id === 'string' && /haiku/i.test(m.id))
221
+ .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
222
+ return candidates[0] ? { id: candidates[0].id } : null;
223
+ } catch {
224
+ return null;
225
+ } finally {
226
+ cancel();
227
+ }
228
+ }
229
+
230
+ async function probeGoogle(env, fetchImpl) {
231
+ const key = env.GEMINI_API_KEY;
232
+ if (!key) return null;
233
+ const { signal, cancel } = makeAbortable();
234
+ try {
235
+ const r = await fetchImpl(
236
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`,
237
+ { signal },
238
+ );
239
+ if (!r.ok) return null;
240
+ const json = await r.json();
241
+ const list = Array.isArray(json.models) ? json.models : [];
242
+ const candidates = list
243
+ .map(m => typeof m.name === 'string' ? m.name.replace(/^models\//, '') : '')
244
+ .filter(Boolean)
245
+ .filter(n => n.startsWith('gemini-'))
246
+ .filter(n => /-pro\b/.test(n)) // prefer pro tier
247
+ .filter(n => !/preview|exp|deprecated/i.test(n))
248
+ .sort(compareModelIds); // semver-aware: gemini-10.0 > gemini-2.5
249
+ const pick = candidates[candidates.length - 1];
250
+ return pick ? { id: pick } : null;
251
+ } catch {
252
+ return null;
253
+ } finally {
254
+ cancel();
255
+ }
256
+ }
257
+
258
+ // Refresh all three families. Preserves existing entries when a probe
259
+ // fails so a transient outage doesn't wipe a good cache. Returns the
260
+ // updated cache payload.
261
+ export async function refreshModelCache({ env = process.env, now = Date.now(), fetch: fetchImpl } = {}) {
262
+ const fetcher = fetchImpl || globalThis.fetch;
263
+ if (typeof fetcher !== 'function') {
264
+ // Older node without global fetch — keep prior cache intact.
265
+ return readCache(env);
266
+ }
267
+
268
+ const prior = readCache(env) || { schema: CACHE_SCHEMA, updated_at: null, models: {} };
269
+ const next = { schema: CACHE_SCHEMA, updated_at: new Date(now).toISOString(), models: { ...prior.models } };
270
+ const ts = next.updated_at;
271
+
272
+ const probes = await Promise.all([
273
+ probeOpenAI(env, fetcher).then(r => ({ family: 'openai', r })),
274
+ probeAnthropic(env, fetcher).then(r => ({ family: 'anthropic', r })),
275
+ probeGoogle(env, fetcher).then(r => ({ family: 'google', r })),
276
+ ]);
277
+
278
+ for (const { family, r } of probes) {
279
+ if (r && r.id) {
280
+ next.models[family] = { id: r.id, checked_at: ts };
281
+ }
282
+ // else: leave prior entry in place (or absent — caller falls back to
283
+ // HARDCODED_FALLBACKS via getLatestModel).
284
+ }
285
+
286
+ writeCache(next, env);
287
+ return next;
288
+ }
289
+
290
+ // Test helper — reset the in-flight guard so unit tests can drive refreshes
291
+ // back-to-back without leak.
292
+ export function _resetInflight() { inflightRefresh = null; }