@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,205 @@
1
+ // playwright-baseline.js -- v1.5.0 audit-MED-design-#6.
2
+ //
3
+ // Visual-regression baseline for the ijfw-ui-auditor pipeline. Stores
4
+ // reference screenshots under `.planning/visual-baseline/<phase>/<surface>.png`
5
+ // and compares subsequent snapshots against them.
6
+ //
7
+ // Playwright is treated as an optional peer. IJFW core stays zero-dep, so this
8
+ // file works in three modes:
9
+ //
10
+ // 1. capture(): caller passes a Buffer (PNG bytes). We just write it to disk
11
+ // with atomic rename. No Playwright needed.
12
+ //
13
+ // 2. compare(): caller passes the new Buffer (or path). We byte-diff against
14
+ // baseline. Returns {diffPercent: 0|100, status: 'identical'|'changed'|...}
15
+ // when no perceptual diff peer is installed. If `opts.pixelmatch` is
16
+ // injected (callers can require pixelmatch + pngjs themselves), we use the
17
+ // real per-pixel ratio.
18
+ //
19
+ // 3. createBaseline()/compareToBaseline(): high-level helpers used by the
20
+ // auditor agent. Auto-derive the baseline path from {phase, surface}.
21
+ //
22
+ // Graceful no-op without Playwright: createBaseline() simply records "no
23
+ // snapshot supplied -- baseline deferred"; compareToBaseline() returns
24
+ // {pass: null, reason: 'no-snapshot'} so the auditor can mark FLAG instead of
25
+ // hard-failing.
26
+
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ writeFileSync,
32
+ renameSync,
33
+ unlinkSync,
34
+ } from 'node:fs';
35
+ import { dirname, join } from 'node:path';
36
+ import { createHash, randomBytes } from 'node:crypto';
37
+
38
+ const DEFAULT_BASELINE_ROOT = '.planning/visual-baseline';
39
+ const DEFAULT_DIFF_THRESHOLD = 1.0; // % of differing pixels above which fail
40
+
41
+ /**
42
+ * Resolve the baseline path for a (phase, surface) pair.
43
+ *
44
+ * @param {object} opts
45
+ * @param {string} opts.phase
46
+ * @param {string} opts.surface
47
+ * @param {string} [opts.root] default .planning/visual-baseline
48
+ * @param {string} [opts.projectRoot] default cwd
49
+ */
50
+ export function baselinePath(opts) {
51
+ const phase = sanitizeSegment(opts.phase || 'unspecified');
52
+ const surface = sanitizeSegment(opts.surface || 'default');
53
+ const root = opts.root || DEFAULT_BASELINE_ROOT;
54
+ const projectRoot = opts.projectRoot || process.cwd();
55
+ return join(projectRoot, root, phase, `${surface}.png`);
56
+ }
57
+
58
+ /**
59
+ * Write a baseline screenshot. Caller is responsible for capturing the bytes
60
+ * (e.g. via `await page.screenshot()` in Playwright).
61
+ *
62
+ * @param {object} opts
63
+ * @param {Buffer|null} opts.png PNG bytes; null = "Playwright unavailable, skip"
64
+ * @param {string} opts.phase
65
+ * @param {string} opts.surface
66
+ * @param {string} [opts.root]
67
+ * @param {string} [opts.projectRoot]
68
+ * @returns {{ok: boolean, path: string|null, reason: string}}
69
+ */
70
+ export function createBaseline(opts) {
71
+ const target = baselinePath(opts);
72
+ if (!opts.png || !(opts.png instanceof Uint8Array)) {
73
+ return { ok: false, path: target, reason: 'no-snapshot' };
74
+ }
75
+ try {
76
+ if (!existsSync(dirname(target))) mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
77
+ // Binary-safe atomic write: tmp file + rename. We can't use writeAtomic
78
+ // from lib/atomic-io.js because it JSON-stringifies non-string payloads.
79
+ const tmp = `${target}.tmp.${randomBytes(6).toString('hex')}`;
80
+ try {
81
+ writeFileSync(tmp, opts.png, { mode: 0o644 });
82
+ renameSync(tmp, target);
83
+ } catch (e) {
84
+ try { unlinkSync(tmp); } catch { /* */ }
85
+ throw e;
86
+ }
87
+ return { ok: true, path: target, reason: 'baseline-written' };
88
+ } catch (e) {
89
+ return { ok: false, path: target, reason: `write-failed: ${e.code || e.message}` };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Compare a candidate PNG against the stored baseline.
95
+ *
96
+ * @param {object} opts
97
+ * @param {Buffer|null} opts.png Candidate bytes.
98
+ * @param {string} opts.phase
99
+ * @param {string} opts.surface
100
+ * @param {string} [opts.root]
101
+ * @param {string} [opts.projectRoot]
102
+ * @param {number} [opts.threshold] Allowed diff %; default 1.0.
103
+ * @param {Function} [opts.pixelmatch] Optional injected pixelmatch fn.
104
+ * @param {Function} [opts.pngParser] Optional injected PNG parser (e.g. require('pngjs').PNG.sync.read)
105
+ * @returns {{pass: boolean|null, diffPercent: number|null, baselinePath: string, reason: string}}
106
+ */
107
+ export function compareToBaseline(opts) {
108
+ const baseline = baselinePath(opts);
109
+ const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_DIFF_THRESHOLD;
110
+
111
+ if (!opts.png || !(opts.png instanceof Uint8Array)) {
112
+ return { pass: null, diffPercent: null, baselinePath: baseline, reason: 'no-snapshot' };
113
+ }
114
+ if (!existsSync(baseline)) {
115
+ return { pass: null, diffPercent: null, baselinePath: baseline, reason: 'baseline-missing' };
116
+ }
117
+
118
+ let baselineBytes;
119
+ try {
120
+ baselineBytes = readFileSync(baseline);
121
+ } catch (e) {
122
+ return { pass: null, diffPercent: null, baselinePath: baseline, reason: `baseline-read-failed: ${e.code || e.message}` };
123
+ }
124
+
125
+ // Fast path: byte-identical.
126
+ if (bytesEqual(baselineBytes, opts.png)) {
127
+ return { pass: true, diffPercent: 0, baselinePath: baseline, reason: 'identical' };
128
+ }
129
+
130
+ // Hash path: hashes differ → at least 1 byte differs → 100% naive diff.
131
+ // Unless a real per-pixel differ is injected.
132
+ if (typeof opts.pixelmatch === 'function' && typeof opts.pngParser === 'function') {
133
+ try {
134
+ const baseImg = opts.pngParser(baselineBytes);
135
+ const candImg = opts.pngParser(opts.png);
136
+ if (baseImg.width !== candImg.width || baseImg.height !== candImg.height) {
137
+ return {
138
+ pass: false,
139
+ diffPercent: 100,
140
+ baselinePath: baseline,
141
+ reason: `dimension-mismatch: ${baseImg.width}x${baseImg.height} vs ${candImg.width}x${candImg.height}`,
142
+ };
143
+ }
144
+ const diffBuffer = new Uint8Array(baseImg.data.length);
145
+ const diffPixels = opts.pixelmatch(
146
+ baseImg.data,
147
+ candImg.data,
148
+ diffBuffer,
149
+ baseImg.width,
150
+ baseImg.height,
151
+ { threshold: 0.1 },
152
+ );
153
+ const total = baseImg.width * baseImg.height;
154
+ const diffPercent = total === 0 ? 0 : (diffPixels / total) * 100;
155
+ const pass = diffPercent <= threshold;
156
+ return {
157
+ pass,
158
+ diffPercent: Math.round(diffPercent * 100) / 100,
159
+ baselinePath: baseline,
160
+ reason: pass ? 'within-threshold' : `diff ${diffPercent.toFixed(2)}% > threshold ${threshold}%`,
161
+ };
162
+ } catch {
163
+ // Fall through to hash-based fallback.
164
+ }
165
+ }
166
+
167
+ // Hash fallback.
168
+ const baseHash = createHash('sha256').update(baselineBytes).digest('hex');
169
+ const candHash = createHash('sha256').update(opts.png).digest('hex');
170
+ return {
171
+ pass: false,
172
+ diffPercent: 100,
173
+ baselinePath: baseline,
174
+ reason: `hash-mismatch (no per-pixel differ installed): ${baseHash.slice(0, 8)} vs ${candHash.slice(0, 8)}`,
175
+ };
176
+ }
177
+
178
+ function bytesEqual(a, b) {
179
+ if (a.length !== b.length) return false;
180
+ for (let i = 0; i < a.length; i += 1) {
181
+ if (a[i] !== b[i]) return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ function sanitizeSegment(s) {
187
+ return String(s)
188
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
189
+ .replace(/^-+|-+$/g, '')
190
+ .slice(0, 80) || 'unspecified';
191
+ }
192
+
193
+ /**
194
+ * Build the auditor prompt fragment for the snapshot capture step.
195
+ */
196
+ export function playwrightCapturePromptFor(url, phase, surface) {
197
+ return [
198
+ 'Playwright (optional peer): if installed, run',
199
+ " npx playwright screenshot " + JSON.stringify(url) + " /tmp/<surface>.png --full-page",
200
+ `Then call createBaseline({ phase: '${phase}', surface: '${surface}', png: <bytes> })`,
201
+ 'from mcp-server/src/lib/playwright-baseline.js.',
202
+ 'If Playwright is missing, leave the baseline unset; the auditor will FLAG',
203
+ 'rather than BLOCK on missing-snapshot.',
204
+ ].join('\n');
205
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * rekor-bridge.js — IJFW v1.5.0 audit-H5.7 Sigstore Rekor transparency log.
3
+ *
4
+ * Closes the meta-key-compromise gap from v1.4.1. When the optional
5
+ * `@sigstore/rekor` peer dep is present (or `IJFW_REKOR_URL` is set for a
6
+ * self-hosted Rekor instance), the registry signer pushes the
7
+ * `{payload, signature, publicKey}` triple to Rekor's append-only public
8
+ * transparency log on sign. Downstream verifiers cross-check the registry's
9
+ * embedded Rekor entry against the live log on verify — an attacker who
10
+ * swaps the meta-key cannot backdate a Rekor entry, so the swap is detectable.
11
+ *
12
+ * Three principles:
13
+ * 1. Graceful no-op. If the peer dep is missing, every function returns null
14
+ * or false in a way the caller can ignore. Ed25519 signature verification
15
+ * remains the primary trust check.
16
+ * 2. Never throw. submitToRekor and verifyRekorEntry catch all errors,
17
+ * emit a stderr advisory, and return null. The caller decides whether
18
+ * to proceed.
19
+ * 3. Backcompat. Unsigned-by-Rekor registries (signed before this lift) still
20
+ * verify on the Ed25519 check alone — the cross-check fires only when
21
+ * both a `rekor` field is embedded AND a local Rekor client is available.
22
+ *
23
+ * Threat model (v1.4.1 → v1.5.0):
24
+ * v1.4.1 shipped Ed25519 publisher signing + meta-key rotation. But if an
25
+ * attacker compromises the meta-key, downstream installs cannot detect the
26
+ * swap — there is no append-only third-party witness. Rekor provides that
27
+ * witness: every legitimate registry sign is also pushed to a public log,
28
+ * so an attacker who later swaps the meta-key would have to either
29
+ * (a) push a tampered registry to Rekor with an entry that doesn't
30
+ * match any prior entry — clients see the registry's rekor field
31
+ * contains a uuid that resolves to a payload not matching the
32
+ * served registry, OR
33
+ * (b) try to backdate Rekor entries, which is cryptographically
34
+ * impossible (Merkle tree append-only).
35
+ *
36
+ * @see https://docs.sigstore.dev/rekor/overview/
37
+ */
38
+
39
+ import { createHash } from 'node:crypto';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Client cache: avoid re-importing the peer dep on every call.
43
+ // __setRekorClientForTest replaces the cache for unit tests; resolveClient
44
+ // honors the test override before attempting the real import.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ let _cachedClient = undefined; // undefined = not-yet-probed, null = unavailable
48
+ let _testClient = null; // explicit test-only override
49
+
50
+ /**
51
+ * Test-only seam: inject a stub Rekor client.
52
+ *
53
+ * The stub must implement:
54
+ * - createEntry({ payload, signature, publicKey }) → Promise<{ uuid, logIndex, integratedTime }>
55
+ * - getEntry({ uuid }) → Promise<{ payloadHash: string }>
56
+ *
57
+ * Where `payloadHash` is the sha256-hex of the payload that was originally
58
+ * submitted to Rekor (so verifyRekorEntry can compare hashes without re-fetching
59
+ * the entire payload from the log).
60
+ *
61
+ * Pass `null` to clear the override and resume normal probe behavior.
62
+ *
63
+ * @param {object|null} stub
64
+ */
65
+ export function __setRekorClientForTest(stub) {
66
+ _testClient = stub;
67
+ // Reset cache so the next call re-resolves through the test override.
68
+ _cachedClient = undefined;
69
+ }
70
+
71
+ /**
72
+ * Resolve a Rekor client, preferring (in order):
73
+ * 1. The explicit test override (`__setRekorClientForTest`).
74
+ * 2. A dynamic import of `@sigstore/rekor` if it's installed as a peer dep.
75
+ * 3. null when neither is available.
76
+ *
77
+ * The result is cached for the process lifetime (test overrides bust the cache).
78
+ *
79
+ * @returns {Promise<object|null>}
80
+ */
81
+ async function resolveClient() {
82
+ if (_testClient !== null) return _testClient;
83
+ if (_cachedClient !== undefined) return _cachedClient;
84
+ try {
85
+ const mod = await import('@sigstore/rekor');
86
+ // Accept either a named `RekorClient` constructor or a default export.
87
+ const ClientCtor =
88
+ (mod && (mod.RekorClient || (mod.default && mod.default.RekorClient))) || null;
89
+ if (typeof ClientCtor !== 'function') {
90
+ _cachedClient = null;
91
+ return null;
92
+ }
93
+ const baseURL =
94
+ typeof process.env.IJFW_REKOR_URL === 'string' && process.env.IJFW_REKOR_URL.length > 0
95
+ ? process.env.IJFW_REKOR_URL
96
+ : 'https://rekor.sigstore.dev';
97
+ _cachedClient = new ClientCtor({ baseURL });
98
+ return _cachedClient;
99
+ } catch {
100
+ _cachedClient = null;
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Whether a Rekor client is available. Synchronous in spec but the underlying
107
+ * resolution is async (dynamic import); we expose an async probe that the
108
+ * caller awaits. Returns true when either the peer dep is installed or a
109
+ * test stub has been injected.
110
+ *
111
+ * @returns {Promise<boolean>}
112
+ */
113
+ export async function hasRekorClient() {
114
+ const client = await resolveClient();
115
+ return client !== null;
116
+ }
117
+
118
+ /**
119
+ * Submit a `{payload, signature, publicKey}` triple to the configured Rekor
120
+ * instance. The payload is the canonical signing bytes that the upstream
121
+ * Ed25519 signature was computed over (NOT the serialized registry).
122
+ *
123
+ * Returns the Rekor entry handle on success, or null on any failure
124
+ * (no client, network error, malformed response, unexpected exception).
125
+ * NEVER throws.
126
+ *
127
+ * Stderr advisory is emitted on failure so operators see when a sign-time
128
+ * Rekor anchor was attempted but skipped — important for audit trails when
129
+ * a registry ships without a `rekor` field.
130
+ *
131
+ * @param {object} args
132
+ * @param {Buffer|string} args.payload canonical bytes of the signed body
133
+ * @param {string} args.signature Ed25519 signature string (e.g. "ed25519:<b64>")
134
+ * @param {string} args.publicKey PEM-encoded SPKI public key
135
+ * @returns {Promise<{ uuid: string, logIndex: number, integratedTime: number } | null>}
136
+ */
137
+ export async function submitToRekor({ payload, signature, publicKey } = {}) {
138
+ const client = await resolveClient();
139
+ if (client === null) return null;
140
+ if (payload === undefined || signature === undefined || publicKey === undefined) {
141
+ advise('rekor: submit called without payload/signature/publicKey — skipping');
142
+ return null;
143
+ }
144
+ try {
145
+ const entry = await client.createEntry({ payload, signature, publicKey });
146
+ if (
147
+ !entry ||
148
+ typeof entry.uuid !== 'string' ||
149
+ typeof entry.logIndex !== 'number' ||
150
+ typeof entry.integratedTime !== 'number'
151
+ ) {
152
+ advise('rekor: createEntry returned malformed response — skipping anchor');
153
+ return null;
154
+ }
155
+ return {
156
+ uuid: entry.uuid,
157
+ logIndex: entry.logIndex,
158
+ integratedTime: entry.integratedTime,
159
+ };
160
+ } catch (err) {
161
+ advise(`rekor: submit failed — ${err.message || 'unknown error'}`);
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Verify that a Rekor entry's recorded payload hash matches the locally
168
+ * canonicalized payload. This is the detection mechanism for meta-key
169
+ * compromise: if an attacker swaps the meta-key but re-uses an old Rekor
170
+ * uuid, the payload hashes will not match and verification fails.
171
+ *
172
+ * Return values:
173
+ * - true — entry exists in Rekor AND its payload hash matches.
174
+ * - false — entry exists but payload hash MISMATCH (tamper detected, REJECT).
175
+ * - null — client unavailable OR entry lookup failed. Caller should fall
176
+ * back to the Ed25519 check alone (backcompat).
177
+ *
178
+ * NEVER throws.
179
+ *
180
+ * @param {object} args
181
+ * @param {string} args.uuid Rekor entry uuid (from registry's embedded rekor field)
182
+ * @param {Buffer|string} args.payload canonical bytes the entry should attest to
183
+ * @returns {Promise<boolean|null>}
184
+ */
185
+ export async function verifyRekorEntry({ uuid, payload } = {}) {
186
+ const client = await resolveClient();
187
+ if (client === null) return null;
188
+ if (typeof uuid !== 'string' || uuid.length === 0 || payload === undefined) {
189
+ advise('rekor: verify called without uuid/payload — skipping');
190
+ return null;
191
+ }
192
+ let entry;
193
+ try {
194
+ entry = await client.getEntry({ uuid });
195
+ } catch (err) {
196
+ advise(`rekor: getEntry failed — ${err.message || 'unknown error'}`);
197
+ return null;
198
+ }
199
+ if (!entry || typeof entry.payloadHash !== 'string' || entry.payloadHash.length === 0) {
200
+ advise('rekor: getEntry returned no payloadHash — cannot cross-check');
201
+ return null;
202
+ }
203
+ const localHash = createHash('sha256')
204
+ .update(typeof payload === 'string' ? Buffer.from(payload, 'utf8') : payload)
205
+ .digest('hex');
206
+ return localHash === entry.payloadHash.toLowerCase();
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Internal: stderr advisory. Always one-shot, never duplicated. Silenceable
211
+ // via IJFW_REKOR_QUIET=1 for noisy test environments.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function advise(message) {
215
+ if (process.env.IJFW_REKOR_QUIET === '1') return;
216
+ try {
217
+ process.stderr.write(`[ijfw] ${message}\n`);
218
+ } catch {
219
+ /* best-effort */
220
+ }
221
+ }