@ijfw/memory-server 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  23. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  24. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  26. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  27. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  28. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  29. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  47. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  48. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  49. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  50. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  52. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  53. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  54. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  68. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  69. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  71. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  72. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  73. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  74. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  92. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  93. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  94. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  95. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  96. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  97. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  98. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  99. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  115. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  116. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  117. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  118. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  119. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  120. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  121. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  122. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  123. package/package.json +6 -3
  124. package/src/active-extension-writer.js +144 -64
  125. package/src/api-client.js +43 -5
  126. package/src/audit-roster.js +80 -5
  127. package/src/blackboard.js +298 -6
  128. package/src/cli-run.js +33 -5
  129. package/src/codex-agents.js +96 -5
  130. package/src/cost/aggregator.js +39 -9
  131. package/src/cost/pricing.js +57 -0
  132. package/src/cost/readers/gemini.js +1 -1
  133. package/src/cross-audit-chunker.js +189 -0
  134. package/src/cross-dispatcher.js +124 -21
  135. package/src/cross-orchestrator-cli.js +754 -159
  136. package/src/cross-orchestrator.js +1065 -17
  137. package/src/cross-project-search.js +195 -9
  138. package/src/dashboard-client-waves.html +304 -0
  139. package/src/dashboard-client.html +5 -1
  140. package/src/dashboard-server.js +73 -0
  141. package/src/deploy-alerts.js +150 -0
  142. package/src/design/iframe-bridge.js +242 -0
  143. package/src/design-companion.js +144 -0
  144. package/src/dispatch/checkpoint-cli.js +97 -0
  145. package/src/dispatch/colon-syntax.js +81 -1
  146. package/src/dispatch/extension.js +26 -2
  147. package/src/dispatch/registry-cli.js +4 -1
  148. package/src/dispatch/wave-cli.js +201 -6
  149. package/src/dispatch/worktree-cli.js +40 -0
  150. package/src/dispatch-planner.js +97 -2
  151. package/src/dream/runner.mjs +47 -11
  152. package/src/dream/stage-runner.js +40 -0
  153. package/src/dream/state-file.js +102 -0
  154. package/src/extension-installer.js +70 -24
  155. package/src/extension-quota-tracker.js +4 -2
  156. package/src/extension-registry.js +289 -35
  157. package/src/feedback-detector.js +26 -0
  158. package/src/fs-lock.js +259 -7
  159. package/src/gate-result.js +95 -1
  160. package/src/hardware-signer.js +4 -2
  161. package/src/hero-line.js +86 -5
  162. package/src/intent-router.js +35 -0
  163. package/src/lib/a11y-contract.js +117 -0
  164. package/src/lib/atomic-io.js +29 -8
  165. package/src/lib/cache-keepalive.js +150 -0
  166. package/src/lib/jsonl-rotation.js +104 -0
  167. package/src/lib/lighthouse-pillar.js +121 -0
  168. package/src/lib/llm-call.js +121 -0
  169. package/src/lib/playwright-baseline.js +205 -0
  170. package/src/lib/rekor-bridge.js +221 -0
  171. package/src/lib/repo-map.js +392 -0
  172. package/src/lib/shasum-verify.js +164 -0
  173. package/src/lib/sketches-gc.js +132 -0
  174. package/src/lib/tmp-suffix.js +62 -0
  175. package/src/lib/ui-review-runner.js +595 -0
  176. package/src/lib/uispec-drift.js +301 -0
  177. package/src/lib/uispec-intake.js +381 -0
  178. package/src/lib/worktree-guards.js +118 -0
  179. package/src/lib/worktree-recovery.js +100 -0
  180. package/src/memory/auto-linker.js +267 -0
  181. package/src/memory/benchmark.js +498 -0
  182. package/src/memory/dedup.js +126 -0
  183. package/src/memory/embedding-cache.js +136 -0
  184. package/src/memory/fact-extractor.js +168 -0
  185. package/src/memory/fts5.js +65 -1
  186. package/src/memory/migration-runner.js +6 -1
  187. package/src/memory/migrations/004-bitemporal.js +91 -0
  188. package/src/memory/migrations/005-vector-cache.js +61 -0
  189. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  190. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  191. package/src/memory/migrations/008-write-provenance.js +41 -0
  192. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  193. package/src/memory/obsidian-parser.js +152 -0
  194. package/src/memory/query-dataview.js +86 -0
  195. package/src/memory/search.js +46 -15
  196. package/src/memory/temporal.js +529 -0
  197. package/src/memory/tokenize.js +10 -0
  198. package/src/memory-facts-handler.js +37 -0
  199. package/src/memory-feedback.js +260 -2
  200. package/src/model-refresh.js +292 -0
  201. package/src/observability/cost-anomaly.js +166 -0
  202. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  203. package/src/observability/trace-id.js +163 -0
  204. package/src/orchestrator/agents-md-blackboard.js +152 -0
  205. package/src/orchestrator/checkpoint-contract.md +140 -0
  206. package/src/orchestrator/debug-trident-trigger.js +374 -0
  207. package/src/orchestrator/debug-trident.js +570 -0
  208. package/src/orchestrator/merge-block-aware.js +350 -0
  209. package/src/orchestrator/plan-checker.js +475 -0
  210. package/src/orchestrator/post-done-runner.js +277 -0
  211. package/src/orchestrator/review.js +38 -3
  212. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  213. package/src/orchestrator/skill-telemetry.js +37 -0
  214. package/src/orchestrator/state-events.js +459 -0
  215. package/src/orchestrator/state-sdk.js +1932 -0
  216. package/src/orchestrator/status-protocol.js +84 -17
  217. package/src/orchestrator/subagent-telemetry.js +471 -0
  218. package/src/orchestrator/termination.js +160 -0
  219. package/src/orchestrator/verification-gate.js +200 -16
  220. package/src/orchestrator/wave-state.js +332 -23
  221. package/src/orchestrator/worktree-provision.js +77 -0
  222. package/src/override-resolver.js +5 -3
  223. package/src/override-use-registry.js +111 -5
  224. package/src/receipts.js +36 -4
  225. package/src/recovery/checkpoint.js +56 -3
  226. package/src/recovery/code-fixer.js +961 -0
  227. package/src/recovery/truncation.js +317 -0
  228. package/src/redactor.js +75 -6
  229. package/src/runtime-mediator.js +15 -1
  230. package/src/sanitizer.js +10 -0
  231. package/src/search-hybrid.js +139 -0
  232. package/src/server.js +795 -112
  233. package/src/swarm/worktree.js +27 -4
  234. package/src/swarm-config.js +102 -17
  235. package/src/team/domain-templates/book.json +51 -0
  236. package/src/team/domain-templates/business.json +44 -0
  237. package/src/team/domain-templates/content.json +50 -0
  238. package/src/team/domain-templates/design.json +44 -0
  239. package/src/team/domain-templates/research.json +44 -0
  240. package/src/team/domain-templates/software.json +40 -0
  241. package/src/team/generator.js +440 -3
  242. package/src/team/modify.js +203 -0
  243. package/src/team/schemas.js +48 -0
  244. package/src/update-apply.js +19 -3
  245. package/src/dashboard-charts.js +0 -239
@@ -0,0 +1,62 @@
1
+ // v1.5.0 audit-LOW-update-#13: single helper for atomic-write tmp-suffix generation.
2
+ //
3
+ // Before this module, six call sites hand-rolled essentially the same pattern:
4
+ // `${path}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`
5
+ // or:
6
+ // `${path}.tmp.${randomBytes(4).toString('hex')}`
7
+ // or:
8
+ // `${path}.tmp-${process.pid}-${Date.now()}`
9
+ //
10
+ // The variations carry no semantic difference -- all three are "unique-enough
11
+ // per-process collision-resistant temporary filename" -- but the drift made
12
+ // audit + diff review noisy and easy to typo. This helper consolidates the
13
+ // pattern and standardises the wide form (pid + 8 random bytes hex) so all
14
+ // atomic writes share one collision-resistance budget.
15
+ //
16
+ // Backwards-compat: existing tmp-files that don't follow this exact pattern
17
+ // are STILL cleaned up by their callers via the same `if (existsSync(tmp))
18
+ // unlinkSync(tmp)` rollback path -- this helper only changes the naming
19
+ // scheme on new writes, not the rollback contract.
20
+
21
+ import { randomBytes } from 'node:crypto';
22
+
23
+ /**
24
+ * Build a temporary-file suffix for atomic writes (write-tmp + rename).
25
+ *
26
+ * @param {object} [opts]
27
+ * @param {number} [opts.bytes=8] Number of random bytes in the suffix (each
28
+ * byte becomes 2 hex chars). 8 bytes = 64 bits
29
+ * of entropy = 2^32 writes per pid before a
30
+ * 50% collision probability under the birthday
31
+ * bound — well past any plausible per-process
32
+ * rate. 4 supported for legacy parity.
33
+ * @param {boolean} [opts.includePid=true] Prefix with `<pid>.` to make stray
34
+ * tmp leftovers diagnosable to the owning
35
+ * process. Disable only if pid would be
36
+ * privacy-sensitive (none of IJFW's writes
37
+ * qualify, hence default true).
38
+ * @returns {string} suffix WITHOUT a leading `.tmp.` — caller composes the
39
+ * full tmp path (kept that way so callers can pick `.tmp.`,
40
+ * `.tmp-`, or any other separator the surrounding code uses).
41
+ */
42
+ export function tmpSuffix(opts = {}) {
43
+ const bytes = Number.isFinite(opts.bytes) && opts.bytes > 0 ? Math.floor(opts.bytes) : 8;
44
+ const includePid = opts.includePid !== false;
45
+ const rand = randomBytes(bytes).toString('hex');
46
+ return includePid ? `${process.pid}.${rand}` : rand;
47
+ }
48
+
49
+ /**
50
+ * Convenience: build the full tmp path for a target file using the canonical
51
+ * `.tmp.<pid>.<random>` shape. Use this when you want exactly the standard
52
+ * pattern; for non-standard separators (e.g. existing call sites that use
53
+ * `.tmp-<pid>-<date>`) keep building the path inline + only call tmpSuffix()
54
+ * for the random portion.
55
+ *
56
+ * @param {string} targetPath
57
+ * @param {object} [opts] Forwarded to tmpSuffix().
58
+ * @returns {string} `${targetPath}.tmp.${suffix}`
59
+ */
60
+ export function tmpPathFor(targetPath, opts = {}) {
61
+ return `${targetPath}.tmp.${tmpSuffix(opts)}`;
62
+ }
@@ -0,0 +1,595 @@
1
+ // ui-review-runner.js -- v1.5.0 wire-W1.D + W1.E (v1.5.1 W2.A: intake wired).
2
+ //
3
+ // Production wire-up for the 6 design libs (uispec-intake, uispec-drift,
4
+ // a11y-contract, lighthouse-pillar, playwright-baseline, sketches-gc) plus
5
+ // the 7-pillar visual audit declared in `claude/agents/ijfw-ui-auditor.md`.
6
+ // All 6 are imported below; the import list IS the canonical wiring count —
7
+ // docstring and imports must move together (v1.5.1 W2.A audit finding).
8
+ //
9
+ // Before W1.D these libraries shipped with isolated tests but ZERO callers.
10
+ // The auditor agent's "wave dispatch one subagent per pillar" was declared
11
+ // in markdown but never implemented in runtime. This runner closes both
12
+ // gaps:
13
+ //
14
+ // - Builds a per-pillar grader function that calls into the relevant lib.
15
+ // - Dispatches all 7 graders in parallel via Promise.all (W1.E).
16
+ // - Assembles a single UI-REVIEW.md from the per-pillar verdicts.
17
+ // - Returns a structured result so the caller can branch on top-level
18
+ // verdict (PASS / FLAG / BLOCK) without re-parsing markdown.
19
+ //
20
+ // Each grader function is intentionally small + boundary-pure: it reads
21
+ // from `spec` + `sourceScope` + `peerInputs` and emits
22
+ // `{ pillar, verdict, findings, startedAt, finishedAt, evidence?, reason? }`.
23
+ // Heavy lifting (Lighthouse, axe, Playwright) is done OUT-OF-PROCESS by
24
+ // peer tools (chrome-devtools-mcp's lighthouse_audit / axe runner, the
25
+ // user's own Playwright). The runner consumes the peer outputs via
26
+ // `peerInputs` and adapts them through the evaluator libs.
27
+
28
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
29
+ import { join, dirname, extname } from 'node:path';
30
+ import {
31
+ parseUISpec,
32
+ scanCodeForTailwind,
33
+ diffPaletteDrift,
34
+ } from './uispec-drift.js';
35
+ import {
36
+ evaluateA11y,
37
+ DEFAULT_A11Y_TARGET,
38
+ DEFAULT_MAX_VIOLATIONS,
39
+ } from './a11y-contract.js';
40
+ import { evaluateLighthouse, LIGHTHOUSE_THRESHOLDS } from './lighthouse-pillar.js';
41
+ import { compareToBaseline } from './playwright-baseline.js';
42
+ import { runSketchesGc } from './sketches-gc.js';
43
+ import { fromImage, fromFigma } from './uispec-intake.js';
44
+
45
+ // Pillar order is canonical -- the auditor agent spec enumerates them in
46
+ // this exact sequence. The runner emits per-pillar sections in the same
47
+ // order so the rendered UI-REVIEW.md is stable across runs.
48
+ export const PILLARS = Object.freeze([
49
+ 'layout',
50
+ 'typography',
51
+ 'color',
52
+ 'spacing',
53
+ 'components',
54
+ 'interaction',
55
+ 'security',
56
+ ]);
57
+
58
+ const PILLAR_TITLES = Object.freeze({
59
+ layout: '1. Layout & Hierarchy',
60
+ typography: '2. Typography & Reading Flow',
61
+ color: '3. Color & Contrast',
62
+ spacing: '4. Spacing & Rhythm',
63
+ components: '5. Component Consistency',
64
+ interaction: '6. Interaction & Motion',
65
+ security: '7. Security & Headers',
66
+ });
67
+
68
+ const VERDICT_PASS = 'PASS';
69
+ const VERDICT_FLAG = 'FLAG';
70
+ const VERDICT_BLOCK = 'BLOCK';
71
+ const VERDICT_MISSING = 'spec-section-missing';
72
+
73
+ // Source-walking — same extension set as the auditor agent's `find` step.
74
+ const SOURCE_EXTS = new Set([
75
+ '.tsx', '.jsx', '.ts', '.js', '.css', '.scss',
76
+ '.html', '.vue', '.svelte', '.md', '.mdx',
77
+ ]);
78
+
79
+ function walkSourceFiles(scopes, projectRoot, opts = {}) {
80
+ const maxFiles = typeof opts.maxFiles === 'number' ? opts.maxFiles : 2000;
81
+ const out = [];
82
+ for (const scope of scopes) {
83
+ const abs = scope.startsWith('/') ? scope : join(projectRoot, scope);
84
+ if (!existsSync(abs)) continue;
85
+ const stack = [abs];
86
+ while (stack.length > 0 && out.length < maxFiles) {
87
+ const dir = stack.pop();
88
+ let entries;
89
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
90
+ for (const ent of entries) {
91
+ if (ent.name.startsWith('.')) continue;
92
+ if (ent.name === 'node_modules') continue;
93
+ const p = join(dir, ent.name);
94
+ if (ent.isDirectory()) { stack.push(p); continue; }
95
+ if (!ent.isFile()) continue;
96
+ if (!SOURCE_EXTS.has(extname(ent.name).toLowerCase())) continue;
97
+ out.push(p);
98
+ if (out.length >= maxFiles) break;
99
+ }
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function readSafe(file) {
106
+ try { return readFileSync(file, 'utf8'); } catch { return ''; }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Pillar graders. Each returns the same shape:
111
+ // { pillar, verdict, findings, evidence?, reason?, startedAt, finishedAt }
112
+ // findings: Array<{ severity, text, file?, line? }>
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function gradeLayout({ spec, files, projectRoot: _projectRoot }) {
116
+ const startedAt = Date.now();
117
+ const findings = [];
118
+ // Surface presence is hard to derive from a spec we only parse loosely.
119
+ // We default to FLAG when the spec is silent on layout (a stronger signal
120
+ // would require parseUISpec to surface layout fields). If the spec text
121
+ // mentions `## 1.` (Layout) at all, we treat it as covered enough to PASS.
122
+ const specText = spec.__rawText || '';
123
+ if (!/^##\s*1\b/m.test(specText) && !/Layout\s*&\s*Hierarchy/i.test(specText)) {
124
+ return {
125
+ pillar: 'layout', verdict: VERDICT_MISSING, findings: [],
126
+ reason: 'UI-SPEC has no Layout section (## 1 ...)',
127
+ startedAt, finishedAt: Date.now(),
128
+ };
129
+ }
130
+ // Presence check: surfaces (any html/tsx file) must exist somewhere in scope.
131
+ const surfaces = files.filter((f) => /\.(tsx|jsx|html|vue|svelte)$/i.test(f));
132
+ if (surfaces.length === 0) {
133
+ findings.push({ severity: 'high', text: 'no surface files (.tsx/.jsx/.html/.vue/.svelte) found in source_scope' });
134
+ return { pillar: 'layout', verdict: VERDICT_BLOCK, findings, startedAt, finishedAt: Date.now() };
135
+ }
136
+ return { pillar: 'layout', verdict: VERDICT_PASS, findings, evidence: `${surfaces.length} surface files in scope`, startedAt, finishedAt: Date.now() };
137
+ }
138
+
139
+ function gradeTypography({ spec, files }) {
140
+ const startedAt = Date.now();
141
+ const findings = [];
142
+ const specText = spec.__rawText || '';
143
+ if (!/^##\s*2\b/m.test(specText) && !/Typography/i.test(specText)) {
144
+ return { pillar: 'typography', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Typography section', startedAt, finishedAt: Date.now() };
145
+ }
146
+ // Cheap check: at least one font-family declaration should exist in scope.
147
+ let fontDecls = 0;
148
+ for (const f of files.slice(0, 200)) {
149
+ const txt = readSafe(f);
150
+ if (/font-family\s*:/i.test(txt)) fontDecls += 1;
151
+ if (fontDecls > 0) break;
152
+ }
153
+ if (fontDecls === 0) {
154
+ findings.push({ severity: 'med', text: 'no `font-family` declaration found in scope (spec defines typography but source omits it)' });
155
+ return { pillar: 'typography', verdict: VERDICT_FLAG, findings, startedAt, finishedAt: Date.now() };
156
+ }
157
+ return { pillar: 'typography', verdict: VERDICT_PASS, findings, evidence: 'font-family declared', startedAt, finishedAt: Date.now() };
158
+ }
159
+
160
+ function gradeColor({ spec, sourceScope, projectRoot }) {
161
+ const startedAt = Date.now();
162
+ const findings = [];
163
+ if (!spec.paletteHex || spec.paletteHex.length === 0) {
164
+ return { pillar: 'color', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no color tokens (## 3 Color)', startedAt, finishedAt: Date.now() };
165
+ }
166
+ // Run the existing drift detector across the scope. `diffPaletteDrift`
167
+ // returns an array of { type, value, severity, declared } findings. A
168
+ // finding here means a color in source that the spec does NOT declare.
169
+ //
170
+ // v1.5.0 r20-HIGH fix: an error inside scanCodeForTailwind / diffPaletteDrift
171
+ // previously silently set drift=[] -- the color pillar would then PASS
172
+ // even though we never actually scanned. Surface the failure as a FLAG
173
+ // finding AND log to stderr; the pillar's verdict reflects real
174
+ // coverage instead of a false-positive PASS.
175
+ let drift = [];
176
+ let driftError = null;
177
+ try {
178
+ const scan = scanCodeForTailwind(sourceScope, { projectRoot });
179
+ drift = diffPaletteDrift(spec, scan);
180
+ } catch (err) {
181
+ driftError = err && err.message ? err.message : String(err);
182
+ drift = [];
183
+ process.stderr.write(
184
+ `ijfw ui-review: gradeColor drift detection failed (${driftError}). ` +
185
+ `Color pillar will FLAG instead of PASS until the scan succeeds.\n`,
186
+ );
187
+ }
188
+
189
+ const blockers = drift.filter((d) => d.severity === 'block');
190
+ const flagsOnly = drift.filter((d) => d.severity === 'flag');
191
+
192
+ for (const d of drift.slice(0, 25)) {
193
+ findings.push({
194
+ severity: d.severity === 'block' ? 'high' : 'med',
195
+ text: `unauthorized ${d.type} in source: ${d.value}`,
196
+ });
197
+ }
198
+
199
+ let verdict = VERDICT_PASS;
200
+ if (blockers.length > 0) verdict = VERDICT_BLOCK;
201
+ else if (flagsOnly.length > 0) verdict = VERDICT_FLAG;
202
+ // r20-HIGH: when the scan itself failed, surface FLAG even with no
203
+ // drift findings so the user doesn't read a false-PASS.
204
+ if (driftError) {
205
+ verdict = VERDICT_FLAG;
206
+ findings.unshift({ severity: 'med', text: `drift scan failed: ${driftError}` });
207
+ }
208
+
209
+ return {
210
+ pillar: 'color',
211
+ verdict,
212
+ findings,
213
+ evidence: driftError
214
+ ? `scan failed (${driftError}); ${drift.length} drift findings observed before failure`
215
+ : `${drift.length} drift findings (${blockers.length} block / ${flagsOnly.length} flag)`,
216
+ startedAt, finishedAt: Date.now(),
217
+ };
218
+ }
219
+
220
+ function gradeSpacing({ spec, files }) {
221
+ const startedAt = Date.now();
222
+ const findings = [];
223
+ const specText = spec.__rawText || '';
224
+ if (!/^##\s*4\b/m.test(specText) && !/Spacing/i.test(specText)) {
225
+ return { pillar: 'spacing', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Spacing section', startedAt, finishedAt: Date.now() };
226
+ }
227
+ // Quick smell test: arbitrary-value Tailwind brackets (e.g. `p-[17px]`)
228
+ // suggest the scale wasn't honored.
229
+ let arbitraryCount = 0;
230
+ for (const f of files.slice(0, 300)) {
231
+ const txt = readSafe(f);
232
+ const m = txt.match(/\b[pm][trblxy]?-\[[^\]]+\]/g);
233
+ if (m) arbitraryCount += m.length;
234
+ }
235
+ if (arbitraryCount > 10) {
236
+ findings.push({ severity: 'med', text: `${arbitraryCount} arbitrary spacing values in scope; spec defines a scale` });
237
+ return { pillar: 'spacing', verdict: VERDICT_FLAG, findings, startedAt, finishedAt: Date.now() };
238
+ }
239
+ return { pillar: 'spacing', verdict: VERDICT_PASS, findings, evidence: `${arbitraryCount} arbitrary values (<= threshold)`, startedAt, finishedAt: Date.now() };
240
+ }
241
+
242
+ function gradeComponents({ spec, files }) {
243
+ const startedAt = Date.now();
244
+ const findings = [];
245
+ const specText = spec.__rawText || '';
246
+ if (!/^##\s*5\b/m.test(specText) && !/Component/i.test(specText)) {
247
+ return { pillar: 'components', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Components section', startedAt, finishedAt: Date.now() };
248
+ }
249
+ // Spec is silent on the exact component set in the parsed shape, so we
250
+ // just confirm that SOME components exist in scope.
251
+ let componentDecls = 0;
252
+ for (const f of files.slice(0, 300)) {
253
+ const txt = readSafe(f);
254
+ // eslint-disable-next-line security/detect-unsafe-regex -- scans developer-authored source files on local disk; bounded by file line length, not exploitable
255
+ if (/export\s+(?:default\s+)?(?:function|class|const)\s+[A-Z]/.test(txt)) componentDecls += 1;
256
+ }
257
+ if (componentDecls === 0) {
258
+ findings.push({ severity: 'med', text: 'no exported component declarations found in scope' });
259
+ return { pillar: 'components', verdict: VERDICT_FLAG, findings, startedAt, finishedAt: Date.now() };
260
+ }
261
+ return { pillar: 'components', verdict: VERDICT_PASS, findings, evidence: `${componentDecls} component declarations`, startedAt, finishedAt: Date.now() };
262
+ }
263
+
264
+ function gradeInteraction({ spec, files, peerInputs }) {
265
+ const startedAt = Date.now();
266
+ const findings = [];
267
+ const specText = spec.__rawText || '';
268
+ if (!/^##\s*6\b/m.test(specText) && !/Interaction|Motion/i.test(specText)) {
269
+ return { pillar: 'interaction', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Interaction section', startedAt, finishedAt: Date.now() };
270
+ }
271
+ // Floor check: every interactive surface should declare :focus styling.
272
+ // Cheap heuristic: count :focus mentions in scope; if zero, BLOCK (a11y floor).
273
+ let focusMentions = 0;
274
+ let interactiveDecls = 0;
275
+ for (const f of files.slice(0, 300)) {
276
+ const txt = readSafe(f);
277
+ if (/:focus(?:-visible)?\b/.test(txt)) focusMentions += 1;
278
+ if (/<(?:button|a|input|select|textarea)\b/i.test(txt)) interactiveDecls += 1;
279
+ }
280
+ if (interactiveDecls > 0 && focusMentions === 0) {
281
+ findings.push({ severity: 'high', text: ':focus styling not found in any file with interactive elements (WCAG floor violation)' });
282
+ return { pillar: 'interaction', verdict: VERDICT_BLOCK, findings, startedAt, finishedAt: Date.now() };
283
+ }
284
+ // Playwright baseline check (optional peer)
285
+ if (peerInputs && peerInputs.playwright) {
286
+ try {
287
+ const cmp = compareToBaseline(peerInputs.playwright);
288
+ if (cmp && cmp.pass === false) {
289
+ findings.push({ severity: 'med', text: `playwright baseline diff: ${cmp.reason || 'changed'}` });
290
+ }
291
+ } catch { /* peer tool optional */ }
292
+ }
293
+ // r21-HIGH-1: derive the verdict from findings instead of returning PASS
294
+ // unconditionally. A recorded playwright baseline diff (or any other
295
+ // finding) must downgrade the pillar — a true PASS means zero findings.
296
+ let verdict = VERDICT_PASS;
297
+ if (findings.some((f) => f.severity === 'high')) verdict = VERDICT_BLOCK;
298
+ else if (findings.length > 0) verdict = VERDICT_FLAG;
299
+ return { pillar: 'interaction', verdict, findings, evidence: `${focusMentions} :focus / ${interactiveDecls} interactive surfaces`, startedAt, finishedAt: Date.now() };
300
+ }
301
+
302
+ function gradeSecurity({ spec, files, peerInputs }) {
303
+ const startedAt = Date.now();
304
+ const findings = [];
305
+ // a11y is part of the security pillar per the v1.5.0 7-pillar enumeration.
306
+ if (peerInputs && peerInputs.axe !== undefined) {
307
+ // r21-MED: isolate evaluator failures — a malformed axe peer input must
308
+ // not throw out of the grader and reject the whole Promise.all review.
309
+ try {
310
+ const a11y = evaluateA11y(peerInputs.axe, {
311
+ target: spec.a11yTarget || DEFAULT_A11Y_TARGET,
312
+ maxViolations: spec.maxViolations != null ? spec.maxViolations : DEFAULT_MAX_VIOLATIONS,
313
+ });
314
+ if (a11y.pass === false) {
315
+ findings.push({ severity: 'high', text: `a11y: ${a11y.count} violations exceed budget ${a11y.maxViolations}` });
316
+ }
317
+ } catch (err) {
318
+ findings.push({ severity: 'med', text: `a11y evaluation failed: ${err && err.message ? err.message : String(err)}` });
319
+ }
320
+ }
321
+ // CSP / inline-handler smell tests
322
+ let inlineHandlers = 0;
323
+ let unsafeCspHits = 0;
324
+ for (const f of files.slice(0, 300)) {
325
+ const txt = readSafe(f);
326
+ if (/\bon(?:click|load|error|change|input|submit)\s*=\s*["']/.test(txt)) inlineHandlers += 1;
327
+ if (/unsafe-(?:inline|eval)/i.test(txt)) unsafeCspHits += 1;
328
+ }
329
+ if (inlineHandlers > 0) {
330
+ findings.push({ severity: 'med', text: `${inlineHandlers} inline event handler(s) in scope` });
331
+ }
332
+ if (unsafeCspHits > 0) {
333
+ findings.push({ severity: 'high', text: `${unsafeCspHits} mention(s) of CSP unsafe-inline / unsafe-eval` });
334
+ }
335
+ // Lighthouse audit (optional peer)
336
+ if (peerInputs && peerInputs.lighthouse !== undefined) {
337
+ // r21-MED: isolate evaluator failures — a malformed lighthouse peer
338
+ // input must not throw out of the grader and crash the review.
339
+ try {
340
+ const lh = evaluateLighthouse(peerInputs.lighthouse);
341
+ if (lh.pass === false) {
342
+ findings.push({ severity: 'med', text: `lighthouse: LCP ${lh.lcpMs}ms / CLS ${lh.clsScore} -- ${lh.reason}` });
343
+ }
344
+ } catch (err) {
345
+ findings.push({ severity: 'med', text: `lighthouse evaluation failed: ${err && err.message ? err.message : String(err)}` });
346
+ }
347
+ }
348
+ let verdict = VERDICT_PASS;
349
+ if (findings.some((f) => f.severity === 'high')) verdict = VERDICT_BLOCK;
350
+ else if (findings.length > 0) verdict = VERDICT_FLAG;
351
+ return { pillar: 'security', verdict, findings, evidence: `${inlineHandlers} inline handlers, ${unsafeCspHits} unsafe CSP hits`, startedAt, finishedAt: Date.now() };
352
+ }
353
+
354
+ const GRADERS = Object.freeze({
355
+ layout: gradeLayout,
356
+ typography: gradeTypography,
357
+ color: gradeColor,
358
+ spacing: gradeSpacing,
359
+ components: gradeComponents,
360
+ interaction: gradeInteraction,
361
+ security: gradeSecurity,
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Top-level runner
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * Run the 7-pillar UI review. All 7 graders fire in parallel via Promise.all
370
+ * (W1.E). Returns the structured result + writes UI-REVIEW.md next to the
371
+ * UI-SPEC path.
372
+ *
373
+ * @param {object} args
374
+ * @param {string} args.uiSpecPath absolute path to UI-SPEC.md
375
+ * @param {string|string[]} args.sourceScope dirs to grade (comma-separated or array)
376
+ * @param {string} [args.projectRoot] default cwd
377
+ * @param {object} [args.peerInputs] { axe, lighthouse, playwright } -- optional pre-computed peer-tool outputs
378
+ * @param {boolean} [args.write] when true, write UI-REVIEW.md (default true)
379
+ * @param {boolean} [args.gcSketches] when true, run sketches-gc as the finalizer (default false)
380
+ * @param {string} [args.fromImage] when set, run uispec-intake.fromImage and attach the stub to the result + UI-REVIEW.md "Intake" section.
381
+ * @param {string} [args.fromFigma] when set, run uispec-intake.fromFigma (same surfacing as fromImage).
382
+ * @returns {Promise<{
383
+ * topVerdict: 'PASS'|'FLAG'|'BLOCK',
384
+ * pillarVerdicts: Record<string, string>,
385
+ * verdicts: Array<{pillar, verdict, findings, startedAt, finishedAt}>,
386
+ * reviewPath: string|null,
387
+ * reviewMarkdown: string,
388
+ * parallel: { minStart: number, maxStart: number, minFinish: number, maxFinish: number, parallelism: number },
389
+ * intake: { kind: 'image'|'figma', ok: boolean, stub: object|null, error: string|null }|null
390
+ * }>}
391
+ */
392
+ export async function runUiReview({
393
+ uiSpecPath,
394
+ sourceScope,
395
+ projectRoot = process.cwd(),
396
+ peerInputs = {},
397
+ write = true,
398
+ gcSketches = false,
399
+ fromImage: fromImagePath = null,
400
+ fromFigma: fromFigmaUrl = null,
401
+ } = {}) {
402
+ if (typeof uiSpecPath !== 'string' || uiSpecPath.length === 0) {
403
+ throw new TypeError('runUiReview: uiSpecPath is required');
404
+ }
405
+ if (!existsSync(uiSpecPath)) {
406
+ throw new Error(`runUiReview: UI-SPEC not found at ${uiSpecPath}`);
407
+ }
408
+ const scopes = Array.isArray(sourceScope)
409
+ ? sourceScope
410
+ : typeof sourceScope === 'string'
411
+ ? sourceScope.split(',').map((s) => s.trim()).filter(Boolean)
412
+ : [];
413
+ if (scopes.length === 0) {
414
+ throw new Error('runUiReview: sourceScope is required (comma-separated paths or array)');
415
+ }
416
+
417
+ const rawSpec = readFileSync(uiSpecPath, 'utf8');
418
+ const spec = parseUISpec(rawSpec);
419
+ spec.__rawText = rawSpec;
420
+
421
+ // v1.5.1 W2.A: optional uispec-intake pre-fill. When the caller supplies
422
+ // --from-image or --from-figma we run the intake helper and surface the
423
+ // resulting stub on the review (rendered into UI-REVIEW.md and returned
424
+ // structurally). This does NOT mutate the parsed spec used for grading —
425
+ // intake is purely a pre-fill hint for the user's next UI-SPEC edit.
426
+ let intake = null;
427
+ if (fromImagePath) {
428
+ const res = fromImage(fromImagePath, { projectRoot });
429
+ intake = { kind: 'image', ok: res.ok, stub: res.stub, error: res.error };
430
+ } else if (fromFigmaUrl) {
431
+ const res = await fromFigma(fromFigmaUrl);
432
+ intake = { kind: 'figma', ok: res.ok, stub: res.stub, error: res.error };
433
+ }
434
+
435
+ const files = walkSourceFiles(scopes, projectRoot);
436
+
437
+ // W1.E: 7 graders in parallel via Promise.all. Concurrency witness is a
438
+ // counter (not timestamps): each grader increments `inFlight` on entry,
439
+ // yields the microtask queue once, then runs to completion. With
440
+ // Promise.all dispatch, all 7 graders enter their wrapper in the same
441
+ // tick before any returns -- so `peakConcurrent === PILLARS.length`.
442
+ // A sequential implementation would peak at 1.
443
+ //
444
+ // What this witness proves: Promise.all DISPATCH is concurrent — the 7
445
+ // grader wrappers all run their entry block in the same event-loop tick
446
+ // before any can exit. It does NOT prove that the (currently synchronous)
447
+ // grader BODIES execute interleaved on the event loop — sync work
448
+ // serializes by definition. If a grader gains a real async operation
449
+ // (e.g. a Lighthouse call), the witness already accommodates it: the
450
+ // yield ensures all peers register before any awaits. This is the
451
+ // semantic guarantee that matters; the lib design intentionally keeps
452
+ // grader bodies cheap + sync so the runner stays fast.
453
+ // (Replaces the earlier Date.now() ms-precision comparison which was
454
+ // flaky on fast sync work.)
455
+ const graderArgs = { spec, sourceScope: scopes, files, projectRoot, peerInputs };
456
+ const beforeAll = Date.now();
457
+ let _inFlight = 0;
458
+ let _peakConcurrent = 0;
459
+ const verdicts = await Promise.all(
460
+ PILLARS.map((pillar) => Promise.resolve().then(async () => {
461
+ _inFlight += 1;
462
+ if (_inFlight > _peakConcurrent) _peakConcurrent = _inFlight;
463
+ // Yield so all other graders also reach this point before any finishes.
464
+ await Promise.resolve();
465
+ try { return GRADERS[pillar](graderArgs); }
466
+ finally { _inFlight -= 1; }
467
+ })),
468
+ );
469
+ const afterAll = Date.now();
470
+
471
+ // Parallelism stats — used by tests + observability.
472
+ const startedAtList = verdicts.map((v) => v.startedAt).sort((a, b) => a - b);
473
+ const finishedAtList = verdicts.map((v) => v.finishedAt).sort((a, b) => a - b);
474
+ const parallel = {
475
+ minStart: startedAtList[0],
476
+ maxStart: startedAtList[startedAtList.length - 1],
477
+ minFinish: finishedAtList[0],
478
+ maxFinish: finishedAtList[finishedAtList.length - 1],
479
+ // Concurrency witness: how many graders were simultaneously inside the
480
+ // dispatcher wrapper at the peak. Equals PILLARS.length when Promise.all
481
+ // dispatch is parallel; would be 1 if implementation went sequential.
482
+ peakConcurrent: _peakConcurrent,
483
+ // True iff the witness == PILLARS.length (all 7 entered before any
484
+ // exited). Robust to wall-clock resolution since it's a tick-level event
485
+ // count, not a timestamp comparison.
486
+ parallelism: _peakConcurrent === PILLARS.length,
487
+ wallMs: afterAll - beforeAll,
488
+ };
489
+
490
+ const pillarVerdicts = {};
491
+ for (const v of verdicts) pillarVerdicts[v.pillar] = v.verdict;
492
+
493
+ const topVerdict = computeTopVerdict(verdicts);
494
+
495
+ const reviewMarkdown = renderReview({
496
+ uiSpecPath,
497
+ sourceScope: scopes,
498
+ verdicts,
499
+ topVerdict,
500
+ intake,
501
+ });
502
+
503
+ let reviewPath = null;
504
+ if (write) {
505
+ reviewPath = join(dirname(uiSpecPath), 'UI-REVIEW.md');
506
+ try { mkdirSync(dirname(reviewPath), { recursive: true }); } catch {}
507
+ writeFileSync(reviewPath, reviewMarkdown, 'utf8');
508
+ }
509
+
510
+ if (gcSketches) {
511
+ try { runSketchesGc({ root: join(projectRoot, '.planning', 'sketches') }); } catch {}
512
+ }
513
+
514
+ return { topVerdict, pillarVerdicts, verdicts, reviewPath, reviewMarkdown, parallel, intake };
515
+ }
516
+
517
+ function computeTopVerdict(verdicts) {
518
+ // BLOCK > FLAG > PASS; spec-section-missing ranks as BLOCK (auditor agent
519
+ // rule: spec-missing is treated as blocking until the spec is updated).
520
+ const ranks = { PASS: 0, FLAG: 1, BLOCK: 2 };
521
+ let top = 'PASS';
522
+ for (const v of verdicts) {
523
+ const norm = v.verdict === VERDICT_MISSING ? 'BLOCK' : v.verdict;
524
+ if ((ranks[norm] ?? 0) > (ranks[top] ?? 0)) top = norm;
525
+ }
526
+ return top;
527
+ }
528
+
529
+ function renderReview({ uiSpecPath, sourceScope, verdicts, topVerdict, intake = null }) {
530
+ const date = new Date().toISOString().slice(0, 10);
531
+ const scopeStr = Array.isArray(sourceScope) ? sourceScope.join(',') : String(sourceScope);
532
+ const lines = [
533
+ `# UI-REVIEW`,
534
+ `**Audited:** ${date} **Auditor:** ijfw-ui-review-runner`,
535
+ `**Spec:** ${uiSpecPath} **Source scope:** ${scopeStr}`,
536
+ `**Top-level verdict:** ${topVerdict}`,
537
+ '',
538
+ ];
539
+ if (intake) {
540
+ lines.push('## Intake (uispec-intake)');
541
+ lines.push('');
542
+ lines.push(`- **Source kind:** ${intake.kind}`);
543
+ lines.push(`- **Status:** ${intake.ok ? 'ok' : 'error'}`);
544
+ if (intake.error) lines.push(`- **Error:** ${intake.error}`);
545
+ if (intake.stub && intake.stub.advisory) lines.push(`- **Advisory:** ${intake.stub.advisory}`);
546
+ if (intake.stub && intake.stub.source) {
547
+ const src = intake.stub.source;
548
+ if (src.path) lines.push(`- **Path:** ${src.path}`);
549
+ if (src.url) lines.push(`- **URL:** ${src.url}`);
550
+ if (src.bytes != null) lines.push(`- **Bytes:** ${src.bytes}`);
551
+ if (src.dimensions) lines.push(`- **Dimensions:** ${src.dimensions.width}x${src.dimensions.height}`);
552
+ if (src.fileKey) lines.push(`- **Figma file key:** ${src.fileKey}`);
553
+ if (src.name) lines.push(`- **Figma file name:** ${src.name}`);
554
+ }
555
+ lines.push('');
556
+ }
557
+ lines.push('## Per-pillar verdicts');
558
+ lines.push('');
559
+ for (const v of verdicts) {
560
+ const title = PILLAR_TITLES[v.pillar] || v.pillar;
561
+ lines.push(`### ${title} — ${v.verdict}`);
562
+ if (v.reason) lines.push(`- **Reason:** ${v.reason}`);
563
+ if (v.evidence) lines.push(`- **Evidence:** ${v.evidence}`);
564
+ if (v.findings && v.findings.length > 0) {
565
+ for (const f of v.findings) {
566
+ const where = f.file ? `\`${f.file}${f.line ? ':' + f.line : ''}\`` : '';
567
+ lines.push(`- **Finding (${f.severity || 'info'}):** ${f.text}${where ? ` ${where}` : ''}`);
568
+ }
569
+ }
570
+ lines.push('');
571
+ }
572
+ // Summary
573
+ const blocks = verdicts.filter((v) => v.verdict === VERDICT_BLOCK || v.verdict === VERDICT_MISSING).map((v) => v.pillar);
574
+ const flags = verdicts.filter((v) => v.verdict === VERDICT_FLAG).map((v) => v.pillar);
575
+ const passes = verdicts.filter((v) => v.verdict === VERDICT_PASS).map((v) => v.pillar);
576
+ const totalFindings = verdicts.reduce((n, v) => n + (v.findings ? v.findings.length : 0), 0);
577
+ const blockFindings = verdicts.reduce((n, v) => n + (v.findings ? v.findings.filter((f) => f.severity === 'high').length : 0), 0);
578
+ lines.push('## Summary');
579
+ lines.push('');
580
+ lines.push(`- **Top-level:** ${topVerdict}`);
581
+ lines.push(`- **Pillars at BLOCK:** ${blocks.length > 0 ? blocks.join(', ') : 'none'}`);
582
+ lines.push(`- **Pillars at FLAG:** ${flags.length > 0 ? flags.join(', ') : 'none'}`);
583
+ lines.push(`- **Pillars at PASS:** ${passes.length > 0 ? passes.join(', ') : 'none'}`);
584
+ lines.push(`- **Total findings:** ${totalFindings} (${blockFindings} BLOCK / ${totalFindings - blockFindings} FLAG)`);
585
+ lines.push('');
586
+ return lines.join('\n');
587
+ }
588
+
589
+ // Constants exported for tests + tooling.
590
+ export const RUNNER_DEFAULTS = Object.freeze({
591
+ pillars: PILLARS,
592
+ pillarTitles: PILLAR_TITLES,
593
+ verdicts: Object.freeze({ PASS: VERDICT_PASS, FLAG: VERDICT_FLAG, BLOCK: VERDICT_BLOCK, MISSING: VERDICT_MISSING }),
594
+ lighthouseThresholds: LIGHTHOUSE_THRESHOLDS,
595
+ });