harnery 0.36.0 → 0.38.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 (432) hide show
  1. package/dist/commander.d.ts +9 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +10 -1
  4. package/dist/commands/admission.d.ts +21 -0
  5. package/dist/commands/admission.d.ts.map +1 -0
  6. package/dist/commands/admission.js +565 -0
  7. package/dist/commands/agents.d.ts +7 -0
  8. package/dist/commands/agents.d.ts.map +1 -1
  9. package/dist/commands/agents.js +148 -47
  10. package/dist/commands/artifacts.d.ts.map +1 -1
  11. package/dist/commands/artifacts.js +73 -2
  12. package/dist/commands/browse-ai.d.ts +2 -2
  13. package/dist/commands/browse-ai.d.ts.map +1 -1
  14. package/dist/commands/browse-ai.js +6 -4
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +360 -21
  17. package/dist/commands/diagnostics.d.ts +4 -0
  18. package/dist/commands/diagnostics.d.ts.map +1 -0
  19. package/dist/commands/diagnostics.js +229 -0
  20. package/dist/commands/fetch.js +1 -0
  21. package/dist/commands/files.d.ts +12 -0
  22. package/dist/commands/files.d.ts.map +1 -1
  23. package/dist/commands/files.js +54 -1
  24. package/dist/commands/governor.d.ts.map +1 -1
  25. package/dist/commands/governor.js +4 -0
  26. package/dist/commands/ledger-v3.d.ts.map +1 -1
  27. package/dist/commands/ledger-v3.js +71 -2
  28. package/dist/commands/qa-record.d.ts +144 -0
  29. package/dist/commands/qa-record.d.ts.map +1 -0
  30. package/dist/commands/qa-record.js +0 -0
  31. package/dist/commands/qa-run.d.ts +8 -0
  32. package/dist/commands/qa-run.d.ts.map +1 -0
  33. package/dist/commands/qa-run.js +396 -0
  34. package/dist/commands/qa-status.d.ts +71 -0
  35. package/dist/commands/qa-status.d.ts.map +1 -0
  36. package/dist/commands/qa-status.js +490 -0
  37. package/dist/commands/qa-verify.d.ts +40 -0
  38. package/dist/commands/qa-verify.d.ts.map +1 -0
  39. package/dist/commands/qa-verify.js +180 -0
  40. package/dist/commands/resources.d.ts +4 -0
  41. package/dist/commands/resources.d.ts.map +1 -0
  42. package/dist/commands/resources.js +29 -0
  43. package/dist/commands/review-pack.d.ts +4 -0
  44. package/dist/commands/review-pack.d.ts.map +1 -0
  45. package/dist/commands/review-pack.js +1001 -0
  46. package/dist/commands/supervisor.d.ts +4 -0
  47. package/dist/commands/supervisor.d.ts.map +1 -0
  48. package/dist/commands/supervisor.js +101 -0
  49. package/dist/commands/web.d.ts.map +1 -1
  50. package/dist/commands/web.js +17 -0
  51. package/dist/commands/workflow.d.ts.map +1 -1
  52. package/dist/commands/workflow.js +4 -0
  53. package/dist/core/agents/cli.js +7 -3
  54. package/dist/core/agents/health.d.ts +17 -0
  55. package/dist/core/agents/health.d.ts.map +1 -0
  56. package/dist/core/agents/health.js +109 -0
  57. package/dist/core/agents/mailbox.d.ts +92 -0
  58. package/dist/core/agents/mailbox.d.ts.map +1 -0
  59. package/dist/core/agents/mailbox.js +325 -0
  60. package/dist/core/agents/qa-signal.d.ts +111 -0
  61. package/dist/core/agents/qa-signal.d.ts.map +1 -0
  62. package/dist/core/agents/qa-signal.js +231 -0
  63. package/dist/core/agents/reconcile-coordination-v3.d.ts +44 -0
  64. package/dist/core/agents/reconcile-coordination-v3.d.ts.map +1 -0
  65. package/dist/core/agents/reconcile-coordination-v3.js +58 -0
  66. package/dist/core/agents/render/prompt-context.d.ts +5 -0
  67. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  68. package/dist/core/agents/render/prompt-context.js +34 -13
  69. package/dist/core/agents/render/session-context.d.ts +8 -0
  70. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  71. package/dist/core/agents/render/session-context.js +34 -3
  72. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  73. package/dist/core/agents/rules/commit-conflict.js +50 -1
  74. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  75. package/dist/core/agents/rules/stop-hook.js +6 -10
  76. package/dist/core/agents/session-finalizer-v3.js +4 -1
  77. package/dist/core/agents/session-name-display.d.ts +20 -5
  78. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  79. package/dist/core/agents/session-name-display.js +67 -7
  80. package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
  81. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  82. package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
  83. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  84. package/dist/core/agents/state/heartbeat-writer.js +17 -0
  85. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  86. package/dist/core/agents/state/live-coordination-view.js +1 -0
  87. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  88. package/dist/core/artifacts/constants.d.ts +1 -1
  89. package/dist/core/artifacts/constants.js +1 -1
  90. package/dist/core/artifacts/index.d.ts +82 -6
  91. package/dist/core/artifacts/index.d.ts.map +1 -1
  92. package/dist/core/artifacts/index.js +410 -15
  93. package/dist/core/config.d.ts +20 -0
  94. package/dist/core/config.d.ts.map +1 -1
  95. package/dist/core/config.js +59 -0
  96. package/dist/core/diagnostics/advice.d.ts +9 -0
  97. package/dist/core/diagnostics/advice.d.ts.map +1 -0
  98. package/dist/core/diagnostics/advice.js +112 -0
  99. package/dist/core/diagnostics/bundle.d.ts +51 -0
  100. package/dist/core/diagnostics/bundle.d.ts.map +1 -0
  101. package/dist/core/diagnostics/bundle.js +968 -0
  102. package/dist/core/diagnostics/comparison.d.ts +5 -0
  103. package/dist/core/diagnostics/comparison.d.ts.map +1 -0
  104. package/dist/core/diagnostics/comparison.js +444 -0
  105. package/dist/core/diagnostics/contract.d.ts +245 -0
  106. package/dist/core/diagnostics/contract.d.ts.map +1 -0
  107. package/dist/core/diagnostics/contract.js +18 -0
  108. package/dist/core/diagnostics/identity.d.ts +3 -0
  109. package/dist/core/diagnostics/identity.d.ts.map +1 -0
  110. package/dist/core/diagnostics/identity.js +10 -0
  111. package/dist/core/diagnostics/index.d.ts +8 -0
  112. package/dist/core/diagnostics/index.d.ts.map +1 -0
  113. package/dist/core/diagnostics/index.js +7 -0
  114. package/dist/core/diagnostics/replay.d.ts +8 -0
  115. package/dist/core/diagnostics/replay.d.ts.map +1 -0
  116. package/dist/core/diagnostics/replay.js +213 -0
  117. package/dist/core/diagnostics/sanitize.d.ts +9 -0
  118. package/dist/core/diagnostics/sanitize.d.ts.map +1 -0
  119. package/dist/core/diagnostics/sanitize.js +84 -0
  120. package/dist/core/events/legacy-storage/compression.d.ts +13 -0
  121. package/dist/core/events/legacy-storage/compression.d.ts.map +1 -0
  122. package/dist/core/events/legacy-storage/compression.js +107 -0
  123. package/dist/core/events/legacy-storage/index.d.ts +1 -0
  124. package/dist/core/events/legacy-storage/index.d.ts.map +1 -1
  125. package/dist/core/events/legacy-storage/index.js +1 -0
  126. package/dist/core/events/v3/archive-retention.d.ts +33 -0
  127. package/dist/core/events/v3/archive-retention.d.ts.map +1 -0
  128. package/dist/core/events/v3/archive-retention.js +195 -0
  129. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  130. package/dist/core/events/v3/bootstrap.js +10 -0
  131. package/dist/core/events/v3/capabilities.d.ts +1 -1
  132. package/dist/core/events/v3/capabilities.d.ts.map +1 -1
  133. package/dist/core/events/v3/capabilities.js +1 -0
  134. package/dist/core/events/v3/coordination-view.d.ts +3 -0
  135. package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
  136. package/dist/core/events/v3/coordination-view.js +69 -8
  137. package/dist/core/events/v3/index.d.ts +1 -0
  138. package/dist/core/events/v3/index.d.ts.map +1 -1
  139. package/dist/core/events/v3/index.js +1 -0
  140. package/dist/core/events/v3/live-routing.d.ts.map +1 -1
  141. package/dist/core/events/v3/live-routing.js +1 -0
  142. package/dist/core/events/v3/producers/hook-base.d.ts +1 -1
  143. package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -1
  144. package/dist/core/events/v3/producers/hook-base.js +13 -2
  145. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  146. package/dist/core/events/v3/producers/intake.js +10 -2
  147. package/dist/core/events/v3/producers/recorder.d.ts +39 -0
  148. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  149. package/dist/core/events/v3/producers/recorder.js +350 -17
  150. package/dist/core/hooks/adapter/output.d.ts +8 -0
  151. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  152. package/dist/core/hooks/adapter/output.js +11 -1
  153. package/dist/core/hooks/cli.js +303 -219
  154. package/dist/core/hooks/health.d.ts +59 -0
  155. package/dist/core/hooks/health.d.ts.map +1 -0
  156. package/dist/core/hooks/health.js +84 -0
  157. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  158. package/dist/core/hooks/resolve/transcript.js +10 -3
  159. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  160. package/dist/core/hooks/session-name-presence.js +4 -1
  161. package/dist/core/qa-artifacts.d.ts +20 -0
  162. package/dist/core/qa-artifacts.d.ts.map +1 -0
  163. package/dist/core/qa-artifacts.js +110 -0
  164. package/dist/core/resources/contract.d.ts +96 -0
  165. package/dist/core/resources/contract.d.ts.map +1 -0
  166. package/dist/core/resources/contract.js +2 -0
  167. package/dist/core/resources/index.d.ts +6 -0
  168. package/dist/core/resources/index.d.ts.map +1 -0
  169. package/dist/core/resources/index.js +5 -0
  170. package/dist/core/resources/sampler.d.ts +34 -0
  171. package/dist/core/resources/sampler.d.ts.map +1 -0
  172. package/dist/core/resources/sampler.js +458 -0
  173. package/dist/core/resources/service-status.d.ts +3 -0
  174. package/dist/core/resources/service-status.d.ts.map +1 -0
  175. package/dist/core/resources/service-status.js +48 -0
  176. package/dist/core/resources/service.d.ts +26 -0
  177. package/dist/core/resources/service.d.ts.map +1 -0
  178. package/dist/core/resources/service.js +292 -0
  179. package/dist/core/resources/storage.d.ts +13 -0
  180. package/dist/core/resources/storage.d.ts.map +1 -0
  181. package/dist/core/resources/storage.js +30 -0
  182. package/dist/core/storage/atomic-json.d.ts +3 -0
  183. package/dist/core/storage/atomic-json.d.ts.map +1 -0
  184. package/dist/core/storage/atomic-json.js +15 -0
  185. package/dist/core/storage/builtins.d.ts.map +1 -1
  186. package/dist/core/storage/builtins.js +45 -6
  187. package/dist/core/storage/logger.d.ts +2 -0
  188. package/dist/core/storage/logger.d.ts.map +1 -1
  189. package/dist/core/storage/logger.js +2 -0
  190. package/dist/core/storage/query.d.ts +14 -0
  191. package/dist/core/storage/query.d.ts.map +1 -1
  192. package/dist/core/storage/query.js +51 -0
  193. package/dist/core/supervisor/activity.d.ts +4 -0
  194. package/dist/core/supervisor/activity.d.ts.map +1 -0
  195. package/dist/core/supervisor/activity.js +141 -0
  196. package/dist/core/supervisor/contract.d.ts +268 -0
  197. package/dist/core/supervisor/contract.d.ts.map +1 -0
  198. package/dist/core/supervisor/contract.js +35 -0
  199. package/dist/core/supervisor/explanations.d.ts +4 -0
  200. package/dist/core/supervisor/explanations.d.ts.map +1 -0
  201. package/dist/core/supervisor/explanations.js +71 -0
  202. package/dist/core/supervisor/findings.d.ts +20 -0
  203. package/dist/core/supervisor/findings.d.ts.map +1 -0
  204. package/dist/core/supervisor/findings.js +399 -0
  205. package/dist/core/supervisor/history.d.ts +9 -0
  206. package/dist/core/supervisor/history.d.ts.map +1 -0
  207. package/dist/core/supervisor/history.js +46 -0
  208. package/dist/core/supervisor/hook-health-alerts.d.ts +14 -0
  209. package/dist/core/supervisor/hook-health-alerts.d.ts.map +1 -0
  210. package/dist/core/supervisor/hook-health-alerts.js +70 -0
  211. package/dist/core/supervisor/hook-health-storage.d.ts +5 -0
  212. package/dist/core/supervisor/hook-health-storage.d.ts.map +1 -0
  213. package/dist/core/supervisor/hook-health-storage.js +24 -0
  214. package/dist/core/supervisor/hook-health.d.ts +72 -0
  215. package/dist/core/supervisor/hook-health.d.ts.map +1 -0
  216. package/dist/core/supervisor/hook-health.js +203 -0
  217. package/dist/core/supervisor/hooks.d.ts +10 -0
  218. package/dist/core/supervisor/hooks.d.ts.map +1 -0
  219. package/dist/core/supervisor/hooks.js +28 -0
  220. package/dist/core/supervisor/index.d.ts +15 -0
  221. package/dist/core/supervisor/index.d.ts.map +1 -0
  222. package/dist/core/supervisor/index.js +14 -0
  223. package/dist/core/supervisor/log-feed.d.ts +9 -0
  224. package/dist/core/supervisor/log-feed.d.ts.map +1 -0
  225. package/dist/core/supervisor/log-feed.js +103 -0
  226. package/dist/core/supervisor/service.d.ts +29 -0
  227. package/dist/core/supervisor/service.d.ts.map +1 -0
  228. package/dist/core/supervisor/service.js +450 -0
  229. package/dist/core/supervisor/services.d.ts +11 -0
  230. package/dist/core/supervisor/services.d.ts.map +1 -0
  231. package/dist/core/supervisor/services.js +155 -0
  232. package/dist/core/supervisor/status.d.ts +3 -0
  233. package/dist/core/supervisor/status.d.ts.map +1 -0
  234. package/dist/core/supervisor/status.js +48 -0
  235. package/dist/core/supervisor/storage.d.ts +30 -0
  236. package/dist/core/supervisor/storage.d.ts.map +1 -0
  237. package/dist/core/supervisor/storage.js +73 -0
  238. package/dist/core/supervisor/timeline.d.ts +3 -0
  239. package/dist/core/supervisor/timeline.d.ts.map +1 -0
  240. package/dist/core/supervisor/timeline.js +87 -0
  241. package/dist/core/workflow/admission.d.ts +23 -0
  242. package/dist/core/workflow/admission.d.ts.map +1 -0
  243. package/dist/core/workflow/admission.js +136 -0
  244. package/dist/core/workflow/engine.d.ts.map +1 -1
  245. package/dist/core/workflow/engine.js +77 -2
  246. package/dist/core/workflow/index.d.ts +3 -2
  247. package/dist/core/workflow/index.d.ts.map +1 -1
  248. package/dist/core/workflow/index.js +2 -1
  249. package/dist/core/workflow/proof.d.ts +2 -1
  250. package/dist/core/workflow/proof.d.ts.map +1 -1
  251. package/dist/core/workflow/proof.js +39 -1
  252. package/dist/core/workflow/run-state.d.ts +3 -2
  253. package/dist/core/workflow/run-state.d.ts.map +1 -1
  254. package/dist/core/workflow/run-state.js +8 -1
  255. package/dist/core/workflow/types.d.ts +33 -1
  256. package/dist/core/workflow/types.d.ts.map +1 -1
  257. package/dist/core/workflow/types.js +2 -1
  258. package/dist/lib/admission.d.ts +71 -0
  259. package/dist/lib/admission.d.ts.map +1 -0
  260. package/dist/lib/admission.js +264 -0
  261. package/dist/lib/agent-browser/client.d.ts +1 -1
  262. package/dist/lib/agent-browser/client.d.ts.map +1 -1
  263. package/dist/lib/agent-browser/client.js +1 -5
  264. package/dist/lib/browser/capture-fidelity.d.ts +39 -0
  265. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
  266. package/dist/lib/browser/capture-fidelity.js +84 -0
  267. package/dist/lib/browser/client.d.ts +46 -1
  268. package/dist/lib/browser/client.d.ts.map +1 -1
  269. package/dist/lib/browser/client.js +168 -9
  270. package/dist/lib/browser/critique.d.ts +38 -1
  271. package/dist/lib/browser/critique.d.ts.map +1 -1
  272. package/dist/lib/browser/critique.js +34 -6
  273. package/dist/lib/browser/index.d.ts +5 -1
  274. package/dist/lib/browser/index.d.ts.map +1 -1
  275. package/dist/lib/browser/index.js +4 -0
  276. package/dist/lib/browser/page-review-judge.d.ts +64 -0
  277. package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
  278. package/dist/lib/browser/page-review-judge.js +270 -0
  279. package/dist/lib/browser/page-review-pack.d.ts +613 -0
  280. package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
  281. package/dist/lib/browser/page-review-pack.js +1751 -0
  282. package/dist/lib/browser/qa-run-contracts.d.ts +377 -0
  283. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -0
  284. package/dist/lib/browser/qa-run-contracts.js +361 -0
  285. package/dist/lib/browser/qa-run.d.ts +141 -0
  286. package/dist/lib/browser/qa-run.d.ts.map +1 -0
  287. package/dist/lib/browser/qa-run.js +1283 -0
  288. package/dist/lib/browser/request-diagnostics.d.ts +13 -0
  289. package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
  290. package/dist/lib/browser/request-diagnostics.js +18 -0
  291. package/dist/lib/browser/tiling.d.ts +19 -0
  292. package/dist/lib/browser/tiling.d.ts.map +1 -1
  293. package/dist/lib/browser/tiling.js +28 -0
  294. package/dist/lib/cookies/client.d.ts +9 -0
  295. package/dist/lib/cookies/client.d.ts.map +1 -1
  296. package/dist/lib/cookies/client.js +197 -44
  297. package/dist/lib/cookies/extra.d.ts +18 -0
  298. package/dist/lib/cookies/extra.d.ts.map +1 -0
  299. package/dist/lib/cookies/extra.js +14 -0
  300. package/dist/lib/cookies/index.d.ts +2 -1
  301. package/dist/lib/cookies/index.d.ts.map +1 -1
  302. package/dist/lib/cookies/index.js +2 -1
  303. package/dist/lib/coord-root-id.d.ts +9 -0
  304. package/dist/lib/coord-root-id.d.ts.map +1 -0
  305. package/dist/lib/coord-root-id.js +17 -0
  306. package/dist/lib/durable-job.d.ts +124 -0
  307. package/dist/lib/durable-job.d.ts.map +1 -0
  308. package/dist/lib/durable-job.js +296 -0
  309. package/dist/lib/http/client.d.ts +7 -1
  310. package/dist/lib/http/client.d.ts.map +1 -1
  311. package/dist/lib/http/client.js +2 -0
  312. package/dist/lib/instructions/apply.d.ts +3 -1
  313. package/dist/lib/instructions/apply.d.ts.map +1 -1
  314. package/dist/lib/instructions/apply.js +74 -3
  315. package/dist/lib/instructions/templates.d.ts.map +1 -1
  316. package/dist/lib/instructions/templates.js +11 -2
  317. package/package.json +19 -2
  318. package/src/commander.ts +75 -1
  319. package/src/commands/admission.ts +699 -0
  320. package/src/commands/agents.ts +189 -50
  321. package/src/commands/artifacts.ts +132 -18
  322. package/src/commands/browse-ai.ts +10 -5
  323. package/src/commands/browse.ts +496 -20
  324. package/src/commands/diagnostics.ts +271 -0
  325. package/src/commands/fetch.ts +1 -0
  326. package/src/commands/files.ts +102 -2
  327. package/src/commands/governor.ts +8 -0
  328. package/src/commands/ledger-v3.ts +82 -0
  329. package/src/commands/qa-record.ts +682 -0
  330. package/src/commands/qa-run.ts +503 -0
  331. package/src/commands/qa-status.ts +608 -0
  332. package/src/commands/qa-verify.ts +238 -0
  333. package/src/commands/resources.ts +38 -0
  334. package/src/commands/review-pack.ts +1281 -0
  335. package/src/commands/supervisor.ts +141 -0
  336. package/src/commands/web.ts +19 -0
  337. package/src/commands/workflow.ts +8 -0
  338. package/src/core/agents/cli.ts +7 -3
  339. package/src/core/agents/health.ts +142 -0
  340. package/src/core/agents/mailbox.ts +373 -0
  341. package/src/core/agents/qa-signal.ts +261 -0
  342. package/src/core/agents/reconcile-coordination-v3.ts +77 -0
  343. package/src/core/agents/render/prompt-context.ts +42 -12
  344. package/src/core/agents/render/session-context.ts +37 -3
  345. package/src/core/agents/rules/commit-conflict.ts +58 -1
  346. package/src/core/agents/rules/stop-hook.ts +6 -12
  347. package/src/core/agents/session-finalizer-v3.ts +4 -1
  348. package/src/core/agents/session-name-display.ts +78 -7
  349. package/src/core/agents/state/heartbeat-reader.ts +7 -0
  350. package/src/core/agents/state/heartbeat-writer.ts +23 -0
  351. package/src/core/agents/state/live-coordination-view.ts +1 -0
  352. package/src/core/agents/state/live-coordination-writer.ts +5 -0
  353. package/src/core/artifacts/constants.ts +1 -1
  354. package/src/core/artifacts/index.ts +565 -30
  355. package/src/core/config.ts +134 -2
  356. package/src/core/diagnostics/advice.ts +147 -0
  357. package/src/core/diagnostics/bundle.ts +1250 -0
  358. package/src/core/diagnostics/comparison.ts +565 -0
  359. package/src/core/diagnostics/contract.ts +311 -0
  360. package/src/core/diagnostics/identity.ts +11 -0
  361. package/src/core/diagnostics/index.ts +7 -0
  362. package/src/core/diagnostics/replay.ts +314 -0
  363. package/src/core/diagnostics/sanitize.ts +97 -0
  364. package/src/core/events/legacy-storage/compression.ts +130 -0
  365. package/src/core/events/legacy-storage/index.ts +1 -0
  366. package/src/core/events/v3/archive-retention.ts +251 -0
  367. package/src/core/events/v3/bootstrap.ts +10 -0
  368. package/src/core/events/v3/capabilities.ts +2 -0
  369. package/src/core/events/v3/coordination-view.ts +100 -11
  370. package/src/core/events/v3/index.ts +9 -0
  371. package/src/core/events/v3/live-routing.ts +1 -0
  372. package/src/core/events/v3/producers/hook-base.ts +14 -2
  373. package/src/core/events/v3/producers/intake.ts +10 -2
  374. package/src/core/events/v3/producers/recorder.ts +420 -17
  375. package/src/core/hooks/adapter/output.ts +12 -1
  376. package/src/core/hooks/cli.ts +332 -247
  377. package/src/core/hooks/health.ts +146 -0
  378. package/src/core/hooks/resolve/transcript.ts +10 -3
  379. package/src/core/hooks/session-name-presence.ts +6 -1
  380. package/src/core/qa-artifacts.ts +126 -0
  381. package/src/core/resources/contract.ts +105 -0
  382. package/src/core/resources/index.ts +5 -0
  383. package/src/core/resources/sampler.ts +590 -0
  384. package/src/core/resources/service-status.ts +62 -0
  385. package/src/core/resources/service.ts +337 -0
  386. package/src/core/resources/storage.ts +44 -0
  387. package/src/core/storage/atomic-json.ts +15 -0
  388. package/src/core/storage/builtins.ts +62 -6
  389. package/src/core/storage/logger.ts +2 -0
  390. package/src/core/storage/query.ts +58 -0
  391. package/src/core/supervisor/activity.ts +193 -0
  392. package/src/core/supervisor/contract.ts +312 -0
  393. package/src/core/supervisor/explanations.ts +104 -0
  394. package/src/core/supervisor/findings.ts +788 -0
  395. package/src/core/supervisor/history.ts +61 -0
  396. package/src/core/supervisor/hook-health-alerts.ts +108 -0
  397. package/src/core/supervisor/hook-health-storage.ts +25 -0
  398. package/src/core/supervisor/hook-health.ts +301 -0
  399. package/src/core/supervisor/hooks.ts +35 -0
  400. package/src/core/supervisor/index.ts +14 -0
  401. package/src/core/supervisor/log-feed.ts +131 -0
  402. package/src/core/supervisor/service.ts +511 -0
  403. package/src/core/supervisor/services.ts +203 -0
  404. package/src/core/supervisor/status.ts +59 -0
  405. package/src/core/supervisor/storage.ts +126 -0
  406. package/src/core/supervisor/timeline.ts +133 -0
  407. package/src/core/workflow/admission.ts +215 -0
  408. package/src/core/workflow/engine.ts +93 -1
  409. package/src/core/workflow/index.ts +10 -0
  410. package/src/core/workflow/proof.ts +54 -1
  411. package/src/core/workflow/run-state.ts +13 -1
  412. package/src/core/workflow/types.ts +39 -1
  413. package/src/lib/admission.ts +347 -0
  414. package/src/lib/agent-browser/client.ts +2 -10
  415. package/src/lib/browser/capture-fidelity.ts +98 -0
  416. package/src/lib/browser/client.ts +212 -10
  417. package/src/lib/browser/critique.ts +62 -7
  418. package/src/lib/browser/index.ts +65 -0
  419. package/src/lib/browser/page-review-judge.ts +360 -0
  420. package/src/lib/browser/page-review-pack.ts +2384 -0
  421. package/src/lib/browser/qa-run-contracts.ts +743 -0
  422. package/src/lib/browser/qa-run.ts +1462 -0
  423. package/src/lib/browser/request-diagnostics.ts +27 -0
  424. package/src/lib/browser/tiling.ts +32 -0
  425. package/src/lib/cookies/client.ts +228 -42
  426. package/src/lib/cookies/extra.ts +28 -0
  427. package/src/lib/cookies/index.ts +2 -0
  428. package/src/lib/coord-root-id.ts +23 -0
  429. package/src/lib/durable-job.ts +407 -0
  430. package/src/lib/http/client.ts +13 -1
  431. package/src/lib/instructions/apply.ts +81 -2
  432. package/src/lib/instructions/templates.ts +11 -2
@@ -0,0 +1,2384 @@
1
+ // Page review pack: the on-disk evidence for reviewing one rendered page
2
+ // without a browser. A pack holds, per rendering context (viewport × theme ×
3
+ // state), the full-page screenshot, its critique tiles as PNG files, the
4
+ // serialized DOM, and the QA signature; plus a `review.md` entry point, a
5
+ // bounded inspection plan, coverage, and a delegated-review `findings.json`.
6
+ //
7
+ // The split this enables: capture (needs a browser, seconds) and judging
8
+ // (vision calls, minutes) become separate stages. Every browser is closed
9
+ // before the first model call, and one bounded pool judges every tile of
10
+ // every context from disk. Tile PNGs are the evidence; every other file in
11
+ // the pack is navigation.
12
+ //
13
+ // Toolkit tier: this module must not import src/core (layering check).
14
+
15
+ import { createHash } from "node:crypto";
16
+ import {
17
+ existsSync,
18
+ lstatSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ renameSync,
23
+ rmdirSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ } from "node:fs";
27
+ import { basename, dirname, join, relative, resolve } from "node:path";
28
+ import { gunzipSync, gzipSync } from "node:zlib";
29
+ import { PNG } from "pngjs";
30
+ import type { CritiqueCoverage, CritiqueFinding, CritiqueTile } from "./critique.js";
31
+ import type { QaContext, QaSignature } from "./qa-plan.js";
32
+
33
+ export const PAGE_REVIEW_PACK_SCHEMA = "harnery-page-review/v1";
34
+ export const PAGE_REVIEW_FINDINGS_SCHEMA = "harnery-page-review-findings/v3";
35
+
36
+ /** Directory name a qa-run gives its pack inside the run directory. */
37
+ export const PAGE_REVIEW_PACK_DIRNAME = "pack";
38
+
39
+ export const PAGE_REVIEW_MANIFEST_FILENAME = "manifest.json";
40
+ export const PAGE_REVIEW_REVIEW_FILENAME = "review.md";
41
+ export const PAGE_REVIEW_FINDINGS_FILENAME = "findings.json";
42
+ export const PAGE_REVIEW_FINDINGS_SCHEMA_FILENAME = "findings.schema.json";
43
+
44
+ /** How long a pack lives after its judge (or its capture, when no judge
45
+ * runs) before the whole directory is deleted. Ruled 2026-09-02. */
46
+ export const PAGE_REVIEW_DEFAULT_RETENTION_MINUTES = 90;
47
+ /** The only file left behind when an expired pack is deleted. */
48
+ export const PAGE_REVIEW_EXPIRED_STUB_FILENAME = "pack-expired.json";
49
+
50
+ /** Reviewer dispositions a machine finding can carry in `findings.json`. */
51
+ export const PAGE_REVIEW_DISPOSITIONS = [
52
+ "confirmed",
53
+ "artifact",
54
+ "not-a-defect",
55
+ "duplicate-of-gate",
56
+ ] as const;
57
+ export type PageReviewDisposition = (typeof PAGE_REVIEW_DISPOSITIONS)[number];
58
+
59
+ /** Schema id and file name of the reviewed-outcome record `review-pack verdict` writes. */
60
+ export const PAGE_REVIEW_VERDICT_SCHEMA = "harnery-page-review-verdict/v2";
61
+ export const PAGE_REVIEW_VERDICT_FILENAME = "verdict.json";
62
+
63
+ export const PAGE_REVIEW_FINDING_SEVERITIES = [
64
+ "critical",
65
+ "high",
66
+ "medium",
67
+ "low",
68
+ "info",
69
+ ] as const;
70
+ export type PageReviewFindingSeverity = (typeof PAGE_REVIEW_FINDING_SEVERITIES)[number];
71
+ export const PAGE_REVIEW_FINDING_CATEGORIES = [
72
+ "layout",
73
+ "typography",
74
+ "contrast",
75
+ "content",
76
+ "image",
77
+ "interaction",
78
+ "accessibility",
79
+ "responsiveness",
80
+ "render-artifact",
81
+ "coverage",
82
+ "other",
83
+ ] as const;
84
+ export type PageReviewFindingCategory = (typeof PAGE_REVIEW_FINDING_CATEGORIES)[number];
85
+
86
+ export const PAGE_REVIEW_SUBAGENT_MODELS = ["GPT-5.6 Luna", "Composer 2.5", "Haiku 4.5"] as const;
87
+ export type PageReviewSubagentModel = (typeof PAGE_REVIEW_SUBAGENT_MODELS)[number];
88
+
89
+ /** One review-subagent finding in `findings.json`. */
90
+ export interface PageReviewReviewerFinding {
91
+ id: string;
92
+ severity: PageReviewFindingSeverity;
93
+ category: PageReviewFindingCategory;
94
+ context_id: string;
95
+ /** Tile ids (`T012`) or pack-relative file paths; never empty. */
96
+ evidence: string[];
97
+ observation: string;
98
+ recommendation?: string;
99
+ confidence?: number;
100
+ }
101
+
102
+ /** A reviewer's verdict on one machine finding in `evidence/critique.json`. */
103
+ export interface PageReviewDispositionRecord {
104
+ /** `<context-id>/<tile-id>#<n>`, n = 0-based position among the tile's findings. */
105
+ target: string;
106
+ disposition: PageReviewDisposition;
107
+ note?: string;
108
+ by?: string;
109
+ at?: string;
110
+ }
111
+
112
+ /** One review subagent's assigned and completed primary-tile work. */
113
+ export interface PageReviewDelegatedReviewRecord {
114
+ reviewer: string;
115
+ model: PageReviewSubagentModel;
116
+ /** `<context-id>/<tile-id>` entries assigned to this subagent. */
117
+ assigned_tiles: string[];
118
+ /** Assigned entries the subagent actually opened at native pixels. */
119
+ completed_tiles: string[];
120
+ status: "complete" | "incomplete";
121
+ }
122
+
123
+ /** The delegated-review `findings.json` document. */
124
+ export interface PageReviewFindingsDocument {
125
+ schema: string;
126
+ schema_path?: string;
127
+ target: string;
128
+ reviewer: string | null;
129
+ reviewed_at: string | null;
130
+ delegated_reviews: PageReviewDelegatedReviewRecord[];
131
+ findings: PageReviewReviewerFinding[];
132
+ dispositions?: PageReviewDispositionRecord[];
133
+ }
134
+
135
+ /** What `resolvePackVerdict` derives from the machine critique plus the reviewer's file. */
136
+ export interface PageReviewVerdict {
137
+ machine_outcome: "pass" | "fail" | "skipped" | "incomplete";
138
+ reviewed_outcome: "pass" | "fail" | "skipped" | "incomplete";
139
+ /** Machine `high` findings across every judged context. */
140
+ high_total: number;
141
+ high_confirmed: number;
142
+ /** Highs dispositioned `artifact`, `not-a-defect`, or `duplicate-of-gate`. */
143
+ high_dismissed: number;
144
+ /** Highs with no disposition; they still count against the page. */
145
+ high_open: number;
146
+ /** Review-subagent findings at `critical` or `high`; each counts against the page. */
147
+ reviewer_high: number;
148
+ /** Dispositions whose target matched a machine finding. */
149
+ dispositions_applied: number;
150
+ /** Disposition targets that name no machine finding in the critique. */
151
+ unmatched_dispositions: string[];
152
+ /** Primary tiles the inspection plan requires. */
153
+ primary_tiles_total: number;
154
+ /** Required tiles covered by a completed review-subagent record. */
155
+ primary_tiles_reviewed: number;
156
+ /** Required `<context-id>/<tile-id>` entries with no completed subagent review. */
157
+ uncovered_primary_tiles: string[];
158
+ }
159
+
160
+ export interface PageReviewVerdictDocument extends PageReviewVerdict {
161
+ schema: string;
162
+ reviewed_at: string;
163
+ }
164
+
165
+ /** How a context's tiles were cut: from one full-page screenshot (the
166
+ * default) or from per-band scrolled viewport captures (the fallback when the
167
+ * fidelity probe proved the full-page screenshot wrong). */
168
+ export interface PageReviewCaptureFidelity {
169
+ source: "full-page" | "scrolled-bands";
170
+ /** Bands re-shot by scroll-and-clip and compared with the full-page capture. */
171
+ probed: Array<{ tile_id: string; scrollY: number; height: number; mismatch_ratio: number }>;
172
+ /** Tile ids whose probe exceeded the mismatch threshold. */
173
+ mismatched: string[];
174
+ mismatch_threshold: number;
175
+ }
176
+
177
+ /** Pack expiry as written into the manifest. `managed` is false for a pack
178
+ * written to an explicit `--out`; such a pack is never deleted automatically. */
179
+ export interface PageReviewRetention {
180
+ expires_at: string;
181
+ managed: boolean;
182
+ }
183
+
184
+ /** One tile as stored in the pack. `id` is stable within its context
185
+ * (`T001`, `T002`, …) and is what a finding cites. `file` is pack-relative. */
186
+ export interface PageReviewTileRecord {
187
+ id: string;
188
+ /** Tiler index (position in the capture's tile list). */
189
+ index: number;
190
+ label: string;
191
+ /** Selector the tile was cut for; absent for full-page bands. */
192
+ scope?: string;
193
+ /** `band` (full-page band), `scope` (selector tile), or `hit-band` (a band
194
+ * captured past the tile cap because a gate hit lands in it). Absent on
195
+ * packs written before this field existed; treat as `band`/`scope` by
196
+ * whether `scope` is set. */
197
+ kind?: "band" | "scope" | "hit-band";
198
+ x: number;
199
+ scrollY: number;
200
+ width: number;
201
+ height: number;
202
+ file: string;
203
+ sha256: string;
204
+ bytes: number;
205
+ }
206
+
207
+ /** One tile region re-captured at a higher device scale factor after the
208
+ * original capture (`review-pack expand`). The source tile is untouched; this
209
+ * record sits beside it. `file` is pack-relative. Optional and additive on the
210
+ * v1 context record. */
211
+ export interface PageReviewExpandedTileRecord {
212
+ /** Source tile id (`T012`). */
213
+ tile: string;
214
+ /** Device scale factor the region was rendered at (2 = twice the pixels). */
215
+ dpr: number;
216
+ /** Pixel size of the expanded PNG (source tile size × dpr, clamped). */
217
+ width: number;
218
+ height: number;
219
+ file: string;
220
+ sha256: string;
221
+ bytes: number;
222
+ captured_at: string;
223
+ }
224
+
225
+ /** The per-context contact sheet: every tile downscaled into one grid PNG
226
+ * for orientation. Reading order is row-major (left to right, then top to
227
+ * bottom) in tile id order; each cell carries its tile id stamped in its
228
+ * label band. Optional and additive on the v1 context record. */
229
+ export interface PageReviewContactSheetRecord {
230
+ file: string;
231
+ sha256: string;
232
+ bytes: number;
233
+ width: number;
234
+ height: number;
235
+ columns: number;
236
+ rows: number;
237
+ cell_width: number;
238
+ cell_height: number;
239
+ order: "row-major";
240
+ }
241
+
242
+ export interface PageReviewContextRecord {
243
+ id: string;
244
+ viewport: string;
245
+ theme: "light" | "dark";
246
+ state: string;
247
+ /** Rendered URL after navigation. */
248
+ url: string;
249
+ title?: string;
250
+ captured_at: string;
251
+ page: { width: number; height: number };
252
+ /** Full-page band coverage (what the tiler kept of the page). */
253
+ coverage: CritiqueCoverage;
254
+ scopes: Array<{ selector: string; tiles: number }>;
255
+ /** Pack-relative file paths. `dom` ends in `.gz` (gzip) for packs written
256
+ * after the footprint change and in `.html` (plain) for older packs. */
257
+ files: { full_page: string; dom: string; signature: string; tiles: string; context: string };
258
+ dom_sha256: string;
259
+ tiles: PageReviewTileRecord[];
260
+ /** Higher-DPR re-captures of single tiles, in the order they were made. */
261
+ expanded?: PageReviewExpandedTileRecord[];
262
+ /** Downscaled grid of every tile (`contacts.png`); absent for a context with no tiles. */
263
+ contact_sheet?: PageReviewContactSheetRecord;
264
+ /** Where the tiles came from and what the fidelity probe saw. Absent on
265
+ * packs written before the probe existed (tiles came from the full page). */
266
+ capture_fidelity?: PageReviewCaptureFidelity;
267
+ /** Bands captured past the tile cap because a gate hit lands in them. */
268
+ hit_bands?: number;
269
+ }
270
+
271
+ /** What a capture hands the pack for one context. `tiles` are the full-page
272
+ * bands; `scopeTiles` are selector tiles, one entry per selector. */
273
+ export interface PageReviewContextCapture {
274
+ context: QaContext & { id?: string };
275
+ url: string;
276
+ title?: string;
277
+ fullPage: Buffer;
278
+ pageWidth: number;
279
+ pageHeight: number;
280
+ tiles: CritiqueTile[];
281
+ coverage: CritiqueCoverage;
282
+ scopeTiles?: Array<{ selector: string; tiles: CritiqueTile[] }>;
283
+ signature: QaSignature;
284
+ domHtml: string;
285
+ capturedAt?: string;
286
+ /** Result of the capture-fidelity probe (capture-fidelity.ts); absent when
287
+ * the capture did not probe. Copied onto the record as `capture_fidelity`. */
288
+ captureFidelity?: PageReviewCaptureFidelity;
289
+ /** Bands cut past the tile cap because a gate hit lands in them (their
290
+ * tiles sit in `tiles` after the kept bands, labelled `hit band N`). */
291
+ hitBands?: number;
292
+ }
293
+
294
+ /** One context's machine critique, as the judge records it into the pack. */
295
+ export interface PageReviewCritiqueRecord {
296
+ context_id: string;
297
+ provider: string;
298
+ tiles_total: number;
299
+ tiles_reviewed: number;
300
+ tiles_reused: number;
301
+ outcome: "pass" | "fail" | "skipped" | "incomplete";
302
+ findings: Array<CritiqueFinding & { tile_id: string }>;
303
+ coverage: CritiqueCoverage;
304
+ error?: string;
305
+ }
306
+
307
+ export interface PageReviewRect {
308
+ x: number;
309
+ y: number;
310
+ width: number;
311
+ height: number;
312
+ }
313
+
314
+ /** One gate finding that carries a document-space rectangle (CSS px at the
315
+ * capture's device scale factor 1, the same space as tile `x`/`scrollY`). */
316
+ export interface PageReviewGateHit {
317
+ /** Check family the hit came from: runts, contrast, truncation, clip, … */
318
+ rule: string;
319
+ /** Short locator: the element label plus the check's own detail. */
320
+ label: string;
321
+ rect: PageReviewRect;
322
+ }
323
+
324
+ export interface PageReviewGateRecord {
325
+ context_id: string;
326
+ check_id: string;
327
+ outcome: "passed" | "failed" | "unknown";
328
+ failures: string[];
329
+ /** Rectangles the gate's envelope recorded; optional and additive. */
330
+ hits?: PageReviewGateHit[];
331
+ }
332
+
333
+ export interface PageReviewInspectionGateHit extends PageReviewGateHit {
334
+ check_id: string;
335
+ /** Tile ids whose rect intersects the hit; empty when no tile covers it. */
336
+ tiles: string[];
337
+ }
338
+
339
+ export interface PageReviewInspectionPlan {
340
+ schema: string;
341
+ purpose: string;
342
+ contexts: Array<{
343
+ context_id: string;
344
+ full_page: string;
345
+ /** Tiles review subagents must open for a complete review; everything else is drill-down. */
346
+ primary_tiles: Array<{ id: string; file: string; reason: string }>;
347
+ drilldown_tiles: number;
348
+ /** Every gate hit with a rectangle, mapped to the tiles that show it. */
349
+ gate_hits: PageReviewInspectionGateHit[];
350
+ }>;
351
+ }
352
+
353
+ export interface PageReviewPackManifest {
354
+ schema: string;
355
+ created_at: string;
356
+ target: string;
357
+ tested_revision?: string;
358
+ tool: { name: string; version?: string };
359
+ contexts: PageReviewContextRecord[];
360
+ gates: PageReviewGateRecord[];
361
+ critique: PageReviewCritiqueRecord[] | null;
362
+ pool?: { concurrency: number; wall_time_ms: number; provider: string };
363
+ not_checked: Array<{ check: string; reason: string }>;
364
+ warnings: string[];
365
+ /** Pack expiry; absent until the writer knows when the judge finished. */
366
+ retention?: PageReviewRetention;
367
+ /** Bytes on disk across every file in the pack at finalize time. */
368
+ size_bytes?: number;
369
+ files: {
370
+ review: string;
371
+ findings: string;
372
+ findings_schema: string;
373
+ inspection_plan: string;
374
+ coverage: string;
375
+ index: string;
376
+ inventory: string;
377
+ critique: string;
378
+ };
379
+ }
380
+
381
+ export function packPaths(packDir: string) {
382
+ const evidenceDir = join(packDir, "evidence");
383
+ const contextsDir = join(packDir, "contexts");
384
+ return {
385
+ dir: packDir,
386
+ manifest: join(packDir, PAGE_REVIEW_MANIFEST_FILENAME),
387
+ review: join(packDir, PAGE_REVIEW_REVIEW_FILENAME),
388
+ findings: join(packDir, PAGE_REVIEW_FINDINGS_FILENAME),
389
+ findingsSchema: join(packDir, PAGE_REVIEW_FINDINGS_SCHEMA_FILENAME),
390
+ evidenceDir,
391
+ inspectionPlan: join(evidenceDir, "inspection-plan.json"),
392
+ coverage: join(evidenceDir, "coverage.json"),
393
+ index: join(evidenceDir, "index.json"),
394
+ inventory: join(evidenceDir, "files.json"),
395
+ critique: join(evidenceDir, "critique.json"),
396
+ verdict: join(evidenceDir, PAGE_REVIEW_VERDICT_FILENAME),
397
+ contextsDir,
398
+ contextDir: (contextId: string) => join(contextsDir, safeSegment(contextId)),
399
+ };
400
+ }
401
+
402
+ function safeSegment(value: string): string {
403
+ const clean = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^\.+/, "");
404
+ return clean.length > 0 ? clean : "context";
405
+ }
406
+
407
+ export function sha256Hex(buffer: Buffer | string): string {
408
+ return createHash("sha256").update(buffer).digest("hex");
409
+ }
410
+
411
+ export function tileId(position: number): string {
412
+ return `T${String(position + 1).padStart(3, "0")}`;
413
+ }
414
+
415
+ function writeJson(path: string, value: unknown): void {
416
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
417
+ }
418
+
419
+ function readJson<T>(path: string): T {
420
+ return JSON.parse(readFileSync(path, "utf8")) as T;
421
+ }
422
+
423
+ /** Deterministic file name for an expanded tile: `T012@2x.png`. */
424
+ export function expandedTileFilename(tileId: string, dpr: number): string {
425
+ return `${tileId}@${dpr}x.png`;
426
+ }
427
+
428
+ /**
429
+ * Crop one region out of a PNG buffer in pixel space. The rect is clamped to
430
+ * the image so an off-by-one never throws; the result is at least 1×1.
431
+ */
432
+ export function cropPngRegion(
433
+ buffer: Buffer,
434
+ rect: { x: number; y: number; width: number; height: number },
435
+ ): { png: Buffer; width: number; height: number } {
436
+ const src = PNG.sync.read(buffer);
437
+ const sx = Math.max(0, Math.min(Math.round(rect.x), src.width - 1));
438
+ const sy = Math.max(0, Math.min(Math.round(rect.y), src.height - 1));
439
+ const w = Math.max(1, Math.min(Math.round(rect.width), src.width - sx));
440
+ const h = Math.max(1, Math.min(Math.round(rect.height), src.height - sy));
441
+ const dst = new PNG({ width: w, height: h });
442
+ for (let row = 0; row < h; row++) {
443
+ const srcStart = ((sy + row) * src.width + sx) * 4;
444
+ src.data.copy(dst.data, row * w * 4, srcStart, srcStart + w * 4);
445
+ }
446
+ return { png: PNG.sync.write(dst), width: w, height: h };
447
+ }
448
+
449
+ /**
450
+ * Locate one tile across a pack's contexts. With `contextId` the lookup is
451
+ * exact; without it the tile id must be unique across the given contexts,
452
+ * otherwise the caller has to name the context.
453
+ */
454
+ export function findPackTile(
455
+ contexts: PageReviewContextRecord[],
456
+ tileId: string,
457
+ contextId?: string,
458
+ ): { context: PageReviewContextRecord; tile: PageReviewTileRecord } {
459
+ if (contextId !== undefined) {
460
+ const context = contexts.find((ctx) => ctx.id === contextId);
461
+ if (!context) {
462
+ throw new Error(
463
+ `context ${contextId} is not in the pack (have: ${contexts.map((c) => c.id).join(", ") || "none"})`,
464
+ );
465
+ }
466
+ const tile = context.tiles.find((t) => t.id === tileId);
467
+ if (!tile) {
468
+ throw new Error(
469
+ `tile ${tileId} is not in context ${contextId} (${context.tiles.length} tile(s): ${
470
+ context.tiles[0]?.id ?? "none"
471
+ }…${context.tiles[context.tiles.length - 1]?.id ?? ""})`,
472
+ );
473
+ }
474
+ return { context, tile };
475
+ }
476
+ const matches = contexts.flatMap((context) =>
477
+ context.tiles.filter((t) => t.id === tileId).map((tile) => ({ context, tile })),
478
+ );
479
+ if (matches.length === 0) throw new Error(`tile ${tileId} is not in any context of the pack`);
480
+ if (matches.length > 1) {
481
+ throw new Error(
482
+ `tile ${tileId} exists in ${matches.length} contexts (${matches
483
+ .map((m) => m.context.id)
484
+ .join(", ")}); pass --context <id>`,
485
+ );
486
+ }
487
+ return matches[0] as { context: PageReviewContextRecord; tile: PageReviewTileRecord };
488
+ }
489
+
490
+ /**
491
+ * Write one tile region re-rendered at `dpr` into an existing context:
492
+ * `tiles/<tile>@<dpr>x.png` cropped from `fullPage` (a screenshot of the same
493
+ * page at that device scale factor, so the source tile's CSS-pixel rect is
494
+ * multiplied by `dpr`). Existing tiles are never touched; the context record
495
+ * gains or replaces one `expanded` entry for that tile + dpr and is rewritten.
496
+ */
497
+ export function writePackExpandedTile(
498
+ packDir: string,
499
+ contextId: string,
500
+ input: { tileId: string; dpr: number; capturedAt?: string } & (
501
+ | { fullPage: Buffer; region?: undefined }
502
+ | {
503
+ fullPage?: undefined;
504
+ /** The tile's region already rendered at `dpr` (a scrolled viewport
505
+ * capture, `Browser.captureRegionByScroll`); written as is. */
506
+ region: Buffer;
507
+ }
508
+ ),
509
+ ): { record: PageReviewContextRecord; expanded: PageReviewExpandedTileRecord } {
510
+ if (!Number.isFinite(input.dpr) || input.dpr <= 0) {
511
+ throw new Error(`dpr must be a positive number (got ${input.dpr})`);
512
+ }
513
+ const record = readPackContext(packDir, contextId);
514
+ const { tile } = findPackTile([record], input.tileId, contextId);
515
+ const paths = packPaths(packDir);
516
+ const dir = paths.contextDir(contextId);
517
+ const file = join(dir, "tiles", expandedTileFilename(tile.id, input.dpr));
518
+ const { png, width, height } = input.region
519
+ ? {
520
+ png: input.region,
521
+ width: input.region.readUInt32BE(16),
522
+ height: input.region.readUInt32BE(20),
523
+ }
524
+ : cropPngRegion(input.fullPage, {
525
+ x: tile.x * input.dpr,
526
+ y: tile.scrollY * input.dpr,
527
+ width: tile.width * input.dpr,
528
+ height: tile.height * input.dpr,
529
+ });
530
+ mkdirSync(join(dir, "tiles"), { recursive: true });
531
+ writeFileSync(file, png);
532
+ const expanded: PageReviewExpandedTileRecord = {
533
+ tile: tile.id,
534
+ dpr: input.dpr,
535
+ width,
536
+ height,
537
+ file: relative(packDir, file).split("\\").join("/"),
538
+ sha256: sha256Hex(png),
539
+ bytes: png.byteLength,
540
+ captured_at: input.capturedAt ?? new Date().toISOString(),
541
+ };
542
+ const kept = (record.expanded ?? []).filter(
543
+ (entry) => !(entry.tile === expanded.tile && entry.dpr === expanded.dpr),
544
+ );
545
+ const next: PageReviewContextRecord = { ...record, expanded: [...kept, expanded] };
546
+ writeJson(join(dir, "context.json"), { schema: PAGE_REVIEW_PACK_SCHEMA, ...next });
547
+ return { record: next, expanded };
548
+ }
549
+
550
+ /**
551
+ * Write one context's capture into the pack: `full-page.png`, `dom.html.gz`,
552
+ * `signature.json`, `tiles/T001.png…`, `tiles.json`, and `context.json`.
553
+ * Full-page bands come first, then each scope's tiles in selector order, so
554
+ * tile ids are stable for a given capture. Returns the record written.
555
+ */
556
+ export function writePackContext(
557
+ packDir: string,
558
+ capture: PageReviewContextCapture,
559
+ ): PageReviewContextRecord {
560
+ const contextId =
561
+ capture.context.id ??
562
+ `${capture.context.viewport}-${capture.context.theme}-${capture.context.state}`;
563
+ const paths = packPaths(packDir);
564
+ const dir = paths.contextDir(contextId);
565
+ const tilesDir = join(dir, "tiles");
566
+ mkdirSync(tilesDir, { recursive: true });
567
+
568
+ const rel = (abs: string): string => relative(packDir, abs).split("\\").join("/");
569
+ const fullPagePath = join(dir, "full-page.png");
570
+ const domPath = join(dir, "dom.html.gz");
571
+ const signaturePath = join(dir, "signature.json");
572
+ const tilesPath = join(dir, "tiles.json");
573
+ const contextPath = join(dir, "context.json");
574
+ writeFileSync(fullPagePath, capture.fullPage);
575
+ // The serialized DOM compresses roughly 10:1; `dom_sha256` stays the digest
576
+ // of the uncompressed bytes so a reader can verify what `readPackDom` returns.
577
+ writeFileSync(domPath, gzipSync(Buffer.from(capture.domHtml, "utf8")));
578
+ writeJson(signaturePath, capture.signature);
579
+
580
+ const records: PageReviewTileRecord[] = [];
581
+ const tilePngs = new Map<string, Buffer>();
582
+ const ordered: Array<{ tile: CritiqueTile; scope?: string }> = [
583
+ ...capture.tiles.map((tile) => ({ tile })),
584
+ ...(capture.scopeTiles ?? []).flatMap((entry) =>
585
+ entry.tiles.map((tile) => ({ tile, scope: entry.selector })),
586
+ ),
587
+ ];
588
+ ordered.forEach(({ tile, scope }, position) => {
589
+ const id = tileId(position);
590
+ const png = Buffer.from(tile.pngBase64, "base64");
591
+ const file = join(tilesDir, `${id}.png`);
592
+ writeFileSync(file, png);
593
+ tilePngs.set(id, png);
594
+ records.push({
595
+ id,
596
+ index: tile.index,
597
+ label: tile.label,
598
+ ...(scope !== undefined ? { scope } : {}),
599
+ kind: tile.label.startsWith("hit band") ? "hit-band" : scope !== undefined ? "scope" : "band",
600
+ x: tile.x ?? 0,
601
+ scrollY: tile.scrollY,
602
+ width: tile.width,
603
+ height: tile.height,
604
+ file: rel(file),
605
+ sha256: sha256Hex(png),
606
+ bytes: png.byteLength,
607
+ });
608
+ });
609
+
610
+ const record: PageReviewContextRecord = {
611
+ id: contextId,
612
+ viewport: capture.context.viewport,
613
+ theme: capture.context.theme,
614
+ state: capture.context.state,
615
+ url: capture.url,
616
+ ...(capture.title !== undefined ? { title: capture.title } : {}),
617
+ captured_at: capture.capturedAt ?? new Date().toISOString(),
618
+ page: { width: capture.pageWidth, height: capture.pageHeight },
619
+ coverage: capture.coverage,
620
+ scopes: (capture.scopeTiles ?? []).map((entry) => ({
621
+ selector: entry.selector,
622
+ tiles: entry.tiles.length,
623
+ })),
624
+ files: {
625
+ full_page: rel(fullPagePath),
626
+ dom: rel(domPath),
627
+ signature: rel(signaturePath),
628
+ tiles: rel(tilesPath),
629
+ context: rel(contextPath),
630
+ },
631
+ dom_sha256: sha256Hex(capture.domHtml),
632
+ tiles: records,
633
+ ...(capture.captureFidelity ? { capture_fidelity: capture.captureFidelity } : {}),
634
+ ...(capture.hitBands !== undefined && capture.hitBands > 0
635
+ ? { hit_bands: capture.hitBands }
636
+ : {}),
637
+ };
638
+ writeJson(tilesPath, { schema: PAGE_REVIEW_PACK_SCHEMA, context_id: contextId, tiles: records });
639
+ writeJson(contextPath, { schema: PAGE_REVIEW_PACK_SCHEMA, ...record });
640
+ // The contact sheet is navigation, so it rides on the record but never
641
+ // gates the capture: the tiles are already on disk when it is built.
642
+ return writePackContactSheet(packDir, record, tilePngs);
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Contact sheet
647
+ // ---------------------------------------------------------------------------
648
+
649
+ export const CONTACT_SHEET_FILENAME = "contacts.png";
650
+ /** Tiles per row. Four 320 px cells plus gutters stay under a 1600 px sheet. */
651
+ export const CONTACT_SHEET_COLUMNS = 4;
652
+ const CONTACT_CELL = 320;
653
+ const CONTACT_LABEL_H = 20;
654
+ const CONTACT_GUTTER = 8;
655
+ const CONTACT_BG: [number, number, number] = [212, 212, 216];
656
+ const CONTACT_LABEL_BG: [number, number, number] = [24, 24, 27];
657
+ const CONTACT_LABEL_FG: [number, number, number] = [250, 250, 250];
658
+
659
+ /** 3×5 bitmap glyphs for the characters a tile id uses. Rows top-down, bits
660
+ * left-to-right. Anything else renders as a blank column. */
661
+ const CONTACT_FONT: Record<string, number[]> = {
662
+ "0": [0b111, 0b101, 0b101, 0b101, 0b111],
663
+ "1": [0b010, 0b110, 0b010, 0b010, 0b111],
664
+ "2": [0b111, 0b001, 0b111, 0b100, 0b111],
665
+ "3": [0b111, 0b001, 0b111, 0b001, 0b111],
666
+ "4": [0b101, 0b101, 0b111, 0b001, 0b001],
667
+ "5": [0b111, 0b100, 0b111, 0b001, 0b111],
668
+ "6": [0b111, 0b100, 0b111, 0b101, 0b111],
669
+ "7": [0b111, 0b001, 0b001, 0b001, 0b001],
670
+ "8": [0b111, 0b101, 0b111, 0b101, 0b111],
671
+ "9": [0b111, 0b101, 0b111, 0b001, 0b111],
672
+ T: [0b111, 0b010, 0b010, 0b010, 0b010],
673
+ };
674
+
675
+ function fillRect(
676
+ png: PNG,
677
+ x0: number,
678
+ y0: number,
679
+ w: number,
680
+ h: number,
681
+ rgb: [number, number, number],
682
+ ): void {
683
+ for (let y = y0; y < y0 + h; y++) {
684
+ for (let x = x0; x < x0 + w; x++) {
685
+ const i = (y * png.width + x) * 4;
686
+ png.data[i] = rgb[0];
687
+ png.data[i + 1] = rgb[1];
688
+ png.data[i + 2] = rgb[2];
689
+ png.data[i + 3] = 255;
690
+ }
691
+ }
692
+ }
693
+
694
+ /** Stamp `text` at (x, y) with the bitmap font at `scale` pixels per dot. */
695
+ function stampText(
696
+ png: PNG,
697
+ text: string,
698
+ x: number,
699
+ y: number,
700
+ scale: number,
701
+ rgb: [number, number, number],
702
+ ): void {
703
+ let cursor = x;
704
+ for (const ch of text) {
705
+ const glyph = CONTACT_FONT[ch];
706
+ if (glyph) {
707
+ glyph.forEach((rowBits, row) => {
708
+ for (let col = 0; col < 3; col++) {
709
+ if ((rowBits >> (2 - col)) & 1) {
710
+ fillRect(png, cursor + col * scale, y + row * scale, scale, scale, rgb);
711
+ }
712
+ }
713
+ });
714
+ }
715
+ cursor += 4 * scale;
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Box-filter downscale (area average) of `src` to `dstW`×`dstH`. Never used
721
+ * to upscale; callers pass a destination no larger than the source.
722
+ */
723
+ function boxDownscale(src: PNG, dstW: number, dstH: number): PNG {
724
+ const dst = new PNG({ width: dstW, height: dstH });
725
+ for (let dy = 0; dy < dstH; dy++) {
726
+ const sy0 = Math.floor((dy * src.height) / dstH);
727
+ const sy1 = Math.max(sy0 + 1, Math.floor(((dy + 1) * src.height) / dstH));
728
+ for (let dx = 0; dx < dstW; dx++) {
729
+ const sx0 = Math.floor((dx * src.width) / dstW);
730
+ const sx1 = Math.max(sx0 + 1, Math.floor(((dx + 1) * src.width) / dstW));
731
+ let r = 0;
732
+ let g = 0;
733
+ let b = 0;
734
+ let a = 0;
735
+ let n = 0;
736
+ for (let sy = sy0; sy < sy1; sy++) {
737
+ for (let sx = sx0; sx < sx1; sx++) {
738
+ const i = (sy * src.width + sx) * 4;
739
+ r += src.data[i] ?? 0;
740
+ g += src.data[i + 1] ?? 0;
741
+ b += src.data[i + 2] ?? 0;
742
+ a += src.data[i + 3] ?? 0;
743
+ n++;
744
+ }
745
+ }
746
+ const o = (dy * dstW + dx) * 4;
747
+ dst.data[o] = Math.round(r / n);
748
+ dst.data[o + 1] = Math.round(g / n);
749
+ dst.data[o + 2] = Math.round(b / n);
750
+ dst.data[o + 3] = Math.round(a / n);
751
+ }
752
+ }
753
+ return dst;
754
+ }
755
+
756
+ /**
757
+ * Build one contact sheet from tile PNGs, in the order given: a fixed grid of
758
+ * `CONTACT_SHEET_COLUMNS` cells per row, each cell a label band stamped with
759
+ * the tile id above the tile downscaled (box filter, aspect kept, never
760
+ * upscaled) to fit the cell. Row-major reading order.
761
+ */
762
+ export function buildContactSheet(tiles: Array<{ id: string; png: Buffer }>): {
763
+ png: Buffer;
764
+ width: number;
765
+ height: number;
766
+ columns: number;
767
+ rows: number;
768
+ cell_width: number;
769
+ cell_height: number;
770
+ } {
771
+ const columns = Math.max(1, Math.min(CONTACT_SHEET_COLUMNS, tiles.length));
772
+ const rows = Math.max(1, Math.ceil(tiles.length / columns));
773
+ const cellH = CONTACT_LABEL_H + CONTACT_CELL;
774
+ const width = columns * CONTACT_CELL + (columns + 1) * CONTACT_GUTTER;
775
+ const height = rows * cellH + (rows + 1) * CONTACT_GUTTER;
776
+ const sheet = new PNG({ width, height });
777
+ fillRect(sheet, 0, 0, width, height, CONTACT_BG);
778
+ tiles.forEach((tile, position) => {
779
+ const col = position % columns;
780
+ const row = Math.floor(position / columns);
781
+ const x0 = CONTACT_GUTTER + col * (CONTACT_CELL + CONTACT_GUTTER);
782
+ const y0 = CONTACT_GUTTER + row * (cellH + CONTACT_GUTTER);
783
+ fillRect(sheet, x0, y0, CONTACT_CELL, CONTACT_LABEL_H, CONTACT_LABEL_BG);
784
+ stampText(sheet, tile.id, x0 + 4, y0 + 3, 3, CONTACT_LABEL_FG);
785
+ const src = PNG.sync.read(tile.png);
786
+ const scale = Math.min(1, CONTACT_CELL / src.width, CONTACT_CELL / src.height);
787
+ const dstW = Math.max(1, Math.round(src.width * scale));
788
+ const dstH = Math.max(1, Math.round(src.height * scale));
789
+ const scaled = scale < 1 ? boxDownscale(src, dstW, dstH) : src;
790
+ const ty = y0 + CONTACT_LABEL_H;
791
+ for (let y = 0; y < scaled.height; y++) {
792
+ const srcStart = y * scaled.width * 4;
793
+ scaled.data.copy(
794
+ sheet.data,
795
+ ((ty + y) * width + x0) * 4,
796
+ srcStart,
797
+ srcStart + scaled.width * 4,
798
+ );
799
+ }
800
+ });
801
+ return {
802
+ png: PNG.sync.write(sheet),
803
+ width,
804
+ height,
805
+ columns,
806
+ rows,
807
+ cell_width: CONTACT_CELL,
808
+ cell_height: cellH,
809
+ };
810
+ }
811
+
812
+ /**
813
+ * Write (or rewrite) a context's `contacts.png` from its tiles and record it
814
+ * on the context. Tile bytes are read from the pack unless supplied. A
815
+ * context with no tiles gets no sheet and its record is returned unchanged.
816
+ */
817
+ export function writePackContactSheet(
818
+ packDir: string,
819
+ record: PageReviewContextRecord,
820
+ tilePngs?: Map<string, Buffer>,
821
+ ): PageReviewContextRecord {
822
+ if (record.tiles.length === 0) return record;
823
+ const tiles = record.tiles.map((tile) => ({
824
+ id: tile.id,
825
+ png: tilePngs?.get(tile.id) ?? readFileSync(join(packDir, tile.file)),
826
+ }));
827
+ const sheet = buildContactSheet(tiles);
828
+ const dir = packPaths(packDir).contextDir(record.id);
829
+ mkdirSync(dir, { recursive: true });
830
+ const file = join(dir, CONTACT_SHEET_FILENAME);
831
+ writeFileSync(file, sheet.png);
832
+ const next: PageReviewContextRecord = {
833
+ ...record,
834
+ contact_sheet: {
835
+ file: relative(packDir, file).split("\\").join("/"),
836
+ sha256: sha256Hex(sheet.png),
837
+ bytes: sheet.png.byteLength,
838
+ width: sheet.width,
839
+ height: sheet.height,
840
+ columns: sheet.columns,
841
+ rows: sheet.rows,
842
+ cell_width: sheet.cell_width,
843
+ cell_height: sheet.cell_height,
844
+ order: "row-major",
845
+ },
846
+ };
847
+ writeJson(join(dir, "context.json"), { schema: PAGE_REVIEW_PACK_SCHEMA, ...next });
848
+ return next;
849
+ }
850
+
851
+ /** Context ids present in the pack, in directory order. */
852
+ export function listPackContexts(packDir: string): string[] {
853
+ const dir = packPaths(packDir).contextsDir;
854
+ if (!existsSync(dir)) return [];
855
+ return readdirSync(dir, { withFileTypes: true })
856
+ .filter((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, "context.json")))
857
+ .map((entry) => entry.name)
858
+ .sort();
859
+ }
860
+
861
+ export function readPackContext(packDir: string, contextId: string): PageReviewContextRecord {
862
+ const path = join(packPaths(packDir).contextDir(contextId), "context.json");
863
+ const { schema, ...record } = readJson<PageReviewContextRecord & { schema?: string }>(path);
864
+ if (schema !== PAGE_REVIEW_PACK_SCHEMA) {
865
+ throw new Error(`${path}: expected schema ${PAGE_REVIEW_PACK_SCHEMA}, found ${schema}`);
866
+ }
867
+ return record;
868
+ }
869
+
870
+ /**
871
+ * Load a context's tiles back as `CritiqueTile`s (PNG bytes read from disk
872
+ * and base64-encoded), verifying each file against its recorded digest so a
873
+ * judge never reviews a tile that was altered after capture.
874
+ */
875
+ export function readPackTiles(
876
+ packDir: string,
877
+ record: PageReviewContextRecord,
878
+ ): Array<CritiqueTile & { id: string; scope?: string }> {
879
+ return record.tiles.map((tile) => {
880
+ const abs = join(packDir, tile.file);
881
+ const png = readFileSync(abs);
882
+ const digest = sha256Hex(png);
883
+ if (digest !== tile.sha256) {
884
+ throw new Error(`${abs}: tile digest ${digest} does not match recorded ${tile.sha256}`);
885
+ }
886
+ return {
887
+ id: tile.id,
888
+ ...(tile.scope !== undefined ? { scope: tile.scope } : {}),
889
+ index: tile.index,
890
+ label: tile.label,
891
+ scrollY: tile.scrollY,
892
+ x: tile.x,
893
+ width: tile.width,
894
+ height: tile.height,
895
+ pngBase64: png.toString("base64"),
896
+ };
897
+ });
898
+ }
899
+
900
+ export function readPackFullPage(packDir: string, record: PageReviewContextRecord): Buffer {
901
+ return readFileSync(join(packDir, record.files.full_page));
902
+ }
903
+
904
+ export function readPackSignature(packDir: string, record: PageReviewContextRecord): QaSignature {
905
+ return readJson<QaSignature>(join(packDir, record.files.signature));
906
+ }
907
+
908
+ /** Read a context's serialized DOM, inflating a gzip-compressed `dom.html.gz`
909
+ * and reading an older plain `dom.html` as-is. */
910
+ export function readPackDom(packDir: string, record: PageReviewContextRecord): string {
911
+ const path = join(packDir, record.files.dom);
912
+ const bytes = readFileSync(path);
913
+ return (path.endsWith(".gz") ? gunzipSync(bytes) : bytes).toString("utf8");
914
+ }
915
+
916
+ export function readPackManifest(packDir: string): PageReviewPackManifest {
917
+ const manifest = readJson<PageReviewPackManifest>(packPaths(packDir).manifest);
918
+ if (manifest.schema !== PAGE_REVIEW_PACK_SCHEMA) {
919
+ throw new Error(
920
+ `${packPaths(packDir).manifest}: expected schema ${PAGE_REVIEW_PACK_SCHEMA}, found ${manifest.schema}`,
921
+ );
922
+ }
923
+ return manifest;
924
+ }
925
+
926
+ /** Read the bounded primary-tile plan used to prove delegated review coverage. */
927
+ export function readPackInspectionPlan(packDir: string): PageReviewInspectionPlan {
928
+ return readJson<PageReviewInspectionPlan>(packPaths(packDir).inspectionPlan);
929
+ }
930
+
931
+ // ---------------------------------------------------------------------------
932
+ // Findings, dispositions, verdict: the reviewer's half of the pack
933
+ // ---------------------------------------------------------------------------
934
+
935
+ export function readPackFindings(packDir: string): PageReviewFindingsDocument {
936
+ const path = packPaths(packDir).findings;
937
+ const doc = readJson<PageReviewFindingsDocument>(path);
938
+ if (doc.schema !== PAGE_REVIEW_FINDINGS_SCHEMA) {
939
+ throw new Error(
940
+ `${path}: expected schema ${PAGE_REVIEW_FINDINGS_SCHEMA}, found ${String(doc.schema)}`,
941
+ );
942
+ }
943
+ return doc;
944
+ }
945
+
946
+ /** Write `findings.json` atomically: a temp file beside it, then a rename. */
947
+ export function writePackFindings(packDir: string, doc: PageReviewFindingsDocument): void {
948
+ const path = packPaths(packDir).findings;
949
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
950
+ writeFileSync(temp, `${JSON.stringify(doc, null, 2)}\n`);
951
+ renameSync(temp, path);
952
+ }
953
+
954
+ const FINDING_ID_PATTERN = /^[A-Z][A-Z0-9_-]*$/;
955
+ const DISPOSITION_TARGET_PATTERN = /^[^/#]+\/T[0-9]{3}#[0-9]+$/;
956
+ const REVIEW_TILE_PATTERN = /^[^/]+\/T[0-9]{3}$/;
957
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
958
+
959
+ function isRecord(value: unknown): value is Record<string, unknown> {
960
+ return typeof value === "object" && value !== null && !Array.isArray(value);
961
+ }
962
+
963
+ /**
964
+ * Check a findings document against the rules `findingsSchemaDocument()`
965
+ * states, by hand so the toolkit tier carries no validator dependency.
966
+ * Returns every violation found; an empty array means the document is valid.
967
+ */
968
+ export function validateFindingsDocument(doc: unknown): string[] {
969
+ const errors: string[] = [];
970
+ if (!isRecord(doc)) return ["findings document must be a JSON object"];
971
+ const topAllowed = new Set([
972
+ "schema",
973
+ "schema_path",
974
+ "target",
975
+ "reviewer",
976
+ "reviewed_at",
977
+ "delegated_reviews",
978
+ "findings",
979
+ "dispositions",
980
+ ]);
981
+ for (const key of Object.keys(doc)) {
982
+ if (!topAllowed.has(key)) errors.push(`unknown top-level key "${key}"`);
983
+ }
984
+ for (const key of [
985
+ "schema",
986
+ "target",
987
+ "reviewer",
988
+ "reviewed_at",
989
+ "delegated_reviews",
990
+ "findings",
991
+ ]) {
992
+ if (!(key in doc)) errors.push(`missing required key "${key}"`);
993
+ }
994
+ if (doc.schema !== undefined && doc.schema !== PAGE_REVIEW_FINDINGS_SCHEMA) {
995
+ errors.push(`schema must be ${PAGE_REVIEW_FINDINGS_SCHEMA}`);
996
+ }
997
+ if (doc.schema_path !== undefined && typeof doc.schema_path !== "string") {
998
+ errors.push("schema_path must be a string");
999
+ }
1000
+ if (doc.target !== undefined && typeof doc.target !== "string") {
1001
+ errors.push("target must be a string");
1002
+ }
1003
+ if (doc.reviewer !== undefined && doc.reviewer !== null && typeof doc.reviewer !== "string") {
1004
+ errors.push("reviewer must be a string or null");
1005
+ }
1006
+ if (doc.reviewed_at !== undefined && doc.reviewed_at !== null) {
1007
+ if (typeof doc.reviewed_at !== "string" || !DATE_TIME_PATTERN.test(doc.reviewed_at)) {
1008
+ errors.push("reviewed_at must be an RFC 3339 date-time string or null");
1009
+ }
1010
+ }
1011
+
1012
+ if (doc.delegated_reviews !== undefined) {
1013
+ if (!Array.isArray(doc.delegated_reviews)) {
1014
+ errors.push("delegated_reviews must be an array");
1015
+ } else {
1016
+ const allowed = new Set(["reviewer", "model", "assigned_tiles", "completed_tiles", "status"]);
1017
+ doc.delegated_reviews.forEach((entry: unknown, index: number) => {
1018
+ const at = `delegated_reviews[${index}]`;
1019
+ if (!isRecord(entry)) {
1020
+ errors.push(`${at}: must be an object`);
1021
+ return;
1022
+ }
1023
+ for (const key of Object.keys(entry)) {
1024
+ if (!allowed.has(key)) errors.push(`${at}: unknown key "${key}"`);
1025
+ }
1026
+ for (const key of ["reviewer", "model", "assigned_tiles", "completed_tiles", "status"]) {
1027
+ if (!(key in entry)) errors.push(`${at}: missing required key "${key}"`);
1028
+ }
1029
+ if (
1030
+ entry.reviewer !== undefined &&
1031
+ (typeof entry.reviewer !== "string" || entry.reviewer.trim().length === 0)
1032
+ ) {
1033
+ errors.push(`${at}: reviewer must be a non-empty string`);
1034
+ }
1035
+ if (
1036
+ entry.model !== undefined &&
1037
+ !(PAGE_REVIEW_SUBAGENT_MODELS as readonly string[]).includes(String(entry.model))
1038
+ ) {
1039
+ errors.push(`${at}: model must be one of ${PAGE_REVIEW_SUBAGENT_MODELS.join(", ")}`);
1040
+ }
1041
+ for (const key of ["assigned_tiles", "completed_tiles"] as const) {
1042
+ const value = entry[key];
1043
+ if (!Array.isArray(value) || value.some((tile) => typeof tile !== "string")) {
1044
+ errors.push(`${at}: ${key} must be an array of <context-id>/T012 strings`);
1045
+ continue;
1046
+ }
1047
+ const invalid = value.filter((tile) => !REVIEW_TILE_PATTERN.test(tile));
1048
+ if (invalid.length > 0) {
1049
+ errors.push(`${at}: ${key} contains invalid tile(s): ${invalid.join(", ")}`);
1050
+ }
1051
+ if (new Set(value).size !== value.length) {
1052
+ errors.push(`${at}: ${key} must not contain duplicates`);
1053
+ }
1054
+ }
1055
+ if (
1056
+ entry.status !== undefined &&
1057
+ entry.status !== "complete" &&
1058
+ entry.status !== "incomplete"
1059
+ ) {
1060
+ errors.push(`${at}: status must be complete or incomplete`);
1061
+ }
1062
+ if (Array.isArray(entry.assigned_tiles) && Array.isArray(entry.completed_tiles)) {
1063
+ const assigned = new Set(entry.assigned_tiles);
1064
+ const outside = entry.completed_tiles.filter((tile) => !assigned.has(tile));
1065
+ if (outside.length > 0) {
1066
+ errors.push(`${at}: completed_tiles not assigned: ${outside.join(", ")}`);
1067
+ }
1068
+ }
1069
+ });
1070
+ }
1071
+ }
1072
+
1073
+ if (doc.findings !== undefined) {
1074
+ if (!Array.isArray(doc.findings)) {
1075
+ errors.push("findings must be an array");
1076
+ } else {
1077
+ const allowed = new Set([
1078
+ "id",
1079
+ "severity",
1080
+ "category",
1081
+ "context_id",
1082
+ "evidence",
1083
+ "observation",
1084
+ "recommendation",
1085
+ "confidence",
1086
+ ]);
1087
+ const seenIds = new Set<string>();
1088
+ doc.findings.forEach((entry: unknown, index: number) => {
1089
+ const at = `findings[${index}]`;
1090
+ if (!isRecord(entry)) {
1091
+ errors.push(`${at}: must be an object`);
1092
+ return;
1093
+ }
1094
+ for (const key of Object.keys(entry)) {
1095
+ if (!allowed.has(key)) errors.push(`${at}: unknown key "${key}"`);
1096
+ }
1097
+ for (const key of ["id", "severity", "category", "context_id", "evidence", "observation"]) {
1098
+ if (!(key in entry)) errors.push(`${at}: missing required key "${key}"`);
1099
+ }
1100
+ if (entry.id !== undefined) {
1101
+ if (typeof entry.id !== "string" || !FINDING_ID_PATTERN.test(entry.id)) {
1102
+ errors.push(`${at}: id must match ${FINDING_ID_PATTERN.source}`);
1103
+ } else if (seenIds.has(entry.id)) {
1104
+ errors.push(`${at}: duplicate id "${entry.id}"`);
1105
+ } else {
1106
+ seenIds.add(entry.id);
1107
+ }
1108
+ }
1109
+ if (
1110
+ entry.severity !== undefined &&
1111
+ !(PAGE_REVIEW_FINDING_SEVERITIES as readonly string[]).includes(String(entry.severity))
1112
+ ) {
1113
+ errors.push(
1114
+ `${at}: severity must be one of ${PAGE_REVIEW_FINDING_SEVERITIES.join(", ")}`,
1115
+ );
1116
+ }
1117
+ if (
1118
+ entry.category !== undefined &&
1119
+ !(PAGE_REVIEW_FINDING_CATEGORIES as readonly string[]).includes(String(entry.category))
1120
+ ) {
1121
+ errors.push(
1122
+ `${at}: category must be one of ${PAGE_REVIEW_FINDING_CATEGORIES.join(", ")}`,
1123
+ );
1124
+ }
1125
+ if (entry.context_id !== undefined && typeof entry.context_id !== "string") {
1126
+ errors.push(`${at}: context_id must be a string`);
1127
+ }
1128
+ if (entry.evidence !== undefined) {
1129
+ if (!Array.isArray(entry.evidence) || entry.evidence.length === 0) {
1130
+ errors.push(`${at}: evidence must be a non-empty array of strings`);
1131
+ } else if (entry.evidence.some((e: unknown) => typeof e !== "string")) {
1132
+ errors.push(`${at}: evidence entries must be strings`);
1133
+ }
1134
+ }
1135
+ if (
1136
+ entry.observation !== undefined &&
1137
+ (typeof entry.observation !== "string" || entry.observation.length === 0)
1138
+ ) {
1139
+ errors.push(`${at}: observation must be a non-empty string`);
1140
+ }
1141
+ if (entry.recommendation !== undefined && typeof entry.recommendation !== "string") {
1142
+ errors.push(`${at}: recommendation must be a string`);
1143
+ }
1144
+ if (entry.confidence !== undefined) {
1145
+ const c = entry.confidence;
1146
+ if (typeof c !== "number" || !Number.isFinite(c) || c < 0 || c > 1) {
1147
+ errors.push(`${at}: confidence must be a number between 0 and 1`);
1148
+ }
1149
+ }
1150
+ });
1151
+ }
1152
+ }
1153
+
1154
+ if (doc.dispositions !== undefined) {
1155
+ if (!Array.isArray(doc.dispositions)) {
1156
+ errors.push("dispositions must be an array");
1157
+ } else {
1158
+ const allowed = new Set(["target", "disposition", "note", "by", "at"]);
1159
+ const seen = new Set<string>();
1160
+ doc.dispositions.forEach((entry: unknown, index: number) => {
1161
+ const at = `dispositions[${index}]`;
1162
+ if (!isRecord(entry)) {
1163
+ errors.push(`${at}: must be an object`);
1164
+ return;
1165
+ }
1166
+ for (const key of Object.keys(entry)) {
1167
+ if (!allowed.has(key)) errors.push(`${at}: unknown key "${key}"`);
1168
+ }
1169
+ for (const key of ["target", "disposition"]) {
1170
+ if (!(key in entry)) errors.push(`${at}: missing required key "${key}"`);
1171
+ }
1172
+ if (entry.target !== undefined) {
1173
+ if (typeof entry.target !== "string" || !DISPOSITION_TARGET_PATTERN.test(entry.target)) {
1174
+ errors.push(`${at}: target must look like <context-id>/T012#0`);
1175
+ } else if (seen.has(entry.target)) {
1176
+ errors.push(`${at}: duplicate target "${entry.target}"`);
1177
+ } else {
1178
+ seen.add(entry.target);
1179
+ }
1180
+ }
1181
+ if (
1182
+ entry.disposition !== undefined &&
1183
+ !(PAGE_REVIEW_DISPOSITIONS as readonly string[]).includes(String(entry.disposition))
1184
+ ) {
1185
+ errors.push(`${at}: disposition must be one of ${PAGE_REVIEW_DISPOSITIONS.join(", ")}`);
1186
+ }
1187
+ for (const key of ["note", "by"]) {
1188
+ if (entry[key] !== undefined && typeof entry[key] !== "string") {
1189
+ errors.push(`${at}: ${key} must be a string`);
1190
+ }
1191
+ }
1192
+ if (entry.at !== undefined) {
1193
+ if (typeof entry.at !== "string" || !DATE_TIME_PATTERN.test(entry.at)) {
1194
+ errors.push(`${at}: at must be an RFC 3339 date-time string`);
1195
+ }
1196
+ }
1197
+ });
1198
+ }
1199
+ }
1200
+ return errors;
1201
+ }
1202
+
1203
+ /** `<context-id>/<tile-id>#<n>` for the n-th finding of a tile in one context. */
1204
+ export function dispositionTarget(contextId: string, tileId: string, n: number): string {
1205
+ return `${contextId}/${tileId}#${n}`;
1206
+ }
1207
+
1208
+ /**
1209
+ * Every machine finding in the critique, keyed by its disposition target.
1210
+ * `n` counts a tile's findings in critique order, all severities included, so
1211
+ * a reviewer can point at the second finding on T006 as `ctx/T006#1`.
1212
+ */
1213
+ export function machineFindingTargets(
1214
+ critique: PageReviewCritiqueRecord[] | null,
1215
+ ): Map<string, CritiqueFinding & { tile_id: string; context_id: string }> {
1216
+ const out = new Map<string, CritiqueFinding & { tile_id: string; context_id: string }>();
1217
+ if (!critique) return out;
1218
+ for (const row of critique) {
1219
+ const perTile = new Map<string, number>();
1220
+ for (const finding of row.findings) {
1221
+ const n = perTile.get(finding.tile_id) ?? 0;
1222
+ perTile.set(finding.tile_id, n + 1);
1223
+ out.set(dispositionTarget(row.context_id, finding.tile_id, n), {
1224
+ ...finding,
1225
+ context_id: row.context_id,
1226
+ });
1227
+ }
1228
+ }
1229
+ return out;
1230
+ }
1231
+
1232
+ /**
1233
+ * Combine the immutable machine critique with the reviewer's dispositions and
1234
+ * findings into one reviewed outcome. A machine `high` counts against the page
1235
+ * unless a reviewer dispositioned it `artifact`, `not-a-defect`, or
1236
+ * `duplicate-of-gate`; `confirmed` and undispositioned highs count; so does a
1237
+ * review-subagent finding at `critical` or `high`. Nothing here rewrites the
1238
+ * critique: `evidence/critique.json` stays the machine record.
1239
+ */
1240
+ export function resolvePackVerdict(
1241
+ manifest: Pick<PageReviewPackManifest, "critique">,
1242
+ inspectionPlan: PageReviewInspectionPlan,
1243
+ findings: PageReviewFindingsDocument,
1244
+ ): PageReviewVerdict {
1245
+ const critique = manifest.critique;
1246
+ const machine_outcome: PageReviewVerdict["machine_outcome"] =
1247
+ critique === null || critique.length === 0
1248
+ ? "skipped"
1249
+ : critique.some((row) => row.outcome === "fail")
1250
+ ? "fail"
1251
+ : critique.some((row) => row.outcome === "incomplete")
1252
+ ? "incomplete"
1253
+ : critique.every((row) => row.outcome === "skipped")
1254
+ ? "skipped"
1255
+ : "pass";
1256
+
1257
+ const targets = machineFindingTargets(critique);
1258
+ const byTarget = new Map<string, PageReviewDispositionRecord>();
1259
+ const unmatched: string[] = [];
1260
+ for (const entry of findings.dispositions ?? []) {
1261
+ if (targets.has(entry.target)) byTarget.set(entry.target, entry);
1262
+ else unmatched.push(entry.target);
1263
+ }
1264
+
1265
+ let high_total = 0;
1266
+ let high_confirmed = 0;
1267
+ let high_dismissed = 0;
1268
+ let high_open = 0;
1269
+ for (const [target, finding] of targets) {
1270
+ if (finding.severity !== "high") continue;
1271
+ high_total += 1;
1272
+ const disposition = byTarget.get(target)?.disposition;
1273
+ if (disposition === undefined) high_open += 1;
1274
+ else if (disposition === "confirmed") high_confirmed += 1;
1275
+ else high_dismissed += 1;
1276
+ }
1277
+ const reviewer_high = findings.findings.filter(
1278
+ (f) => f.severity === "critical" || f.severity === "high",
1279
+ ).length;
1280
+
1281
+ const requiredTiles = new Set(
1282
+ inspectionPlan.contexts.flatMap((context) =>
1283
+ context.primary_tiles.map((tile) => `${context.context_id}/${tile.id}`),
1284
+ ),
1285
+ );
1286
+ const completedTiles = new Set(
1287
+ findings.delegated_reviews
1288
+ .filter((review) => review.status === "complete")
1289
+ .flatMap((review) => review.completed_tiles),
1290
+ );
1291
+ const uncovered_primary_tiles = [...requiredTiles]
1292
+ .filter((tile) => !completedTiles.has(tile))
1293
+ .sort();
1294
+
1295
+ const blocking = high_confirmed + high_open + reviewer_high;
1296
+ const anyIncomplete = critique?.some((row) => row.outcome === "incomplete") ?? false;
1297
+ const reviewed_outcome: PageReviewVerdict["reviewed_outcome"] =
1298
+ blocking > 0
1299
+ ? "fail"
1300
+ : anyIncomplete || uncovered_primary_tiles.length > 0
1301
+ ? "incomplete"
1302
+ : machine_outcome === "skipped"
1303
+ ? "skipped"
1304
+ : "pass";
1305
+
1306
+ return {
1307
+ machine_outcome,
1308
+ reviewed_outcome,
1309
+ high_total,
1310
+ high_confirmed,
1311
+ high_dismissed,
1312
+ high_open,
1313
+ reviewer_high,
1314
+ dispositions_applied: byTarget.size,
1315
+ unmatched_dispositions: unmatched,
1316
+ primary_tiles_total: requiredTiles.size,
1317
+ primary_tiles_reviewed: requiredTiles.size - uncovered_primary_tiles.length,
1318
+ uncovered_primary_tiles,
1319
+ };
1320
+ }
1321
+
1322
+ /** The verdict a previous `review-pack verdict` wrote, or undefined when none exists. */
1323
+ export function readPackVerdict(packDir: string): PageReviewVerdictDocument | undefined {
1324
+ const path = packPaths(packDir).verdict;
1325
+ if (!existsSync(path)) return undefined;
1326
+ const doc = readJson<PageReviewVerdictDocument>(path);
1327
+ return doc.schema === PAGE_REVIEW_VERDICT_SCHEMA ? doc : undefined;
1328
+ }
1329
+
1330
+ // ---------------------------------------------------------------------------
1331
+ // Finalize: manifest, evidence, findings skeleton, review.md
1332
+ // ---------------------------------------------------------------------------
1333
+
1334
+ export interface FinalizePageReviewPackInput {
1335
+ packDir: string;
1336
+ target: string;
1337
+ tested_revision?: string;
1338
+ contexts: PageReviewContextRecord[];
1339
+ gates?: PageReviewGateRecord[];
1340
+ /** Null or absent until the judge has run. */
1341
+ critique?: PageReviewCritiqueRecord[] | null;
1342
+ pool?: { concurrency: number; wall_time_ms: number; provider: string };
1343
+ not_checked?: Array<{ check: string; reason: string }>;
1344
+ warnings?: string[];
1345
+ tool?: { name: string; version?: string };
1346
+ /** Command the reader can run to judge the pack, shown in review.md. */
1347
+ judgeCommand?: string;
1348
+ createdAt?: string;
1349
+ /** Pack expiry to record; omit to keep whatever the manifest already says. */
1350
+ retention?: PageReviewRetention;
1351
+ }
1352
+
1353
+ export function findingsSchemaDocument(): Record<string, unknown> {
1354
+ return {
1355
+ $schema: "https://json-schema.org/draft/2020-12/schema",
1356
+ $id: PAGE_REVIEW_FINDINGS_SCHEMA,
1357
+ title: "Page review findings",
1358
+ type: "object",
1359
+ additionalProperties: false,
1360
+ required: ["schema", "target", "reviewer", "reviewed_at", "delegated_reviews", "findings"],
1361
+ properties: {
1362
+ schema: { const: PAGE_REVIEW_FINDINGS_SCHEMA },
1363
+ schema_path: { type: "string" },
1364
+ target: { type: "string" },
1365
+ reviewer: { type: ["string", "null"] },
1366
+ reviewed_at: { type: ["string", "null"], format: "date-time" },
1367
+ delegated_reviews: {
1368
+ type: "array",
1369
+ items: {
1370
+ type: "object",
1371
+ additionalProperties: false,
1372
+ required: ["reviewer", "model", "assigned_tiles", "completed_tiles", "status"],
1373
+ properties: {
1374
+ reviewer: { type: "string", minLength: 1 },
1375
+ model: { enum: [...PAGE_REVIEW_SUBAGENT_MODELS] },
1376
+ assigned_tiles: {
1377
+ type: "array",
1378
+ uniqueItems: true,
1379
+ items: { type: "string", pattern: "^[^/]+/T[0-9]{3}$" },
1380
+ },
1381
+ completed_tiles: {
1382
+ type: "array",
1383
+ uniqueItems: true,
1384
+ items: { type: "string", pattern: "^[^/]+/T[0-9]{3}$" },
1385
+ },
1386
+ status: { enum: ["complete", "incomplete"] },
1387
+ },
1388
+ },
1389
+ },
1390
+ findings: {
1391
+ type: "array",
1392
+ items: {
1393
+ type: "object",
1394
+ additionalProperties: false,
1395
+ required: ["id", "severity", "category", "context_id", "evidence", "observation"],
1396
+ properties: {
1397
+ id: { type: "string", pattern: "^[A-Z][A-Z0-9_-]*$" },
1398
+ severity: { enum: ["critical", "high", "medium", "low", "info"] },
1399
+ category: {
1400
+ enum: [
1401
+ "layout",
1402
+ "typography",
1403
+ "contrast",
1404
+ "content",
1405
+ "image",
1406
+ "interaction",
1407
+ "accessibility",
1408
+ "responsiveness",
1409
+ "render-artifact",
1410
+ "coverage",
1411
+ "other",
1412
+ ],
1413
+ },
1414
+ context_id: { type: "string" },
1415
+ /** Tile ids (`T012`) or pack-relative file paths. */
1416
+ evidence: { type: "array", minItems: 1, items: { type: "string" } },
1417
+ observation: { type: "string", minLength: 1 },
1418
+ recommendation: { type: "string" },
1419
+ confidence: { type: "number", minimum: 0, maximum: 1 },
1420
+ },
1421
+ },
1422
+ },
1423
+ /** Reviewer verdicts on machine findings in `evidence/critique.json`.
1424
+ * `target` is `<context-id>/<tile-id>#<n>`, n = position of the finding
1425
+ * among that tile's findings in the critique record (0-based). */
1426
+ dispositions: {
1427
+ type: "array",
1428
+ items: {
1429
+ type: "object",
1430
+ additionalProperties: false,
1431
+ required: ["target", "disposition"],
1432
+ properties: {
1433
+ target: { type: "string", pattern: "^[^/#]+/T[0-9]{3}#[0-9]+$" },
1434
+ disposition: { enum: [...PAGE_REVIEW_DISPOSITIONS] },
1435
+ note: { type: "string" },
1436
+ by: { type: "string" },
1437
+ at: { type: "string", format: "date-time" },
1438
+ },
1439
+ },
1440
+ },
1441
+ },
1442
+ };
1443
+ }
1444
+
1445
+ /** Most hits one envelope contributes; a runaway sweep must not bloat the pack. */
1446
+ export const GATE_HITS_PER_ENVELOPE = 50;
1447
+
1448
+ function asRect(value: unknown): PageReviewRect | undefined {
1449
+ if (!value || typeof value !== "object") return undefined;
1450
+ const r = value as Record<string, unknown>;
1451
+ const x = r.x;
1452
+ const y = r.y;
1453
+ const width = r.width;
1454
+ const height = r.height;
1455
+ if (
1456
+ typeof x !== "number" ||
1457
+ typeof y !== "number" ||
1458
+ typeof width !== "number" ||
1459
+ typeof height !== "number" ||
1460
+ ![x, y, width, height].every(Number.isFinite)
1461
+ ) {
1462
+ return undefined;
1463
+ }
1464
+ return { x, y, width, height };
1465
+ }
1466
+
1467
+ function unionRect(a: PageReviewRect, b: PageReviewRect): PageReviewRect {
1468
+ const x = Math.min(a.x, b.x);
1469
+ const y = Math.min(a.y, b.y);
1470
+ return {
1471
+ x,
1472
+ y,
1473
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
1474
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
1475
+ };
1476
+ }
1477
+
1478
+ function asArray(value: unknown): Record<string, unknown>[] {
1479
+ return Array.isArray(value)
1480
+ ? value.filter((v): v is Record<string, unknown> => Boolean(v) && typeof v === "object")
1481
+ : [];
1482
+ }
1483
+
1484
+ function str(value: unknown, fallback = "?"): string {
1485
+ return typeof value === "string" && value.length > 0 ? value : fallback;
1486
+ }
1487
+
1488
+ /**
1489
+ * Pull every rectangle-bearing finding out of a `browse` JSON envelope so the
1490
+ * inspection plan can point at the tiles that show it. Covers the checks
1491
+ * whose results carry a document-space rect: runts, truncation, contrast,
1492
+ * placeholder, image, clip, overlap, crowd, align, gap, overflow, and
1493
+ * target-size (`hit`). Anything without a rect is skipped; the gate's
1494
+ * `failures` lines still describe it. Capped at `GATE_HITS_PER_ENVELOPE`.
1495
+ */
1496
+ export function gateHitsFromEnvelope(
1497
+ envelope: Record<string, unknown> | undefined,
1498
+ ): PageReviewGateHit[] {
1499
+ if (!envelope) return [];
1500
+ const hits: PageReviewGateHit[] = [];
1501
+ const push = (rule: string, label: string, rect: PageReviewRect | undefined): void => {
1502
+ if (rect && hits.length < GATE_HITS_PER_ENVELOPE) hits.push({ rule, label, rect });
1503
+ };
1504
+ const runts = envelope.runts as Record<string, unknown> | undefined;
1505
+ for (const hit of asArray(runts?.runts)) {
1506
+ push("runts", `${str(hit.block)}: "${str(hit.word, "")}"`, asRect(hit.rect));
1507
+ }
1508
+ const truncation = envelope.truncation as Record<string, unknown> | undefined;
1509
+ for (const hit of asArray(truncation?.hits)) {
1510
+ push(
1511
+ "truncation",
1512
+ `${str(hit.label)}: ${str(hit.how)} on ${str(hit.axis)}, +${String(hit.overflowPx ?? "?")}px`,
1513
+ asRect(hit.rect),
1514
+ );
1515
+ }
1516
+ const contrast = envelope.contrast as Record<string, unknown> | undefined;
1517
+ for (const hit of asArray(contrast?.hits)) {
1518
+ push(
1519
+ "contrast",
1520
+ `${str(hit.label)}: ${String(hit.ratio ?? "?")}:1 < ${String(hit.required ?? "?")}`,
1521
+ asRect(hit.rect),
1522
+ );
1523
+ }
1524
+ const placeholder = envelope.placeholder as Record<string, unknown> | undefined;
1525
+ for (const hit of asArray(placeholder?.hits)) {
1526
+ push(
1527
+ "placeholder",
1528
+ `${str(hit.label)}: ${str(hit.kind)} ${str(hit.token, "")}`,
1529
+ asRect(hit.rect),
1530
+ );
1531
+ }
1532
+ const image = envelope.image as Record<string, unknown> | undefined;
1533
+ for (const hit of asArray(image?.issues)) {
1534
+ push("image", `${str(hit.label)}: ${str(hit.reason)}`, asRect(hit.rect));
1535
+ }
1536
+ for (const result of asArray(envelope.clip)) {
1537
+ for (const issue of asArray(result.issues)) {
1538
+ const element = issue.element as Record<string, unknown> | undefined;
1539
+ push(
1540
+ "clip",
1541
+ `${str(element?.label)} clipped by ${str(issue.clippedBy)}, ${String(issue.maxOverrunPx ?? "?")}px`,
1542
+ asRect(element?.rect),
1543
+ );
1544
+ }
1545
+ }
1546
+ for (const result of asArray(envelope.overlap)) {
1547
+ for (const issue of asArray(result.issues)) {
1548
+ const first = issue.first as Record<string, unknown> | undefined;
1549
+ const second = issue.second as Record<string, unknown> | undefined;
1550
+ push(
1551
+ "overlap",
1552
+ `${str(first?.label)} × ${str(second?.label)}, ${String(issue.areaPx ?? "?")}px²`,
1553
+ asRect(issue.intersection),
1554
+ );
1555
+ }
1556
+ }
1557
+ for (const result of asArray(envelope.crowd)) {
1558
+ for (const issue of asArray(result.issues)) {
1559
+ const before = asRect((issue.before as Record<string, unknown> | undefined)?.rect);
1560
+ const after = asRect((issue.after as Record<string, unknown> | undefined)?.rect);
1561
+ const rect = before && after ? unionRect(before, after) : (before ?? after);
1562
+ push(
1563
+ "crowd",
1564
+ `${str((issue.before as Record<string, unknown> | undefined)?.label)} / ${str(
1565
+ (issue.after as Record<string, unknown> | undefined)?.label,
1566
+ )}: ${String(issue.separationPx ?? "?")}px apart`,
1567
+ rect,
1568
+ );
1569
+ }
1570
+ }
1571
+ for (const result of asArray(envelope.align)) {
1572
+ for (const cluster of asArray(result.clusters)) {
1573
+ for (const child of asArray(cluster.children)) {
1574
+ if (child.fail !== true) continue;
1575
+ push(
1576
+ "align",
1577
+ `${str(child.label)}: off by ${String(child.deltaPx ?? "?")}px`,
1578
+ asRect(child.rect),
1579
+ );
1580
+ }
1581
+ }
1582
+ }
1583
+ for (const result of asArray(envelope.gap)) {
1584
+ for (const cluster of asArray(result.clusters)) {
1585
+ for (const pair of asArray(cluster.pairs)) {
1586
+ if (pair.fail !== true) continue;
1587
+ const before = pair.before as Record<string, unknown> | undefined;
1588
+ const after = pair.after as Record<string, unknown> | undefined;
1589
+ const a = asRect(before?.rect);
1590
+ const b = asRect(after?.rect);
1591
+ push(
1592
+ "gap",
1593
+ `${str(before?.label)} / ${str(after?.label)}: ${String(pair.observedGapPx ?? "?")}px`,
1594
+ a && b ? unionRect(a, b) : (a ?? b),
1595
+ );
1596
+ }
1597
+ }
1598
+ }
1599
+ const overflow = envelope.overflow as Record<string, unknown> | undefined;
1600
+ for (const key of ["widerThanViewport", "rightOverflow"] as const) {
1601
+ for (const element of asArray(overflow?.[key])) {
1602
+ const tag = str(element.tagName, "element").toLowerCase();
1603
+ const id = str(element.id, "");
1604
+ const cls = str(element.className, "").trim().split(/\s+/)[0] ?? "";
1605
+ const label = id ? `${tag}#${id}` : cls ? `${tag}.${cls}` : tag;
1606
+ const px = key === "widerThanViewport" ? element.widthOverflowPx : element.rightOverflowPx;
1607
+ push("overflow", `${label}: +${String(px ?? "?")}px past the viewport`, asRect(element.rect));
1608
+ }
1609
+ }
1610
+ for (const result of asArray(envelope.hit)) {
1611
+ for (const node of asArray(result.nodes)) {
1612
+ if (node.outcome !== "fail") continue;
1613
+ const target = Array.isArray(node.target) ? node.target.map(String).join(" ") : "target";
1614
+ push(
1615
+ "hit",
1616
+ `${target}: ${str(node.message, "below the minimum target size")}`,
1617
+ asRect(node.rect),
1618
+ );
1619
+ }
1620
+ }
1621
+ return hits;
1622
+ }
1623
+
1624
+ /** Tile ids whose rect intersects `rect` (both in document-space px). */
1625
+ export function tilesCoveringRect(tiles: PageReviewTileRecord[], rect: PageReviewRect): string[] {
1626
+ const x1 = rect.x + Math.max(rect.width, 1);
1627
+ const y1 = rect.y + Math.max(rect.height, 1);
1628
+ return tiles
1629
+ .filter(
1630
+ (tile) =>
1631
+ tile.x < x1 &&
1632
+ tile.x + tile.width > rect.x &&
1633
+ tile.scrollY < y1 &&
1634
+ tile.scrollY + tile.height > rect.y,
1635
+ )
1636
+ .map((tile) => tile.id);
1637
+ }
1638
+
1639
+ export function buildInspectionPlan(
1640
+ contexts: PageReviewContextRecord[],
1641
+ critique: PageReviewCritiqueRecord[] | null | undefined,
1642
+ gates?: PageReviewGateRecord[],
1643
+ ): PageReviewInspectionPlan {
1644
+ return {
1645
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1646
+ purpose:
1647
+ "Bound a complete review to representative tiles instead of every PNG. Open primary tiles; treat the rest as drill-down for a named question.",
1648
+ contexts: contexts.map((ctx) => {
1649
+ const reasons = new Map<string, string[]>();
1650
+ const add = (id: string, reason: string): void => {
1651
+ const list = reasons.get(id) ?? [];
1652
+ list.push(reason);
1653
+ reasons.set(id, list);
1654
+ };
1655
+ // Hit bands sit past the cap, so they never stand in for the page bottom.
1656
+ const bands = ctx.tiles.filter(
1657
+ (tile) => tile.scope === undefined && tile.kind !== "hit-band",
1658
+ );
1659
+ const first = bands[0] ?? ctx.tiles[0];
1660
+ const last = bands[bands.length - 1] ?? ctx.tiles[ctx.tiles.length - 1];
1661
+ if (first) add(first.id, "page top: header, navigation, first fold");
1662
+ if (last && last.id !== first?.id)
1663
+ add(last.id, "page bottom: footer and final call to action");
1664
+ for (const tile of ctx.tiles) {
1665
+ if (tile.scope !== undefined) add(tile.id, `scoped tile for ${tile.scope}`);
1666
+ if (tile.kind === "hit-band")
1667
+ add(tile.id, "hit band: captured past the tile cap for a gate hit");
1668
+ }
1669
+ const row = critique?.find((entry) => entry.context_id === ctx.id);
1670
+ for (const finding of row?.findings ?? []) {
1671
+ add(finding.tile_id, `machine finding (${finding.severity}): ${finding.category}`);
1672
+ }
1673
+ const gateHits: PageReviewInspectionGateHit[] = [];
1674
+ for (const gate of gates ?? []) {
1675
+ if (gate.context_id !== ctx.id) continue;
1676
+ for (const hit of gate.hits ?? []) {
1677
+ const tiles = tilesCoveringRect(ctx.tiles, hit.rect);
1678
+ gateHits.push({ ...hit, check_id: gate.check_id, tiles });
1679
+ for (const id of tiles) add(id, `gate hit (${hit.rule}): ${hit.label}`);
1680
+ }
1681
+ }
1682
+ const byId = new Map(ctx.tiles.map((tile) => [tile.id, tile]));
1683
+ const primary = [...reasons.entries()]
1684
+ .map(([id, list]) => ({ id, file: byId.get(id)?.file ?? "", reason: list.join("; ") }))
1685
+ .filter((entry) => entry.file.length > 0)
1686
+ .sort((a, b) => a.id.localeCompare(b.id));
1687
+ return {
1688
+ context_id: ctx.id,
1689
+ full_page: ctx.files.full_page,
1690
+ primary_tiles: primary,
1691
+ drilldown_tiles: Math.max(0, ctx.tiles.length - primary.length),
1692
+ gate_hits: gateHits,
1693
+ };
1694
+ }),
1695
+ };
1696
+ }
1697
+
1698
+ function walkFiles(root: string, dir: string, out: Array<{ path: string; bytes: number }>): void {
1699
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1700
+ const abs = join(dir, entry.name);
1701
+ if (entry.isDirectory()) walkFiles(root, abs, out);
1702
+ else if (entry.isFile()) {
1703
+ out.push({
1704
+ path: relative(root, abs).split("\\").join("/"),
1705
+ bytes: readFileSync(abs).byteLength,
1706
+ });
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ /**
1712
+ * Write the pack's navigation layer: manifest, evidence files, the findings
1713
+ * skeleton and schema (never overwriting a findings file a reviewer already
1714
+ * filled), and `review.md`. Safe to call twice: once after capture, again
1715
+ * after the judge so machine findings land in the plan and the review.
1716
+ */
1717
+ export function finalizePageReviewPack(input: FinalizePageReviewPackInput): {
1718
+ manifest: string;
1719
+ review: string;
1720
+ } {
1721
+ const paths = packPaths(input.packDir);
1722
+ mkdirSync(paths.evidenceDir, { recursive: true });
1723
+ const rel = (abs: string): string => relative(input.packDir, abs).split("\\").join("/");
1724
+ const createdAt = input.createdAt ?? new Date().toISOString();
1725
+ const critique = input.critique ?? null;
1726
+ const gates = input.gates ?? [];
1727
+ const notChecked = [...(input.not_checked ?? [])];
1728
+ const warnings = [...(input.warnings ?? [])];
1729
+
1730
+ if (critique === null) {
1731
+ notChecked.push({
1732
+ check: "machine vision critique",
1733
+ reason: input.judgeCommand
1734
+ ? `The judge stage has not run for this pack. Run: ${input.judgeCommand}`
1735
+ : "The judge stage has not run for this pack.",
1736
+ });
1737
+ } else if (critique.some((row) => row.outcome === "skipped")) {
1738
+ notChecked.push({
1739
+ check: "machine vision critique",
1740
+ reason:
1741
+ "The judge ran without a vision provider (or the provider never answered) for at least one context; review those tiles directly.",
1742
+ });
1743
+ }
1744
+ for (const ctx of input.contexts) {
1745
+ if (ctx.coverage.capped) {
1746
+ warnings.push(
1747
+ `${ctx.id}: tile cap reached, ${ctx.coverage.bands_reviewed} of ${ctx.coverage.bands_total} bands ` +
1748
+ `(${ctx.coverage.reviewed_height_px} of ${ctx.coverage.page_height_px} px) are in the pack; the rest of the page is unreviewed.`,
1749
+ );
1750
+ }
1751
+ if (ctx.hit_bands !== undefined && ctx.hit_bands > 0) {
1752
+ warnings.push(
1753
+ `${ctx.id}: ${ctx.hit_bands} band(s) below the tile cap were captured because gate hits land in them.`,
1754
+ );
1755
+ }
1756
+ }
1757
+ notChecked.push({
1758
+ check: "interactive behavior",
1759
+ reason:
1760
+ "The pack holds frozen states only. Hover, focus, open menus, and scroll-triggered motion are not in it unless captured as named states.",
1761
+ });
1762
+ notChecked.push({
1763
+ check: "external copy facts",
1764
+ reason:
1765
+ "The pack shows rendered text. It does not verify prices, dates, names, or claims against an outside source.",
1766
+ });
1767
+
1768
+ // A context captured by an older writer, or one whose sheet was removed,
1769
+ // gets its contact sheet here so every finalized pack carries one per
1770
+ // context with tiles. Failure is a warning, never a lost pack.
1771
+ const contexts = input.contexts.map((ctx) => {
1772
+ if (ctx.tiles.length === 0) return ctx;
1773
+ if (ctx.contact_sheet && existsSync(join(input.packDir, ctx.contact_sheet.file))) return ctx;
1774
+ try {
1775
+ return writePackContactSheet(input.packDir, ctx);
1776
+ } catch (err: unknown) {
1777
+ warnings.push(
1778
+ `${ctx.id}: contact sheet not written (${err instanceof Error ? err.message : String(err)})`,
1779
+ );
1780
+ return ctx;
1781
+ }
1782
+ });
1783
+
1784
+ const plan = buildInspectionPlan(contexts, critique, gates);
1785
+ writeJson(paths.inspectionPlan, plan);
1786
+ writeJson(paths.coverage, {
1787
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1788
+ contexts: contexts.map((ctx) => ({
1789
+ context_id: ctx.id,
1790
+ page_height_px: ctx.coverage.page_height_px,
1791
+ reviewed_height_px: ctx.coverage.reviewed_height_px,
1792
+ bands_total: ctx.coverage.bands_total,
1793
+ bands_reviewed: ctx.coverage.bands_reviewed,
1794
+ capped: ctx.coverage.capped,
1795
+ tiles: ctx.tiles.length,
1796
+ scopes: ctx.scopes,
1797
+ })),
1798
+ warnings,
1799
+ not_checked: notChecked,
1800
+ });
1801
+ if (critique !== null) {
1802
+ writeJson(paths.critique, {
1803
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1804
+ ...(input.pool ? { pool: input.pool } : {}),
1805
+ contexts: critique,
1806
+ });
1807
+ }
1808
+
1809
+ writeJson(paths.findingsSchema, findingsSchemaDocument());
1810
+ if (!existsSync(paths.findings)) {
1811
+ writeJson(paths.findings, {
1812
+ schema: PAGE_REVIEW_FINDINGS_SCHEMA,
1813
+ schema_path: PAGE_REVIEW_FINDINGS_SCHEMA_FILENAME,
1814
+ target: input.target,
1815
+ reviewer: null,
1816
+ reviewed_at: null,
1817
+ delegated_reviews: [],
1818
+ findings: [],
1819
+ dispositions: [],
1820
+ });
1821
+ }
1822
+
1823
+ // Retention: what the caller says, else what the manifest already carries.
1824
+ let retention = input.retention;
1825
+ if (!retention && existsSync(paths.manifest)) {
1826
+ try {
1827
+ const prior = JSON.parse(
1828
+ readFileSync(paths.manifest, "utf8"),
1829
+ ) as Partial<PageReviewPackManifest>;
1830
+ if (prior.retention) retention = prior.retention;
1831
+ } catch {
1832
+ // A malformed prior manifest is rewritten below; retention starts absent.
1833
+ }
1834
+ }
1835
+
1836
+ const manifest: PageReviewPackManifest = {
1837
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1838
+ created_at: createdAt,
1839
+ target: input.target,
1840
+ ...(input.tested_revision !== undefined ? { tested_revision: input.tested_revision } : {}),
1841
+ tool: input.tool ?? { name: "harnery" },
1842
+ contexts,
1843
+ gates,
1844
+ critique,
1845
+ ...(input.pool ? { pool: input.pool } : {}),
1846
+ not_checked: notChecked,
1847
+ warnings,
1848
+ ...(retention ? { retention } : {}),
1849
+ files: {
1850
+ review: rel(paths.review),
1851
+ findings: rel(paths.findings),
1852
+ findings_schema: rel(paths.findingsSchema),
1853
+ inspection_plan: rel(paths.inspectionPlan),
1854
+ coverage: rel(paths.coverage),
1855
+ index: rel(paths.index),
1856
+ inventory: rel(paths.inventory),
1857
+ critique: rel(paths.critique),
1858
+ },
1859
+ };
1860
+ writeJson(paths.manifest, manifest);
1861
+ writeJson(paths.index, {
1862
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1863
+ purpose:
1864
+ "Small map for agents that should not have to probe the manifest before finding evidence.",
1865
+ target: input.target,
1866
+ counts: {
1867
+ contexts: contexts.length,
1868
+ tiles: contexts.reduce((sum, ctx) => sum + ctx.tiles.length, 0),
1869
+ machine_findings: critique?.reduce((sum, row) => sum + row.findings.length, 0) ?? null,
1870
+ },
1871
+ start_here: {
1872
+ review: manifest.files.review,
1873
+ inspection_plan: manifest.files.inspection_plan,
1874
+ coverage: manifest.files.coverage,
1875
+ critique: critique !== null ? manifest.files.critique : null,
1876
+ findings: manifest.files.findings,
1877
+ contexts: Object.fromEntries(contexts.map((ctx) => [ctx.id, ctx.files.context])),
1878
+ contact_sheets: Object.fromEntries(
1879
+ contexts.flatMap((ctx) => (ctx.contact_sheet ? [[ctx.id, ctx.contact_sheet.file]] : [])),
1880
+ ),
1881
+ },
1882
+ });
1883
+ // A verdict written by `review-pack verdict` survives every refinalize so
1884
+ // review.md keeps its "Reviewed outcome" section; the section itself notes
1885
+ // when the verdict predates the current critique.
1886
+ let verdict: PageReviewVerdictDocument | undefined;
1887
+ try {
1888
+ verdict = readPackVerdict(input.packDir);
1889
+ } catch {
1890
+ verdict = undefined;
1891
+ }
1892
+ writeFileSync(paths.review, renderReviewMarkdown(manifest, plan, input.judgeCommand, verdict));
1893
+ const inventory: Array<{ path: string; bytes: number }> = [];
1894
+ walkFiles(input.packDir, input.packDir, inventory);
1895
+ writeJson(paths.inventory, {
1896
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1897
+ files: inventory.sort((a, b) => a.path.localeCompare(b.path)),
1898
+ });
1899
+ // The manifest is rewritten once with the pack's size so `list` and the
1900
+ // run result can report it without walking the tree again.
1901
+ manifest.size_bytes = inventory.reduce((sum, file) => sum + file.bytes, 0);
1902
+ writeJson(paths.manifest, manifest);
1903
+ return { manifest: paths.manifest, review: paths.review };
1904
+ }
1905
+
1906
+ function severityCounts(findings: ReadonlyArray<{ severity: string }>): string {
1907
+ const high = findings.filter((f) => f.severity === "high").length;
1908
+ const medium = findings.filter((f) => f.severity === "medium").length;
1909
+ const low = findings.filter((f) => f.severity === "low").length;
1910
+ return `${high} high / ${medium} medium / ${low} low`;
1911
+ }
1912
+
1913
+ export function renderReviewMarkdown(
1914
+ manifest: PageReviewPackManifest,
1915
+ plan: PageReviewInspectionPlan,
1916
+ judgeCommand?: string,
1917
+ verdict?: PageReviewVerdictDocument,
1918
+ ): string {
1919
+ const lines: string[] = [];
1920
+ const contexts = manifest.contexts;
1921
+ lines.push(`# Page review: ${manifest.target}`);
1922
+ lines.push("");
1923
+ lines.push(`Captured ${manifest.created_at}. ${contexts.length} rendering context(s).`);
1924
+ if (manifest.tested_revision) lines.push(`Tested revision: \`${manifest.tested_revision}\`.`);
1925
+ lines.push("");
1926
+ lines.push(
1927
+ "This pack is evidence, not a verdict. Tile PNGs are the evidence; every other file here is navigation. Cite a tile as `<context-id>/<tile-id>` in every finding.",
1928
+ );
1929
+ lines.push("");
1930
+ lines.push("## Delegated review protocol");
1931
+ lines.push("");
1932
+ lines.push(
1933
+ "1. The coordinating agent reads the context table and coverage below. A capped context has unreviewed page below its last tile.",
1934
+ );
1935
+ lines.push(
1936
+ "2. Read `evidence/inspection-plan.json`, then dispatch review subagents with disjoint assignments covering every `primary_tiles` entry. Choose the first model available in this exact order: GPT-5.6 Luna (`gpt-5.6-luna` where an id is required), Composer 2.5, Haiku 4.5. Use that model for every tile-review subagent; do not substitute another model. If none exists, the review is incomplete. Each tile must be opened at native pixels by at least one completed subagent. The coordinating agent must not open tile images itself.",
1937
+ );
1938
+ lines.push(
1939
+ "3. Review subagents may use the contact sheet (`contacts.png`, every tile downscaled into one grid, ids stamped, row-major order) and the full-page screenshot for orientation only; both hide small defects a tile shows at native pixels.",
1940
+ );
1941
+ lines.push(
1942
+ "4. Review subagents read the machine findings for their assigned tiles. A finding is a claim to confirm against the tile, never a fact. Slice-edge cropping is a tiling artifact, not a defect.",
1943
+ );
1944
+ lines.push(
1945
+ "5. Review subagents read the deterministic gate results for their assignments. A failed gate is already a defect; do not re-litigate it, cite it.",
1946
+ );
1947
+ lines.push(
1948
+ "6. Review subagents read `not checked`. Do not invent evidence for anything listed there; record the gap in their report instead.",
1949
+ );
1950
+ lines.push(
1951
+ "7. The coordinating agent serializes the subagent reports into `findings.json`, recording the reviewer names or ids, then runs the verdict. If subagents are unavailable, any primary tile is uncovered, or reports conflict, dispatch another subagent; never substitute a coordinator image read. Without complete delegated coverage, the review is incomplete.",
1952
+ );
1953
+ lines.push("");
1954
+ lines.push("## Contexts");
1955
+ lines.push("");
1956
+ lines.push(
1957
+ "| Context | Viewport | Theme | State | Page (w×h px) | Tiles | Coverage | Contacts | Full page |",
1958
+ );
1959
+ lines.push("|---|---|---|---|---|---|---|---|---|");
1960
+ for (const ctx of contexts) {
1961
+ const cov = ctx.coverage.capped
1962
+ ? `${ctx.coverage.bands_reviewed}/${ctx.coverage.bands_total} bands, capped`
1963
+ : "complete";
1964
+ const contacts = ctx.contact_sheet
1965
+ ? `[${CONTACT_SHEET_FILENAME}](${ctx.contact_sheet.file})`
1966
+ : "none";
1967
+ lines.push(
1968
+ `| ${ctx.id} | ${ctx.viewport} | ${ctx.theme} | ${ctx.state} | ${ctx.page.width}×${ctx.page.height} | ${ctx.tiles.length} | ${cov} | ${contacts} | [full-page.png](${ctx.files.full_page}) |`,
1969
+ );
1970
+ }
1971
+ lines.push("");
1972
+ lines.push("## Coverage and warnings");
1973
+ lines.push("");
1974
+ if (manifest.warnings.length === 0) lines.push("- No warnings from the capture.");
1975
+ for (const warning of manifest.warnings) lines.push(`- ${warning}`);
1976
+ lines.push("");
1977
+ lines.push("## Not checked");
1978
+ lines.push("");
1979
+ for (const entry of manifest.not_checked) lines.push(`- **${entry.check}:** ${entry.reason}`);
1980
+ lines.push("");
1981
+ lines.push("## Deterministic gates");
1982
+ lines.push("");
1983
+ if (manifest.gates.length === 0) {
1984
+ lines.push("- No gate results were recorded in this pack.");
1985
+ } else {
1986
+ const shown = 20;
1987
+ for (const gate of manifest.gates) {
1988
+ const detail = gate.failures.length > 0 ? `: ${gate.failures.join("; ")}` : "";
1989
+ lines.push(`- ${gate.context_id} · ${gate.check_id} · **${gate.outcome}**${detail}`);
1990
+ const ctx = contexts.find((c) => c.id === gate.context_id);
1991
+ const planned = plan.contexts.find((c) => c.context_id === gate.context_id);
1992
+ const hits = planned?.gate_hits.filter((hit) => hit.check_id === gate.check_id) ?? [];
1993
+ for (const hit of hits.slice(0, shown)) {
1994
+ const r = hit.rect;
1995
+ const where = `(${Math.round(r.x)}, ${Math.round(r.y)}) ${Math.round(r.width)}×${Math.round(r.height)} px`;
1996
+ const tiles =
1997
+ hit.tiles.length > 0
1998
+ ? hit.tiles
1999
+ .map((id) => {
2000
+ const tile = ctx?.tiles.find((t) => t.id === id);
2001
+ return tile ? `[${id}](${tile.file})` : id;
2002
+ })
2003
+ .join(", ")
2004
+ : "no tile covers this rect (outside the reviewed page area)";
2005
+ lines.push(` - ${hit.rule} · ${hit.label} · at ${where} → ${tiles}`);
2006
+ }
2007
+ if (hits.length > shown) {
2008
+ lines.push(
2009
+ ` - ${hits.length - shown} more hit(s) with rectangles in \`evidence/inspection-plan.json\``,
2010
+ );
2011
+ }
2012
+ }
2013
+ }
2014
+ lines.push("");
2015
+ lines.push("## Machine findings");
2016
+ lines.push("");
2017
+ if (manifest.critique === null) {
2018
+ lines.push(
2019
+ judgeCommand
2020
+ ? `The judge stage has not run. Run \`${judgeCommand}\` to add machine findings, then dispatch review subagents over the primary tiles.`
2021
+ : "The judge stage has not run. Dispatch review subagents over the primary tiles.",
2022
+ );
2023
+ } else {
2024
+ for (const row of manifest.critique) {
2025
+ const reused = row.tiles_reused > 0 ? `, ${row.tiles_reused} reused from the baseline` : "";
2026
+ lines.push(
2027
+ `### ${row.context_id} · ${row.outcome.toUpperCase()} · ${row.tiles_reviewed} of ${row.tiles_total} tiles judged${reused} · ${severityCounts(row.findings)}`,
2028
+ );
2029
+ lines.push("");
2030
+ if (row.error) lines.push(`- Error: ${row.error}`);
2031
+ if (row.findings.length === 0) lines.push("- No machine findings.");
2032
+ for (const finding of row.findings) {
2033
+ const ctx = contexts.find((c) => c.id === row.context_id);
2034
+ const tile = ctx?.tiles.find((t) => t.id === finding.tile_id);
2035
+ const link = tile ? `[${finding.tile_id}](${tile.file})` : finding.tile_id;
2036
+ lines.push(
2037
+ `- ${link} · **${finding.severity}** · ${finding.category}: ${finding.description}`,
2038
+ );
2039
+ }
2040
+ lines.push("");
2041
+ }
2042
+ }
2043
+ if (verdict) {
2044
+ lines.push("## Reviewed outcome");
2045
+ lines.push("");
2046
+ lines.push(
2047
+ `**${verdict.reviewed_outcome.toUpperCase()}** (machine outcome ${verdict.machine_outcome}), recorded ${verdict.reviewed_at} in \`evidence/${PAGE_REVIEW_VERDICT_FILENAME}\`.`,
2048
+ );
2049
+ lines.push("");
2050
+ lines.push(
2051
+ `- Machine high findings: ${verdict.high_total} total · ${verdict.high_confirmed} confirmed · ${verdict.high_dismissed} dismissed (artifact, not a defect, or duplicate of a gate) · ${verdict.high_open} without a disposition.`,
2052
+ );
2053
+ lines.push(
2054
+ `- Review-subagent findings at critical or high: ${verdict.reviewer_high}. Dispositions applied: ${verdict.dispositions_applied}.`,
2055
+ );
2056
+ lines.push(
2057
+ `- Delegated primary-tile coverage: ${verdict.primary_tiles_reviewed}/${verdict.primary_tiles_total}.`,
2058
+ );
2059
+ if (verdict.uncovered_primary_tiles.length > 0) {
2060
+ lines.push(`- Uncovered primary tiles: ${verdict.uncovered_primary_tiles.join(", ")}.`);
2061
+ }
2062
+ if (verdict.unmatched_dispositions.length > 0) {
2063
+ lines.push(
2064
+ `- Dispositions naming no machine finding (ignored): ${verdict.unmatched_dispositions.join(", ")}.`,
2065
+ );
2066
+ }
2067
+ lines.push(
2068
+ "- A confirmed or undispositioned high, or a review-subagent finding at high or critical, fails the reviewed outcome. Missing delegated coverage keeps it incomplete. The machine critique above is unchanged by any disposition.",
2069
+ );
2070
+ lines.push("");
2071
+ }
2072
+ lines.push("## Inspection plan");
2073
+ lines.push("");
2074
+ for (const ctx of plan.contexts) {
2075
+ lines.push(
2076
+ `### ${ctx.context_id} · ${ctx.primary_tiles.length} primary tile(s), ${ctx.drilldown_tiles} drill-down`,
2077
+ );
2078
+ lines.push("");
2079
+ for (const tile of ctx.primary_tiles) {
2080
+ lines.push(`- [${tile.id}](${tile.file}) · ${tile.reason}`);
2081
+ }
2082
+ lines.push("");
2083
+ }
2084
+ lines.push("## Tile index");
2085
+ lines.push("");
2086
+ for (const ctx of contexts) {
2087
+ lines.push(`### ${ctx.id}`);
2088
+ lines.push("");
2089
+ if (ctx.contact_sheet) {
2090
+ lines.push(
2091
+ `Contact sheet: [${CONTACT_SHEET_FILENAME}](${ctx.contact_sheet.file}) shows every tile below in id order, row-major (left to right, then top to bottom), ${ctx.contact_sheet.columns} per row, each cell stamped with its tile id. Orientation only; a review subagent opens the tile file for native pixels.`,
2092
+ );
2093
+ lines.push("");
2094
+ }
2095
+ lines.push("| Tile | Label | Kind | Scope | y (px) | Size (px) | File |");
2096
+ lines.push("|---|---|---|---|---|---|---|");
2097
+ for (const tile of ctx.tiles) {
2098
+ const kind = tile.kind ?? (tile.scope !== undefined ? "scope" : "band");
2099
+ lines.push(
2100
+ `| ${tile.id} | ${tile.label} | ${kind} | ${tile.scope ?? "full page"} | ${tile.scrollY} | ${tile.width}×${tile.height} | [${tile.id}.png](${tile.file}) |`,
2101
+ );
2102
+ }
2103
+ lines.push("");
2104
+ if (ctx.expanded && ctx.expanded.length > 0) {
2105
+ lines.push(
2106
+ "Expanded tiles (the same region re-rendered at a higher device scale factor; the source tile above is unchanged):",
2107
+ );
2108
+ lines.push("");
2109
+ for (const entry of ctx.expanded) {
2110
+ lines.push(
2111
+ `- ${entry.tile} at ${entry.dpr}× · ${entry.width}×${entry.height} px · [${expandedTileFilename(entry.tile, entry.dpr)}](${entry.file})`,
2112
+ );
2113
+ }
2114
+ lines.push("");
2115
+ }
2116
+ }
2117
+ lines.push("## Write findings");
2118
+ lines.push("");
2119
+ lines.push(
2120
+ "`findings.json` is the delegated review output file. The coordinating agent writes it serially from completed subagent reports. Record each assignment and its chosen model with `review-pack reviews add`; `delegated_reviews` must prove completed coverage for every primary tile before the verdict can pass. Set top-level `reviewer` to the review subagent names or ids and set an RFC 3339 `reviewed_at`. Each finding requires `id`, `severity` (critical, high, medium, low, info), `category`, `context_id`, `evidence` (tile ids or pack-relative paths), and `observation`; `recommendation` and `confidence` are optional. Use `findings.schema.json` only when a validator rejects the write.",
2121
+ );
2122
+ lines.push("");
2123
+ lines.push("## Files");
2124
+ lines.push("");
2125
+ lines.push(
2126
+ `- [Manifest](${PAGE_REVIEW_MANIFEST_FILENAME}) · [Inspection plan](${manifest.files.inspection_plan}) · [Coverage](${manifest.files.coverage}) · [Index](${manifest.files.index}) · [Inventory](${manifest.files.inventory})`,
2127
+ );
2128
+ if (manifest.critique !== null) lines.push(`- [Machine critique](${manifest.files.critique})`);
2129
+ lines.push("");
2130
+ return `${lines.join("\n")}\n`;
2131
+ }
2132
+
2133
+ // ---------------------------------------------------------------------------
2134
+ // Expiry: find packs, delete the expired ones, leave a stub behind
2135
+ // ---------------------------------------------------------------------------
2136
+
2137
+ /** Schema of the stub left behind when an expired pack is deleted. */
2138
+ export const PAGE_REVIEW_EXPIRED_SCHEMA = "harnery-page-review-expired/v1";
2139
+
2140
+ /** The artifact store's per-workspace manifest. A standalone review-pack
2141
+ * workspace IS the pack, so deletion keeps this one file beside the stub.
2142
+ * Named here rather than imported: this toolkit module must not reach into
2143
+ * `src/core`. */
2144
+ const ARTIFACT_WORKSPACE_MANIFEST_FILENAME = ".harnery-artifact.json";
2145
+
2146
+ /** What remains of a deleted pack: enough for a result document's
2147
+ * `review_pack.dir` to explain itself without the evidence. */
2148
+ export interface PageReviewExpiredStub {
2149
+ schema: typeof PAGE_REVIEW_EXPIRED_SCHEMA;
2150
+ target: string;
2151
+ created_at: string;
2152
+ expires_at: string;
2153
+ deleted_at: string;
2154
+ /** Contexts the pack held before deletion. */
2155
+ contexts: number;
2156
+ /** Aggregate of the manifest's critique outcomes; null when no judge ran. */
2157
+ machine_outcome: PageReviewCritiqueRecord["outcome"] | null;
2158
+ }
2159
+
2160
+ /** One pack as seen by the expiry sweep. `expires_at`, `size_bytes`, and
2161
+ * `managed` are null when the manifest does not carry them; such a pack is
2162
+ * never deleted. */
2163
+ export interface PageReviewPackRow {
2164
+ dir: string;
2165
+ target: string;
2166
+ created_at: string;
2167
+ expires_at: string | null;
2168
+ size_bytes: number | null;
2169
+ managed: boolean | null;
2170
+ /** `retention.expires_at` is in the past (at the sweep's `now`). */
2171
+ expired: boolean;
2172
+ }
2173
+
2174
+ export interface DeleteExpiredPacksInput {
2175
+ /** Directories to search; see `findPageReviewPacks` for the shape searched. */
2176
+ roots: string[];
2177
+ /** Sweep clock (default: the wall clock). */
2178
+ now?: Date;
2179
+ /** Also delete packs whose manifest says `managed: false` (an explicit
2180
+ * `--out`). Default false: only the store's own packs are touched. */
2181
+ includeUnmanaged?: boolean;
2182
+ /** Report without touching the filesystem. */
2183
+ dryRun: boolean;
2184
+ }
2185
+
2186
+ export interface DeleteExpiredPacksResult {
2187
+ /** Every pack found under the roots, deletable or not. */
2188
+ candidates: PageReviewPackRow[];
2189
+ /** Packs removed by this call; under `dryRun`, the packs that would be. */
2190
+ deleted: PageReviewPackRow[];
2191
+ }
2192
+
2193
+ /** The manifest at `dir`, or null when `dir` is not a page review pack. A
2194
+ * malformed or foreign `manifest.json` is not a pack. */
2195
+ function readPackManifestIfPack(dir: string): PageReviewPackManifest | null {
2196
+ const manifestPath = join(dir, PAGE_REVIEW_MANIFEST_FILENAME);
2197
+ if (!existsSync(manifestPath)) return null;
2198
+ try {
2199
+ const manifest = JSON.parse(
2200
+ readFileSync(manifestPath, "utf8"),
2201
+ ) as Partial<PageReviewPackManifest>;
2202
+ if (manifest.schema !== PAGE_REVIEW_PACK_SCHEMA) return null;
2203
+ if (typeof manifest.target !== "string" || typeof manifest.created_at !== "string") return null;
2204
+ return manifest as PageReviewPackManifest;
2205
+ } catch {
2206
+ return null;
2207
+ }
2208
+ }
2209
+
2210
+ function listChildDirs(dir: string): string[] {
2211
+ try {
2212
+ return readdirSync(dir, { withFileTypes: true })
2213
+ .filter((entry) => entry.isDirectory())
2214
+ .map((entry) => join(dir, entry.name))
2215
+ .sort();
2216
+ } catch {
2217
+ return [];
2218
+ }
2219
+ }
2220
+
2221
+ /** A workspace and the qa-run packs beneath it (`run-<id>/pack`). */
2222
+ function packCandidatesUnder(workspace: string): string[] {
2223
+ const out = [workspace];
2224
+ for (const runDir of listChildDirs(workspace)) {
2225
+ if (!runDir.split(/[\\/]/).pop()?.startsWith("run-")) continue;
2226
+ out.push(join(runDir, PAGE_REVIEW_PACK_DIRNAME));
2227
+ }
2228
+ return out;
2229
+ }
2230
+
2231
+ /**
2232
+ * Every page review pack under the given roots. A pack is a directory whose
2233
+ * `manifest.json` carries the pack schema. Each root is searched as: the root
2234
+ * itself, each immediate child workspace, and `run-<id>/pack` under the root and
2235
+ * under each workspace (the layout of the managed artifact store, where a
2236
+ * `review-pack` workspace is itself the pack and a `qa-run` workspace holds
2237
+ * one pack per run). Nothing deeper is walked.
2238
+ */
2239
+ export function findPageReviewPacks(roots: string[]): string[] {
2240
+ const found = new Set<string>();
2241
+ for (const rawRoot of roots) {
2242
+ const root = resolve(rawRoot);
2243
+ if (!existsSync(root)) continue;
2244
+ const candidates = [
2245
+ ...packCandidatesUnder(root),
2246
+ ...listChildDirs(root).flatMap((workspace) => packCandidatesUnder(workspace)),
2247
+ ];
2248
+ for (const candidate of candidates) {
2249
+ if (readPackManifestIfPack(candidate)) found.add(candidate);
2250
+ }
2251
+ }
2252
+ return [...found].sort();
2253
+ }
2254
+
2255
+ function packRow(dir: string, manifest: PageReviewPackManifest, nowMs: number): PageReviewPackRow {
2256
+ const expiresAt = manifest.retention?.expires_at ?? null;
2257
+ const expiresMs = expiresAt === null ? Number.NaN : Date.parse(expiresAt);
2258
+ return {
2259
+ dir,
2260
+ target: manifest.target,
2261
+ created_at: manifest.created_at,
2262
+ expires_at: expiresAt,
2263
+ size_bytes: typeof manifest.size_bytes === "number" ? manifest.size_bytes : null,
2264
+ managed: typeof manifest.retention?.managed === "boolean" ? manifest.retention.managed : null,
2265
+ expired: Number.isFinite(expiresMs) && expiresMs <= nowMs,
2266
+ };
2267
+ }
2268
+
2269
+ /** Whether the sweep may delete this pack: expired, and either managed by the
2270
+ * store or explicitly included. A pack without retention never qualifies. */
2271
+ export function isPackDeletable(row: PageReviewPackRow, includeUnmanaged = false): boolean {
2272
+ if (!row.expired || row.expires_at === null) return false;
2273
+ if (row.managed === true) return true;
2274
+ return includeUnmanaged && row.managed === false;
2275
+ }
2276
+
2277
+ /** One verdict for the whole pack from its per-context critique rows: any
2278
+ * fail is a fail, otherwise the weakest non-pass outcome, otherwise pass. */
2279
+ export function aggregateMachineOutcome(
2280
+ critique: PageReviewCritiqueRecord[] | null | undefined,
2281
+ ): PageReviewCritiqueRecord["outcome"] | null {
2282
+ if (!critique || critique.length === 0) return null;
2283
+ if (critique.some((row) => row.outcome === "fail")) return "fail";
2284
+ if (critique.some((row) => row.outcome === "incomplete")) return "incomplete";
2285
+ if (critique.some((row) => row.outcome === "skipped")) return "skipped";
2286
+ return "pass";
2287
+ }
2288
+
2289
+ /**
2290
+ * Delete every expired pack under the roots and leave `pack-expired.json` in
2291
+ * its place. Deletion removes every entry inside the pack directory except
2292
+ * the artifact store's own `.harnery-artifact.json` (a standalone review-pack
2293
+ * workspace is the pack, and the store still owns the workspace). Nothing
2294
+ * outside the pack directory is touched. Only a pack whose manifest carries
2295
+ * `retention.expires_at` in the past is eligible, and an unmanaged pack
2296
+ * (explicit `--out`) only when `includeUnmanaged` is set.
2297
+ */
2298
+ export function deleteExpiredPacks(input: DeleteExpiredPacksInput): DeleteExpiredPacksResult {
2299
+ const now = input.now ?? new Date();
2300
+ const nowMs = now.getTime();
2301
+ const candidates: PageReviewPackRow[] = [];
2302
+ const deleted: PageReviewPackRow[] = [];
2303
+ for (const dir of findPageReviewPacks(input.roots)) {
2304
+ const manifest = readPackManifestIfPack(dir);
2305
+ if (!manifest) continue;
2306
+ const row = packRow(dir, manifest, nowMs);
2307
+ candidates.push(row);
2308
+ if (!isPackDeletable(row, input.includeUnmanaged === true)) continue;
2309
+ const protection = packArtifactProtection(dir);
2310
+ if (protection.protected) continue;
2311
+ let locked = false;
2312
+ if (!input.dryRun && protection.lock) {
2313
+ try {
2314
+ mkdirSync(protection.lock);
2315
+ locked = true;
2316
+ } catch {
2317
+ continue;
2318
+ }
2319
+ }
2320
+ try {
2321
+ if (packArtifactProtection(dir).protected) continue;
2322
+ if (!input.dryRun) {
2323
+ const stub: PageReviewExpiredStub = {
2324
+ schema: PAGE_REVIEW_EXPIRED_SCHEMA,
2325
+ target: manifest.target,
2326
+ created_at: manifest.created_at,
2327
+ // Guarded by isPackDeletable: a deletable row always has expires_at.
2328
+ expires_at: row.expires_at ?? "",
2329
+ deleted_at: now.toISOString(),
2330
+ contexts: Array.isArray(manifest.contexts) ? manifest.contexts.length : 0,
2331
+ machine_outcome: aggregateMachineOutcome(manifest.critique),
2332
+ };
2333
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2334
+ if (entry.name === ARTIFACT_WORKSPACE_MANIFEST_FILENAME) continue;
2335
+ rmSync(join(dir, entry.name), { recursive: true, force: true });
2336
+ }
2337
+ writeJson(join(dir, PAGE_REVIEW_EXPIRED_STUB_FILENAME), stub);
2338
+ }
2339
+ deleted.push(row);
2340
+ } finally {
2341
+ if (locked) rmdirSync(protection.lock!);
2342
+ }
2343
+ }
2344
+ return { candidates, deleted };
2345
+ }
2346
+
2347
+ /** Read the artifact wire contract without importing the product tier. The same
2348
+ * lock protocol covers core hold writes and pack payload deletion. Unknown or
2349
+ * malformed manifests are retained, including legacy manifests awaiting migration. */
2350
+ function packArtifactProtection(dir: string): { protected: boolean; lock?: string } {
2351
+ let current = resolve(dir);
2352
+ let lock: string | undefined;
2353
+ let protectedByManifest = false;
2354
+ while (dirname(current) !== current) {
2355
+ try {
2356
+ if (lstatSync(current).isSymbolicLink()) return { protected: true };
2357
+ if (
2358
+ basename(dirname(current)) === "artifacts" &&
2359
+ basename(dirname(dirname(current))) === ".harnery"
2360
+ ) {
2361
+ lock = join(dirname(dirname(current)), "artifacts-mutation.lock");
2362
+ if (!existsSync(join(current, ARTIFACT_WORKSPACE_MANIFEST_FILENAME)))
2363
+ protectedByManifest = true;
2364
+ }
2365
+ const path = join(current, ARTIFACT_WORKSPACE_MANIFEST_FILENAME);
2366
+ if (existsSync(path)) {
2367
+ const stat = lstatSync(path);
2368
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
2369
+ return { protected: true };
2370
+ const manifest = JSON.parse(readFileSync(path, "utf8"));
2371
+ if (
2372
+ manifest?.schema_version !== 2 ||
2373
+ !Array.isArray(manifest.holds) ||
2374
+ manifest.holds.length !== 0
2375
+ )
2376
+ protectedByManifest = true;
2377
+ }
2378
+ } catch {
2379
+ return { protected: true };
2380
+ }
2381
+ current = dirname(current);
2382
+ }
2383
+ return { protected: protectedByManifest, lock };
2384
+ }