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,1751 @@
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
+ import { createHash } from "node:crypto";
15
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync, } from "node:fs";
16
+ import { basename, dirname, join, relative, resolve } from "node:path";
17
+ import { gunzipSync, gzipSync } from "node:zlib";
18
+ import { PNG } from "pngjs";
19
+ export const PAGE_REVIEW_PACK_SCHEMA = "harnery-page-review/v1";
20
+ export const PAGE_REVIEW_FINDINGS_SCHEMA = "harnery-page-review-findings/v3";
21
+ /** Directory name a qa-run gives its pack inside the run directory. */
22
+ export const PAGE_REVIEW_PACK_DIRNAME = "pack";
23
+ export const PAGE_REVIEW_MANIFEST_FILENAME = "manifest.json";
24
+ export const PAGE_REVIEW_REVIEW_FILENAME = "review.md";
25
+ export const PAGE_REVIEW_FINDINGS_FILENAME = "findings.json";
26
+ export const PAGE_REVIEW_FINDINGS_SCHEMA_FILENAME = "findings.schema.json";
27
+ /** How long a pack lives after its judge (or its capture, when no judge
28
+ * runs) before the whole directory is deleted. Ruled 2026-09-02. */
29
+ export const PAGE_REVIEW_DEFAULT_RETENTION_MINUTES = 90;
30
+ /** The only file left behind when an expired pack is deleted. */
31
+ export const PAGE_REVIEW_EXPIRED_STUB_FILENAME = "pack-expired.json";
32
+ /** Reviewer dispositions a machine finding can carry in `findings.json`. */
33
+ export const PAGE_REVIEW_DISPOSITIONS = [
34
+ "confirmed",
35
+ "artifact",
36
+ "not-a-defect",
37
+ "duplicate-of-gate",
38
+ ];
39
+ /** Schema id and file name of the reviewed-outcome record `review-pack verdict` writes. */
40
+ export const PAGE_REVIEW_VERDICT_SCHEMA = "harnery-page-review-verdict/v2";
41
+ export const PAGE_REVIEW_VERDICT_FILENAME = "verdict.json";
42
+ export const PAGE_REVIEW_FINDING_SEVERITIES = [
43
+ "critical",
44
+ "high",
45
+ "medium",
46
+ "low",
47
+ "info",
48
+ ];
49
+ export const PAGE_REVIEW_FINDING_CATEGORIES = [
50
+ "layout",
51
+ "typography",
52
+ "contrast",
53
+ "content",
54
+ "image",
55
+ "interaction",
56
+ "accessibility",
57
+ "responsiveness",
58
+ "render-artifact",
59
+ "coverage",
60
+ "other",
61
+ ];
62
+ export const PAGE_REVIEW_SUBAGENT_MODELS = ["GPT-5.6 Luna", "Composer 2.5", "Haiku 4.5"];
63
+ export function packPaths(packDir) {
64
+ const evidenceDir = join(packDir, "evidence");
65
+ const contextsDir = join(packDir, "contexts");
66
+ return {
67
+ dir: packDir,
68
+ manifest: join(packDir, PAGE_REVIEW_MANIFEST_FILENAME),
69
+ review: join(packDir, PAGE_REVIEW_REVIEW_FILENAME),
70
+ findings: join(packDir, PAGE_REVIEW_FINDINGS_FILENAME),
71
+ findingsSchema: join(packDir, PAGE_REVIEW_FINDINGS_SCHEMA_FILENAME),
72
+ evidenceDir,
73
+ inspectionPlan: join(evidenceDir, "inspection-plan.json"),
74
+ coverage: join(evidenceDir, "coverage.json"),
75
+ index: join(evidenceDir, "index.json"),
76
+ inventory: join(evidenceDir, "files.json"),
77
+ critique: join(evidenceDir, "critique.json"),
78
+ verdict: join(evidenceDir, PAGE_REVIEW_VERDICT_FILENAME),
79
+ contextsDir,
80
+ contextDir: (contextId) => join(contextsDir, safeSegment(contextId)),
81
+ };
82
+ }
83
+ function safeSegment(value) {
84
+ const clean = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^\.+/, "");
85
+ return clean.length > 0 ? clean : "context";
86
+ }
87
+ export function sha256Hex(buffer) {
88
+ return createHash("sha256").update(buffer).digest("hex");
89
+ }
90
+ export function tileId(position) {
91
+ return `T${String(position + 1).padStart(3, "0")}`;
92
+ }
93
+ function writeJson(path, value) {
94
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
95
+ }
96
+ function readJson(path) {
97
+ return JSON.parse(readFileSync(path, "utf8"));
98
+ }
99
+ /** Deterministic file name for an expanded tile: `T012@2x.png`. */
100
+ export function expandedTileFilename(tileId, dpr) {
101
+ return `${tileId}@${dpr}x.png`;
102
+ }
103
+ /**
104
+ * Crop one region out of a PNG buffer in pixel space. The rect is clamped to
105
+ * the image so an off-by-one never throws; the result is at least 1×1.
106
+ */
107
+ export function cropPngRegion(buffer, rect) {
108
+ const src = PNG.sync.read(buffer);
109
+ const sx = Math.max(0, Math.min(Math.round(rect.x), src.width - 1));
110
+ const sy = Math.max(0, Math.min(Math.round(rect.y), src.height - 1));
111
+ const w = Math.max(1, Math.min(Math.round(rect.width), src.width - sx));
112
+ const h = Math.max(1, Math.min(Math.round(rect.height), src.height - sy));
113
+ const dst = new PNG({ width: w, height: h });
114
+ for (let row = 0; row < h; row++) {
115
+ const srcStart = ((sy + row) * src.width + sx) * 4;
116
+ src.data.copy(dst.data, row * w * 4, srcStart, srcStart + w * 4);
117
+ }
118
+ return { png: PNG.sync.write(dst), width: w, height: h };
119
+ }
120
+ /**
121
+ * Locate one tile across a pack's contexts. With `contextId` the lookup is
122
+ * exact; without it the tile id must be unique across the given contexts,
123
+ * otherwise the caller has to name the context.
124
+ */
125
+ export function findPackTile(contexts, tileId, contextId) {
126
+ if (contextId !== undefined) {
127
+ const context = contexts.find((ctx) => ctx.id === contextId);
128
+ if (!context) {
129
+ throw new Error(`context ${contextId} is not in the pack (have: ${contexts.map((c) => c.id).join(", ") || "none"})`);
130
+ }
131
+ const tile = context.tiles.find((t) => t.id === tileId);
132
+ if (!tile) {
133
+ throw new Error(`tile ${tileId} is not in context ${contextId} (${context.tiles.length} tile(s): ${context.tiles[0]?.id ?? "none"}…${context.tiles[context.tiles.length - 1]?.id ?? ""})`);
134
+ }
135
+ return { context, tile };
136
+ }
137
+ const matches = contexts.flatMap((context) => context.tiles.filter((t) => t.id === tileId).map((tile) => ({ context, tile })));
138
+ if (matches.length === 0)
139
+ throw new Error(`tile ${tileId} is not in any context of the pack`);
140
+ if (matches.length > 1) {
141
+ throw new Error(`tile ${tileId} exists in ${matches.length} contexts (${matches
142
+ .map((m) => m.context.id)
143
+ .join(", ")}); pass --context <id>`);
144
+ }
145
+ return matches[0];
146
+ }
147
+ /**
148
+ * Write one tile region re-rendered at `dpr` into an existing context:
149
+ * `tiles/<tile>@<dpr>x.png` cropped from `fullPage` (a screenshot of the same
150
+ * page at that device scale factor, so the source tile's CSS-pixel rect is
151
+ * multiplied by `dpr`). Existing tiles are never touched; the context record
152
+ * gains or replaces one `expanded` entry for that tile + dpr and is rewritten.
153
+ */
154
+ export function writePackExpandedTile(packDir, contextId, input) {
155
+ if (!Number.isFinite(input.dpr) || input.dpr <= 0) {
156
+ throw new Error(`dpr must be a positive number (got ${input.dpr})`);
157
+ }
158
+ const record = readPackContext(packDir, contextId);
159
+ const { tile } = findPackTile([record], input.tileId, contextId);
160
+ const paths = packPaths(packDir);
161
+ const dir = paths.contextDir(contextId);
162
+ const file = join(dir, "tiles", expandedTileFilename(tile.id, input.dpr));
163
+ const { png, width, height } = input.region
164
+ ? {
165
+ png: input.region,
166
+ width: input.region.readUInt32BE(16),
167
+ height: input.region.readUInt32BE(20),
168
+ }
169
+ : cropPngRegion(input.fullPage, {
170
+ x: tile.x * input.dpr,
171
+ y: tile.scrollY * input.dpr,
172
+ width: tile.width * input.dpr,
173
+ height: tile.height * input.dpr,
174
+ });
175
+ mkdirSync(join(dir, "tiles"), { recursive: true });
176
+ writeFileSync(file, png);
177
+ const expanded = {
178
+ tile: tile.id,
179
+ dpr: input.dpr,
180
+ width,
181
+ height,
182
+ file: relative(packDir, file).split("\\").join("/"),
183
+ sha256: sha256Hex(png),
184
+ bytes: png.byteLength,
185
+ captured_at: input.capturedAt ?? new Date().toISOString(),
186
+ };
187
+ const kept = (record.expanded ?? []).filter((entry) => !(entry.tile === expanded.tile && entry.dpr === expanded.dpr));
188
+ const next = { ...record, expanded: [...kept, expanded] };
189
+ writeJson(join(dir, "context.json"), { schema: PAGE_REVIEW_PACK_SCHEMA, ...next });
190
+ return { record: next, expanded };
191
+ }
192
+ /**
193
+ * Write one context's capture into the pack: `full-page.png`, `dom.html.gz`,
194
+ * `signature.json`, `tiles/T001.png…`, `tiles.json`, and `context.json`.
195
+ * Full-page bands come first, then each scope's tiles in selector order, so
196
+ * tile ids are stable for a given capture. Returns the record written.
197
+ */
198
+ export function writePackContext(packDir, capture) {
199
+ const contextId = capture.context.id ??
200
+ `${capture.context.viewport}-${capture.context.theme}-${capture.context.state}`;
201
+ const paths = packPaths(packDir);
202
+ const dir = paths.contextDir(contextId);
203
+ const tilesDir = join(dir, "tiles");
204
+ mkdirSync(tilesDir, { recursive: true });
205
+ const rel = (abs) => relative(packDir, abs).split("\\").join("/");
206
+ const fullPagePath = join(dir, "full-page.png");
207
+ const domPath = join(dir, "dom.html.gz");
208
+ const signaturePath = join(dir, "signature.json");
209
+ const tilesPath = join(dir, "tiles.json");
210
+ const contextPath = join(dir, "context.json");
211
+ writeFileSync(fullPagePath, capture.fullPage);
212
+ // The serialized DOM compresses roughly 10:1; `dom_sha256` stays the digest
213
+ // of the uncompressed bytes so a reader can verify what `readPackDom` returns.
214
+ writeFileSync(domPath, gzipSync(Buffer.from(capture.domHtml, "utf8")));
215
+ writeJson(signaturePath, capture.signature);
216
+ const records = [];
217
+ const tilePngs = new Map();
218
+ const ordered = [
219
+ ...capture.tiles.map((tile) => ({ tile })),
220
+ ...(capture.scopeTiles ?? []).flatMap((entry) => entry.tiles.map((tile) => ({ tile, scope: entry.selector }))),
221
+ ];
222
+ ordered.forEach(({ tile, scope }, position) => {
223
+ const id = tileId(position);
224
+ const png = Buffer.from(tile.pngBase64, "base64");
225
+ const file = join(tilesDir, `${id}.png`);
226
+ writeFileSync(file, png);
227
+ tilePngs.set(id, png);
228
+ records.push({
229
+ id,
230
+ index: tile.index,
231
+ label: tile.label,
232
+ ...(scope !== undefined ? { scope } : {}),
233
+ kind: tile.label.startsWith("hit band") ? "hit-band" : scope !== undefined ? "scope" : "band",
234
+ x: tile.x ?? 0,
235
+ scrollY: tile.scrollY,
236
+ width: tile.width,
237
+ height: tile.height,
238
+ file: rel(file),
239
+ sha256: sha256Hex(png),
240
+ bytes: png.byteLength,
241
+ });
242
+ });
243
+ const record = {
244
+ id: contextId,
245
+ viewport: capture.context.viewport,
246
+ theme: capture.context.theme,
247
+ state: capture.context.state,
248
+ url: capture.url,
249
+ ...(capture.title !== undefined ? { title: capture.title } : {}),
250
+ captured_at: capture.capturedAt ?? new Date().toISOString(),
251
+ page: { width: capture.pageWidth, height: capture.pageHeight },
252
+ coverage: capture.coverage,
253
+ scopes: (capture.scopeTiles ?? []).map((entry) => ({
254
+ selector: entry.selector,
255
+ tiles: entry.tiles.length,
256
+ })),
257
+ files: {
258
+ full_page: rel(fullPagePath),
259
+ dom: rel(domPath),
260
+ signature: rel(signaturePath),
261
+ tiles: rel(tilesPath),
262
+ context: rel(contextPath),
263
+ },
264
+ dom_sha256: sha256Hex(capture.domHtml),
265
+ tiles: records,
266
+ ...(capture.captureFidelity ? { capture_fidelity: capture.captureFidelity } : {}),
267
+ ...(capture.hitBands !== undefined && capture.hitBands > 0
268
+ ? { hit_bands: capture.hitBands }
269
+ : {}),
270
+ };
271
+ writeJson(tilesPath, { schema: PAGE_REVIEW_PACK_SCHEMA, context_id: contextId, tiles: records });
272
+ writeJson(contextPath, { schema: PAGE_REVIEW_PACK_SCHEMA, ...record });
273
+ // The contact sheet is navigation, so it rides on the record but never
274
+ // gates the capture: the tiles are already on disk when it is built.
275
+ return writePackContactSheet(packDir, record, tilePngs);
276
+ }
277
+ // ---------------------------------------------------------------------------
278
+ // Contact sheet
279
+ // ---------------------------------------------------------------------------
280
+ export const CONTACT_SHEET_FILENAME = "contacts.png";
281
+ /** Tiles per row. Four 320 px cells plus gutters stay under a 1600 px sheet. */
282
+ export const CONTACT_SHEET_COLUMNS = 4;
283
+ const CONTACT_CELL = 320;
284
+ const CONTACT_LABEL_H = 20;
285
+ const CONTACT_GUTTER = 8;
286
+ const CONTACT_BG = [212, 212, 216];
287
+ const CONTACT_LABEL_BG = [24, 24, 27];
288
+ const CONTACT_LABEL_FG = [250, 250, 250];
289
+ /** 3×5 bitmap glyphs for the characters a tile id uses. Rows top-down, bits
290
+ * left-to-right. Anything else renders as a blank column. */
291
+ const CONTACT_FONT = {
292
+ "0": [0b111, 0b101, 0b101, 0b101, 0b111],
293
+ "1": [0b010, 0b110, 0b010, 0b010, 0b111],
294
+ "2": [0b111, 0b001, 0b111, 0b100, 0b111],
295
+ "3": [0b111, 0b001, 0b111, 0b001, 0b111],
296
+ "4": [0b101, 0b101, 0b111, 0b001, 0b001],
297
+ "5": [0b111, 0b100, 0b111, 0b001, 0b111],
298
+ "6": [0b111, 0b100, 0b111, 0b101, 0b111],
299
+ "7": [0b111, 0b001, 0b001, 0b001, 0b001],
300
+ "8": [0b111, 0b101, 0b111, 0b101, 0b111],
301
+ "9": [0b111, 0b101, 0b111, 0b001, 0b111],
302
+ T: [0b111, 0b010, 0b010, 0b010, 0b010],
303
+ };
304
+ function fillRect(png, x0, y0, w, h, rgb) {
305
+ for (let y = y0; y < y0 + h; y++) {
306
+ for (let x = x0; x < x0 + w; x++) {
307
+ const i = (y * png.width + x) * 4;
308
+ png.data[i] = rgb[0];
309
+ png.data[i + 1] = rgb[1];
310
+ png.data[i + 2] = rgb[2];
311
+ png.data[i + 3] = 255;
312
+ }
313
+ }
314
+ }
315
+ /** Stamp `text` at (x, y) with the bitmap font at `scale` pixels per dot. */
316
+ function stampText(png, text, x, y, scale, rgb) {
317
+ let cursor = x;
318
+ for (const ch of text) {
319
+ const glyph = CONTACT_FONT[ch];
320
+ if (glyph) {
321
+ glyph.forEach((rowBits, row) => {
322
+ for (let col = 0; col < 3; col++) {
323
+ if ((rowBits >> (2 - col)) & 1) {
324
+ fillRect(png, cursor + col * scale, y + row * scale, scale, scale, rgb);
325
+ }
326
+ }
327
+ });
328
+ }
329
+ cursor += 4 * scale;
330
+ }
331
+ }
332
+ /**
333
+ * Box-filter downscale (area average) of `src` to `dstW`×`dstH`. Never used
334
+ * to upscale; callers pass a destination no larger than the source.
335
+ */
336
+ function boxDownscale(src, dstW, dstH) {
337
+ const dst = new PNG({ width: dstW, height: dstH });
338
+ for (let dy = 0; dy < dstH; dy++) {
339
+ const sy0 = Math.floor((dy * src.height) / dstH);
340
+ const sy1 = Math.max(sy0 + 1, Math.floor(((dy + 1) * src.height) / dstH));
341
+ for (let dx = 0; dx < dstW; dx++) {
342
+ const sx0 = Math.floor((dx * src.width) / dstW);
343
+ const sx1 = Math.max(sx0 + 1, Math.floor(((dx + 1) * src.width) / dstW));
344
+ let r = 0;
345
+ let g = 0;
346
+ let b = 0;
347
+ let a = 0;
348
+ let n = 0;
349
+ for (let sy = sy0; sy < sy1; sy++) {
350
+ for (let sx = sx0; sx < sx1; sx++) {
351
+ const i = (sy * src.width + sx) * 4;
352
+ r += src.data[i] ?? 0;
353
+ g += src.data[i + 1] ?? 0;
354
+ b += src.data[i + 2] ?? 0;
355
+ a += src.data[i + 3] ?? 0;
356
+ n++;
357
+ }
358
+ }
359
+ const o = (dy * dstW + dx) * 4;
360
+ dst.data[o] = Math.round(r / n);
361
+ dst.data[o + 1] = Math.round(g / n);
362
+ dst.data[o + 2] = Math.round(b / n);
363
+ dst.data[o + 3] = Math.round(a / n);
364
+ }
365
+ }
366
+ return dst;
367
+ }
368
+ /**
369
+ * Build one contact sheet from tile PNGs, in the order given: a fixed grid of
370
+ * `CONTACT_SHEET_COLUMNS` cells per row, each cell a label band stamped with
371
+ * the tile id above the tile downscaled (box filter, aspect kept, never
372
+ * upscaled) to fit the cell. Row-major reading order.
373
+ */
374
+ export function buildContactSheet(tiles) {
375
+ const columns = Math.max(1, Math.min(CONTACT_SHEET_COLUMNS, tiles.length));
376
+ const rows = Math.max(1, Math.ceil(tiles.length / columns));
377
+ const cellH = CONTACT_LABEL_H + CONTACT_CELL;
378
+ const width = columns * CONTACT_CELL + (columns + 1) * CONTACT_GUTTER;
379
+ const height = rows * cellH + (rows + 1) * CONTACT_GUTTER;
380
+ const sheet = new PNG({ width, height });
381
+ fillRect(sheet, 0, 0, width, height, CONTACT_BG);
382
+ tiles.forEach((tile, position) => {
383
+ const col = position % columns;
384
+ const row = Math.floor(position / columns);
385
+ const x0 = CONTACT_GUTTER + col * (CONTACT_CELL + CONTACT_GUTTER);
386
+ const y0 = CONTACT_GUTTER + row * (cellH + CONTACT_GUTTER);
387
+ fillRect(sheet, x0, y0, CONTACT_CELL, CONTACT_LABEL_H, CONTACT_LABEL_BG);
388
+ stampText(sheet, tile.id, x0 + 4, y0 + 3, 3, CONTACT_LABEL_FG);
389
+ const src = PNG.sync.read(tile.png);
390
+ const scale = Math.min(1, CONTACT_CELL / src.width, CONTACT_CELL / src.height);
391
+ const dstW = Math.max(1, Math.round(src.width * scale));
392
+ const dstH = Math.max(1, Math.round(src.height * scale));
393
+ const scaled = scale < 1 ? boxDownscale(src, dstW, dstH) : src;
394
+ const ty = y0 + CONTACT_LABEL_H;
395
+ for (let y = 0; y < scaled.height; y++) {
396
+ const srcStart = y * scaled.width * 4;
397
+ scaled.data.copy(sheet.data, ((ty + y) * width + x0) * 4, srcStart, srcStart + scaled.width * 4);
398
+ }
399
+ });
400
+ return {
401
+ png: PNG.sync.write(sheet),
402
+ width,
403
+ height,
404
+ columns,
405
+ rows,
406
+ cell_width: CONTACT_CELL,
407
+ cell_height: cellH,
408
+ };
409
+ }
410
+ /**
411
+ * Write (or rewrite) a context's `contacts.png` from its tiles and record it
412
+ * on the context. Tile bytes are read from the pack unless supplied. A
413
+ * context with no tiles gets no sheet and its record is returned unchanged.
414
+ */
415
+ export function writePackContactSheet(packDir, record, tilePngs) {
416
+ if (record.tiles.length === 0)
417
+ return record;
418
+ const tiles = record.tiles.map((tile) => ({
419
+ id: tile.id,
420
+ png: tilePngs?.get(tile.id) ?? readFileSync(join(packDir, tile.file)),
421
+ }));
422
+ const sheet = buildContactSheet(tiles);
423
+ const dir = packPaths(packDir).contextDir(record.id);
424
+ mkdirSync(dir, { recursive: true });
425
+ const file = join(dir, CONTACT_SHEET_FILENAME);
426
+ writeFileSync(file, sheet.png);
427
+ const next = {
428
+ ...record,
429
+ contact_sheet: {
430
+ file: relative(packDir, file).split("\\").join("/"),
431
+ sha256: sha256Hex(sheet.png),
432
+ bytes: sheet.png.byteLength,
433
+ width: sheet.width,
434
+ height: sheet.height,
435
+ columns: sheet.columns,
436
+ rows: sheet.rows,
437
+ cell_width: sheet.cell_width,
438
+ cell_height: sheet.cell_height,
439
+ order: "row-major",
440
+ },
441
+ };
442
+ writeJson(join(dir, "context.json"), { schema: PAGE_REVIEW_PACK_SCHEMA, ...next });
443
+ return next;
444
+ }
445
+ /** Context ids present in the pack, in directory order. */
446
+ export function listPackContexts(packDir) {
447
+ const dir = packPaths(packDir).contextsDir;
448
+ if (!existsSync(dir))
449
+ return [];
450
+ return readdirSync(dir, { withFileTypes: true })
451
+ .filter((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, "context.json")))
452
+ .map((entry) => entry.name)
453
+ .sort();
454
+ }
455
+ export function readPackContext(packDir, contextId) {
456
+ const path = join(packPaths(packDir).contextDir(contextId), "context.json");
457
+ const { schema, ...record } = readJson(path);
458
+ if (schema !== PAGE_REVIEW_PACK_SCHEMA) {
459
+ throw new Error(`${path}: expected schema ${PAGE_REVIEW_PACK_SCHEMA}, found ${schema}`);
460
+ }
461
+ return record;
462
+ }
463
+ /**
464
+ * Load a context's tiles back as `CritiqueTile`s (PNG bytes read from disk
465
+ * and base64-encoded), verifying each file against its recorded digest so a
466
+ * judge never reviews a tile that was altered after capture.
467
+ */
468
+ export function readPackTiles(packDir, record) {
469
+ return record.tiles.map((tile) => {
470
+ const abs = join(packDir, tile.file);
471
+ const png = readFileSync(abs);
472
+ const digest = sha256Hex(png);
473
+ if (digest !== tile.sha256) {
474
+ throw new Error(`${abs}: tile digest ${digest} does not match recorded ${tile.sha256}`);
475
+ }
476
+ return {
477
+ id: tile.id,
478
+ ...(tile.scope !== undefined ? { scope: tile.scope } : {}),
479
+ index: tile.index,
480
+ label: tile.label,
481
+ scrollY: tile.scrollY,
482
+ x: tile.x,
483
+ width: tile.width,
484
+ height: tile.height,
485
+ pngBase64: png.toString("base64"),
486
+ };
487
+ });
488
+ }
489
+ export function readPackFullPage(packDir, record) {
490
+ return readFileSync(join(packDir, record.files.full_page));
491
+ }
492
+ export function readPackSignature(packDir, record) {
493
+ return readJson(join(packDir, record.files.signature));
494
+ }
495
+ /** Read a context's serialized DOM, inflating a gzip-compressed `dom.html.gz`
496
+ * and reading an older plain `dom.html` as-is. */
497
+ export function readPackDom(packDir, record) {
498
+ const path = join(packDir, record.files.dom);
499
+ const bytes = readFileSync(path);
500
+ return (path.endsWith(".gz") ? gunzipSync(bytes) : bytes).toString("utf8");
501
+ }
502
+ export function readPackManifest(packDir) {
503
+ const manifest = readJson(packPaths(packDir).manifest);
504
+ if (manifest.schema !== PAGE_REVIEW_PACK_SCHEMA) {
505
+ throw new Error(`${packPaths(packDir).manifest}: expected schema ${PAGE_REVIEW_PACK_SCHEMA}, found ${manifest.schema}`);
506
+ }
507
+ return manifest;
508
+ }
509
+ /** Read the bounded primary-tile plan used to prove delegated review coverage. */
510
+ export function readPackInspectionPlan(packDir) {
511
+ return readJson(packPaths(packDir).inspectionPlan);
512
+ }
513
+ // ---------------------------------------------------------------------------
514
+ // Findings, dispositions, verdict: the reviewer's half of the pack
515
+ // ---------------------------------------------------------------------------
516
+ export function readPackFindings(packDir) {
517
+ const path = packPaths(packDir).findings;
518
+ const doc = readJson(path);
519
+ if (doc.schema !== PAGE_REVIEW_FINDINGS_SCHEMA) {
520
+ throw new Error(`${path}: expected schema ${PAGE_REVIEW_FINDINGS_SCHEMA}, found ${String(doc.schema)}`);
521
+ }
522
+ return doc;
523
+ }
524
+ /** Write `findings.json` atomically: a temp file beside it, then a rename. */
525
+ export function writePackFindings(packDir, doc) {
526
+ const path = packPaths(packDir).findings;
527
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
528
+ writeFileSync(temp, `${JSON.stringify(doc, null, 2)}\n`);
529
+ renameSync(temp, path);
530
+ }
531
+ const FINDING_ID_PATTERN = /^[A-Z][A-Z0-9_-]*$/;
532
+ const DISPOSITION_TARGET_PATTERN = /^[^/#]+\/T[0-9]{3}#[0-9]+$/;
533
+ const REVIEW_TILE_PATTERN = /^[^/]+\/T[0-9]{3}$/;
534
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
535
+ function isRecord(value) {
536
+ return typeof value === "object" && value !== null && !Array.isArray(value);
537
+ }
538
+ /**
539
+ * Check a findings document against the rules `findingsSchemaDocument()`
540
+ * states, by hand so the toolkit tier carries no validator dependency.
541
+ * Returns every violation found; an empty array means the document is valid.
542
+ */
543
+ export function validateFindingsDocument(doc) {
544
+ const errors = [];
545
+ if (!isRecord(doc))
546
+ return ["findings document must be a JSON object"];
547
+ const topAllowed = new Set([
548
+ "schema",
549
+ "schema_path",
550
+ "target",
551
+ "reviewer",
552
+ "reviewed_at",
553
+ "delegated_reviews",
554
+ "findings",
555
+ "dispositions",
556
+ ]);
557
+ for (const key of Object.keys(doc)) {
558
+ if (!topAllowed.has(key))
559
+ errors.push(`unknown top-level key "${key}"`);
560
+ }
561
+ for (const key of [
562
+ "schema",
563
+ "target",
564
+ "reviewer",
565
+ "reviewed_at",
566
+ "delegated_reviews",
567
+ "findings",
568
+ ]) {
569
+ if (!(key in doc))
570
+ errors.push(`missing required key "${key}"`);
571
+ }
572
+ if (doc.schema !== undefined && doc.schema !== PAGE_REVIEW_FINDINGS_SCHEMA) {
573
+ errors.push(`schema must be ${PAGE_REVIEW_FINDINGS_SCHEMA}`);
574
+ }
575
+ if (doc.schema_path !== undefined && typeof doc.schema_path !== "string") {
576
+ errors.push("schema_path must be a string");
577
+ }
578
+ if (doc.target !== undefined && typeof doc.target !== "string") {
579
+ errors.push("target must be a string");
580
+ }
581
+ if (doc.reviewer !== undefined && doc.reviewer !== null && typeof doc.reviewer !== "string") {
582
+ errors.push("reviewer must be a string or null");
583
+ }
584
+ if (doc.reviewed_at !== undefined && doc.reviewed_at !== null) {
585
+ if (typeof doc.reviewed_at !== "string" || !DATE_TIME_PATTERN.test(doc.reviewed_at)) {
586
+ errors.push("reviewed_at must be an RFC 3339 date-time string or null");
587
+ }
588
+ }
589
+ if (doc.delegated_reviews !== undefined) {
590
+ if (!Array.isArray(doc.delegated_reviews)) {
591
+ errors.push("delegated_reviews must be an array");
592
+ }
593
+ else {
594
+ const allowed = new Set(["reviewer", "model", "assigned_tiles", "completed_tiles", "status"]);
595
+ doc.delegated_reviews.forEach((entry, index) => {
596
+ const at = `delegated_reviews[${index}]`;
597
+ if (!isRecord(entry)) {
598
+ errors.push(`${at}: must be an object`);
599
+ return;
600
+ }
601
+ for (const key of Object.keys(entry)) {
602
+ if (!allowed.has(key))
603
+ errors.push(`${at}: unknown key "${key}"`);
604
+ }
605
+ for (const key of ["reviewer", "model", "assigned_tiles", "completed_tiles", "status"]) {
606
+ if (!(key in entry))
607
+ errors.push(`${at}: missing required key "${key}"`);
608
+ }
609
+ if (entry.reviewer !== undefined &&
610
+ (typeof entry.reviewer !== "string" || entry.reviewer.trim().length === 0)) {
611
+ errors.push(`${at}: reviewer must be a non-empty string`);
612
+ }
613
+ if (entry.model !== undefined &&
614
+ !PAGE_REVIEW_SUBAGENT_MODELS.includes(String(entry.model))) {
615
+ errors.push(`${at}: model must be one of ${PAGE_REVIEW_SUBAGENT_MODELS.join(", ")}`);
616
+ }
617
+ for (const key of ["assigned_tiles", "completed_tiles"]) {
618
+ const value = entry[key];
619
+ if (!Array.isArray(value) || value.some((tile) => typeof tile !== "string")) {
620
+ errors.push(`${at}: ${key} must be an array of <context-id>/T012 strings`);
621
+ continue;
622
+ }
623
+ const invalid = value.filter((tile) => !REVIEW_TILE_PATTERN.test(tile));
624
+ if (invalid.length > 0) {
625
+ errors.push(`${at}: ${key} contains invalid tile(s): ${invalid.join(", ")}`);
626
+ }
627
+ if (new Set(value).size !== value.length) {
628
+ errors.push(`${at}: ${key} must not contain duplicates`);
629
+ }
630
+ }
631
+ if (entry.status !== undefined &&
632
+ entry.status !== "complete" &&
633
+ entry.status !== "incomplete") {
634
+ errors.push(`${at}: status must be complete or incomplete`);
635
+ }
636
+ if (Array.isArray(entry.assigned_tiles) && Array.isArray(entry.completed_tiles)) {
637
+ const assigned = new Set(entry.assigned_tiles);
638
+ const outside = entry.completed_tiles.filter((tile) => !assigned.has(tile));
639
+ if (outside.length > 0) {
640
+ errors.push(`${at}: completed_tiles not assigned: ${outside.join(", ")}`);
641
+ }
642
+ }
643
+ });
644
+ }
645
+ }
646
+ if (doc.findings !== undefined) {
647
+ if (!Array.isArray(doc.findings)) {
648
+ errors.push("findings must be an array");
649
+ }
650
+ else {
651
+ const allowed = new Set([
652
+ "id",
653
+ "severity",
654
+ "category",
655
+ "context_id",
656
+ "evidence",
657
+ "observation",
658
+ "recommendation",
659
+ "confidence",
660
+ ]);
661
+ const seenIds = new Set();
662
+ doc.findings.forEach((entry, index) => {
663
+ const at = `findings[${index}]`;
664
+ if (!isRecord(entry)) {
665
+ errors.push(`${at}: must be an object`);
666
+ return;
667
+ }
668
+ for (const key of Object.keys(entry)) {
669
+ if (!allowed.has(key))
670
+ errors.push(`${at}: unknown key "${key}"`);
671
+ }
672
+ for (const key of ["id", "severity", "category", "context_id", "evidence", "observation"]) {
673
+ if (!(key in entry))
674
+ errors.push(`${at}: missing required key "${key}"`);
675
+ }
676
+ if (entry.id !== undefined) {
677
+ if (typeof entry.id !== "string" || !FINDING_ID_PATTERN.test(entry.id)) {
678
+ errors.push(`${at}: id must match ${FINDING_ID_PATTERN.source}`);
679
+ }
680
+ else if (seenIds.has(entry.id)) {
681
+ errors.push(`${at}: duplicate id "${entry.id}"`);
682
+ }
683
+ else {
684
+ seenIds.add(entry.id);
685
+ }
686
+ }
687
+ if (entry.severity !== undefined &&
688
+ !PAGE_REVIEW_FINDING_SEVERITIES.includes(String(entry.severity))) {
689
+ errors.push(`${at}: severity must be one of ${PAGE_REVIEW_FINDING_SEVERITIES.join(", ")}`);
690
+ }
691
+ if (entry.category !== undefined &&
692
+ !PAGE_REVIEW_FINDING_CATEGORIES.includes(String(entry.category))) {
693
+ errors.push(`${at}: category must be one of ${PAGE_REVIEW_FINDING_CATEGORIES.join(", ")}`);
694
+ }
695
+ if (entry.context_id !== undefined && typeof entry.context_id !== "string") {
696
+ errors.push(`${at}: context_id must be a string`);
697
+ }
698
+ if (entry.evidence !== undefined) {
699
+ if (!Array.isArray(entry.evidence) || entry.evidence.length === 0) {
700
+ errors.push(`${at}: evidence must be a non-empty array of strings`);
701
+ }
702
+ else if (entry.evidence.some((e) => typeof e !== "string")) {
703
+ errors.push(`${at}: evidence entries must be strings`);
704
+ }
705
+ }
706
+ if (entry.observation !== undefined &&
707
+ (typeof entry.observation !== "string" || entry.observation.length === 0)) {
708
+ errors.push(`${at}: observation must be a non-empty string`);
709
+ }
710
+ if (entry.recommendation !== undefined && typeof entry.recommendation !== "string") {
711
+ errors.push(`${at}: recommendation must be a string`);
712
+ }
713
+ if (entry.confidence !== undefined) {
714
+ const c = entry.confidence;
715
+ if (typeof c !== "number" || !Number.isFinite(c) || c < 0 || c > 1) {
716
+ errors.push(`${at}: confidence must be a number between 0 and 1`);
717
+ }
718
+ }
719
+ });
720
+ }
721
+ }
722
+ if (doc.dispositions !== undefined) {
723
+ if (!Array.isArray(doc.dispositions)) {
724
+ errors.push("dispositions must be an array");
725
+ }
726
+ else {
727
+ const allowed = new Set(["target", "disposition", "note", "by", "at"]);
728
+ const seen = new Set();
729
+ doc.dispositions.forEach((entry, index) => {
730
+ const at = `dispositions[${index}]`;
731
+ if (!isRecord(entry)) {
732
+ errors.push(`${at}: must be an object`);
733
+ return;
734
+ }
735
+ for (const key of Object.keys(entry)) {
736
+ if (!allowed.has(key))
737
+ errors.push(`${at}: unknown key "${key}"`);
738
+ }
739
+ for (const key of ["target", "disposition"]) {
740
+ if (!(key in entry))
741
+ errors.push(`${at}: missing required key "${key}"`);
742
+ }
743
+ if (entry.target !== undefined) {
744
+ if (typeof entry.target !== "string" || !DISPOSITION_TARGET_PATTERN.test(entry.target)) {
745
+ errors.push(`${at}: target must look like <context-id>/T012#0`);
746
+ }
747
+ else if (seen.has(entry.target)) {
748
+ errors.push(`${at}: duplicate target "${entry.target}"`);
749
+ }
750
+ else {
751
+ seen.add(entry.target);
752
+ }
753
+ }
754
+ if (entry.disposition !== undefined &&
755
+ !PAGE_REVIEW_DISPOSITIONS.includes(String(entry.disposition))) {
756
+ errors.push(`${at}: disposition must be one of ${PAGE_REVIEW_DISPOSITIONS.join(", ")}`);
757
+ }
758
+ for (const key of ["note", "by"]) {
759
+ if (entry[key] !== undefined && typeof entry[key] !== "string") {
760
+ errors.push(`${at}: ${key} must be a string`);
761
+ }
762
+ }
763
+ if (entry.at !== undefined) {
764
+ if (typeof entry.at !== "string" || !DATE_TIME_PATTERN.test(entry.at)) {
765
+ errors.push(`${at}: at must be an RFC 3339 date-time string`);
766
+ }
767
+ }
768
+ });
769
+ }
770
+ }
771
+ return errors;
772
+ }
773
+ /** `<context-id>/<tile-id>#<n>` for the n-th finding of a tile in one context. */
774
+ export function dispositionTarget(contextId, tileId, n) {
775
+ return `${contextId}/${tileId}#${n}`;
776
+ }
777
+ /**
778
+ * Every machine finding in the critique, keyed by its disposition target.
779
+ * `n` counts a tile's findings in critique order, all severities included, so
780
+ * a reviewer can point at the second finding on T006 as `ctx/T006#1`.
781
+ */
782
+ export function machineFindingTargets(critique) {
783
+ const out = new Map();
784
+ if (!critique)
785
+ return out;
786
+ for (const row of critique) {
787
+ const perTile = new Map();
788
+ for (const finding of row.findings) {
789
+ const n = perTile.get(finding.tile_id) ?? 0;
790
+ perTile.set(finding.tile_id, n + 1);
791
+ out.set(dispositionTarget(row.context_id, finding.tile_id, n), {
792
+ ...finding,
793
+ context_id: row.context_id,
794
+ });
795
+ }
796
+ }
797
+ return out;
798
+ }
799
+ /**
800
+ * Combine the immutable machine critique with the reviewer's dispositions and
801
+ * findings into one reviewed outcome. A machine `high` counts against the page
802
+ * unless a reviewer dispositioned it `artifact`, `not-a-defect`, or
803
+ * `duplicate-of-gate`; `confirmed` and undispositioned highs count; so does a
804
+ * review-subagent finding at `critical` or `high`. Nothing here rewrites the
805
+ * critique: `evidence/critique.json` stays the machine record.
806
+ */
807
+ export function resolvePackVerdict(manifest, inspectionPlan, findings) {
808
+ const critique = manifest.critique;
809
+ const machine_outcome = critique === null || critique.length === 0
810
+ ? "skipped"
811
+ : critique.some((row) => row.outcome === "fail")
812
+ ? "fail"
813
+ : critique.some((row) => row.outcome === "incomplete")
814
+ ? "incomplete"
815
+ : critique.every((row) => row.outcome === "skipped")
816
+ ? "skipped"
817
+ : "pass";
818
+ const targets = machineFindingTargets(critique);
819
+ const byTarget = new Map();
820
+ const unmatched = [];
821
+ for (const entry of findings.dispositions ?? []) {
822
+ if (targets.has(entry.target))
823
+ byTarget.set(entry.target, entry);
824
+ else
825
+ unmatched.push(entry.target);
826
+ }
827
+ let high_total = 0;
828
+ let high_confirmed = 0;
829
+ let high_dismissed = 0;
830
+ let high_open = 0;
831
+ for (const [target, finding] of targets) {
832
+ if (finding.severity !== "high")
833
+ continue;
834
+ high_total += 1;
835
+ const disposition = byTarget.get(target)?.disposition;
836
+ if (disposition === undefined)
837
+ high_open += 1;
838
+ else if (disposition === "confirmed")
839
+ high_confirmed += 1;
840
+ else
841
+ high_dismissed += 1;
842
+ }
843
+ const reviewer_high = findings.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
844
+ const requiredTiles = new Set(inspectionPlan.contexts.flatMap((context) => context.primary_tiles.map((tile) => `${context.context_id}/${tile.id}`)));
845
+ const completedTiles = new Set(findings.delegated_reviews
846
+ .filter((review) => review.status === "complete")
847
+ .flatMap((review) => review.completed_tiles));
848
+ const uncovered_primary_tiles = [...requiredTiles]
849
+ .filter((tile) => !completedTiles.has(tile))
850
+ .sort();
851
+ const blocking = high_confirmed + high_open + reviewer_high;
852
+ const anyIncomplete = critique?.some((row) => row.outcome === "incomplete") ?? false;
853
+ const reviewed_outcome = blocking > 0
854
+ ? "fail"
855
+ : anyIncomplete || uncovered_primary_tiles.length > 0
856
+ ? "incomplete"
857
+ : machine_outcome === "skipped"
858
+ ? "skipped"
859
+ : "pass";
860
+ return {
861
+ machine_outcome,
862
+ reviewed_outcome,
863
+ high_total,
864
+ high_confirmed,
865
+ high_dismissed,
866
+ high_open,
867
+ reviewer_high,
868
+ dispositions_applied: byTarget.size,
869
+ unmatched_dispositions: unmatched,
870
+ primary_tiles_total: requiredTiles.size,
871
+ primary_tiles_reviewed: requiredTiles.size - uncovered_primary_tiles.length,
872
+ uncovered_primary_tiles,
873
+ };
874
+ }
875
+ /** The verdict a previous `review-pack verdict` wrote, or undefined when none exists. */
876
+ export function readPackVerdict(packDir) {
877
+ const path = packPaths(packDir).verdict;
878
+ if (!existsSync(path))
879
+ return undefined;
880
+ const doc = readJson(path);
881
+ return doc.schema === PAGE_REVIEW_VERDICT_SCHEMA ? doc : undefined;
882
+ }
883
+ export function findingsSchemaDocument() {
884
+ return {
885
+ $schema: "https://json-schema.org/draft/2020-12/schema",
886
+ $id: PAGE_REVIEW_FINDINGS_SCHEMA,
887
+ title: "Page review findings",
888
+ type: "object",
889
+ additionalProperties: false,
890
+ required: ["schema", "target", "reviewer", "reviewed_at", "delegated_reviews", "findings"],
891
+ properties: {
892
+ schema: { const: PAGE_REVIEW_FINDINGS_SCHEMA },
893
+ schema_path: { type: "string" },
894
+ target: { type: "string" },
895
+ reviewer: { type: ["string", "null"] },
896
+ reviewed_at: { type: ["string", "null"], format: "date-time" },
897
+ delegated_reviews: {
898
+ type: "array",
899
+ items: {
900
+ type: "object",
901
+ additionalProperties: false,
902
+ required: ["reviewer", "model", "assigned_tiles", "completed_tiles", "status"],
903
+ properties: {
904
+ reviewer: { type: "string", minLength: 1 },
905
+ model: { enum: [...PAGE_REVIEW_SUBAGENT_MODELS] },
906
+ assigned_tiles: {
907
+ type: "array",
908
+ uniqueItems: true,
909
+ items: { type: "string", pattern: "^[^/]+/T[0-9]{3}$" },
910
+ },
911
+ completed_tiles: {
912
+ type: "array",
913
+ uniqueItems: true,
914
+ items: { type: "string", pattern: "^[^/]+/T[0-9]{3}$" },
915
+ },
916
+ status: { enum: ["complete", "incomplete"] },
917
+ },
918
+ },
919
+ },
920
+ findings: {
921
+ type: "array",
922
+ items: {
923
+ type: "object",
924
+ additionalProperties: false,
925
+ required: ["id", "severity", "category", "context_id", "evidence", "observation"],
926
+ properties: {
927
+ id: { type: "string", pattern: "^[A-Z][A-Z0-9_-]*$" },
928
+ severity: { enum: ["critical", "high", "medium", "low", "info"] },
929
+ category: {
930
+ enum: [
931
+ "layout",
932
+ "typography",
933
+ "contrast",
934
+ "content",
935
+ "image",
936
+ "interaction",
937
+ "accessibility",
938
+ "responsiveness",
939
+ "render-artifact",
940
+ "coverage",
941
+ "other",
942
+ ],
943
+ },
944
+ context_id: { type: "string" },
945
+ /** Tile ids (`T012`) or pack-relative file paths. */
946
+ evidence: { type: "array", minItems: 1, items: { type: "string" } },
947
+ observation: { type: "string", minLength: 1 },
948
+ recommendation: { type: "string" },
949
+ confidence: { type: "number", minimum: 0, maximum: 1 },
950
+ },
951
+ },
952
+ },
953
+ /** Reviewer verdicts on machine findings in `evidence/critique.json`.
954
+ * `target` is `<context-id>/<tile-id>#<n>`, n = position of the finding
955
+ * among that tile's findings in the critique record (0-based). */
956
+ dispositions: {
957
+ type: "array",
958
+ items: {
959
+ type: "object",
960
+ additionalProperties: false,
961
+ required: ["target", "disposition"],
962
+ properties: {
963
+ target: { type: "string", pattern: "^[^/#]+/T[0-9]{3}#[0-9]+$" },
964
+ disposition: { enum: [...PAGE_REVIEW_DISPOSITIONS] },
965
+ note: { type: "string" },
966
+ by: { type: "string" },
967
+ at: { type: "string", format: "date-time" },
968
+ },
969
+ },
970
+ },
971
+ },
972
+ };
973
+ }
974
+ /** Most hits one envelope contributes; a runaway sweep must not bloat the pack. */
975
+ export const GATE_HITS_PER_ENVELOPE = 50;
976
+ function asRect(value) {
977
+ if (!value || typeof value !== "object")
978
+ return undefined;
979
+ const r = value;
980
+ const x = r.x;
981
+ const y = r.y;
982
+ const width = r.width;
983
+ const height = r.height;
984
+ if (typeof x !== "number" ||
985
+ typeof y !== "number" ||
986
+ typeof width !== "number" ||
987
+ typeof height !== "number" ||
988
+ ![x, y, width, height].every(Number.isFinite)) {
989
+ return undefined;
990
+ }
991
+ return { x, y, width, height };
992
+ }
993
+ function unionRect(a, b) {
994
+ const x = Math.min(a.x, b.x);
995
+ const y = Math.min(a.y, b.y);
996
+ return {
997
+ x,
998
+ y,
999
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
1000
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
1001
+ };
1002
+ }
1003
+ function asArray(value) {
1004
+ return Array.isArray(value)
1005
+ ? value.filter((v) => Boolean(v) && typeof v === "object")
1006
+ : [];
1007
+ }
1008
+ function str(value, fallback = "?") {
1009
+ return typeof value === "string" && value.length > 0 ? value : fallback;
1010
+ }
1011
+ /**
1012
+ * Pull every rectangle-bearing finding out of a `browse` JSON envelope so the
1013
+ * inspection plan can point at the tiles that show it. Covers the checks
1014
+ * whose results carry a document-space rect: runts, truncation, contrast,
1015
+ * placeholder, image, clip, overlap, crowd, align, gap, overflow, and
1016
+ * target-size (`hit`). Anything without a rect is skipped; the gate's
1017
+ * `failures` lines still describe it. Capped at `GATE_HITS_PER_ENVELOPE`.
1018
+ */
1019
+ export function gateHitsFromEnvelope(envelope) {
1020
+ if (!envelope)
1021
+ return [];
1022
+ const hits = [];
1023
+ const push = (rule, label, rect) => {
1024
+ if (rect && hits.length < GATE_HITS_PER_ENVELOPE)
1025
+ hits.push({ rule, label, rect });
1026
+ };
1027
+ const runts = envelope.runts;
1028
+ for (const hit of asArray(runts?.runts)) {
1029
+ push("runts", `${str(hit.block)}: "${str(hit.word, "")}"`, asRect(hit.rect));
1030
+ }
1031
+ const truncation = envelope.truncation;
1032
+ for (const hit of asArray(truncation?.hits)) {
1033
+ push("truncation", `${str(hit.label)}: ${str(hit.how)} on ${str(hit.axis)}, +${String(hit.overflowPx ?? "?")}px`, asRect(hit.rect));
1034
+ }
1035
+ const contrast = envelope.contrast;
1036
+ for (const hit of asArray(contrast?.hits)) {
1037
+ push("contrast", `${str(hit.label)}: ${String(hit.ratio ?? "?")}:1 < ${String(hit.required ?? "?")}`, asRect(hit.rect));
1038
+ }
1039
+ const placeholder = envelope.placeholder;
1040
+ for (const hit of asArray(placeholder?.hits)) {
1041
+ push("placeholder", `${str(hit.label)}: ${str(hit.kind)} ${str(hit.token, "")}`, asRect(hit.rect));
1042
+ }
1043
+ const image = envelope.image;
1044
+ for (const hit of asArray(image?.issues)) {
1045
+ push("image", `${str(hit.label)}: ${str(hit.reason)}`, asRect(hit.rect));
1046
+ }
1047
+ for (const result of asArray(envelope.clip)) {
1048
+ for (const issue of asArray(result.issues)) {
1049
+ const element = issue.element;
1050
+ push("clip", `${str(element?.label)} clipped by ${str(issue.clippedBy)}, ${String(issue.maxOverrunPx ?? "?")}px`, asRect(element?.rect));
1051
+ }
1052
+ }
1053
+ for (const result of asArray(envelope.overlap)) {
1054
+ for (const issue of asArray(result.issues)) {
1055
+ const first = issue.first;
1056
+ const second = issue.second;
1057
+ push("overlap", `${str(first?.label)} × ${str(second?.label)}, ${String(issue.areaPx ?? "?")}px²`, asRect(issue.intersection));
1058
+ }
1059
+ }
1060
+ for (const result of asArray(envelope.crowd)) {
1061
+ for (const issue of asArray(result.issues)) {
1062
+ const before = asRect(issue.before?.rect);
1063
+ const after = asRect(issue.after?.rect);
1064
+ const rect = before && after ? unionRect(before, after) : (before ?? after);
1065
+ push("crowd", `${str(issue.before?.label)} / ${str(issue.after?.label)}: ${String(issue.separationPx ?? "?")}px apart`, rect);
1066
+ }
1067
+ }
1068
+ for (const result of asArray(envelope.align)) {
1069
+ for (const cluster of asArray(result.clusters)) {
1070
+ for (const child of asArray(cluster.children)) {
1071
+ if (child.fail !== true)
1072
+ continue;
1073
+ push("align", `${str(child.label)}: off by ${String(child.deltaPx ?? "?")}px`, asRect(child.rect));
1074
+ }
1075
+ }
1076
+ }
1077
+ for (const result of asArray(envelope.gap)) {
1078
+ for (const cluster of asArray(result.clusters)) {
1079
+ for (const pair of asArray(cluster.pairs)) {
1080
+ if (pair.fail !== true)
1081
+ continue;
1082
+ const before = pair.before;
1083
+ const after = pair.after;
1084
+ const a = asRect(before?.rect);
1085
+ const b = asRect(after?.rect);
1086
+ push("gap", `${str(before?.label)} / ${str(after?.label)}: ${String(pair.observedGapPx ?? "?")}px`, a && b ? unionRect(a, b) : (a ?? b));
1087
+ }
1088
+ }
1089
+ }
1090
+ const overflow = envelope.overflow;
1091
+ for (const key of ["widerThanViewport", "rightOverflow"]) {
1092
+ for (const element of asArray(overflow?.[key])) {
1093
+ const tag = str(element.tagName, "element").toLowerCase();
1094
+ const id = str(element.id, "");
1095
+ const cls = str(element.className, "").trim().split(/\s+/)[0] ?? "";
1096
+ const label = id ? `${tag}#${id}` : cls ? `${tag}.${cls}` : tag;
1097
+ const px = key === "widerThanViewport" ? element.widthOverflowPx : element.rightOverflowPx;
1098
+ push("overflow", `${label}: +${String(px ?? "?")}px past the viewport`, asRect(element.rect));
1099
+ }
1100
+ }
1101
+ for (const result of asArray(envelope.hit)) {
1102
+ for (const node of asArray(result.nodes)) {
1103
+ if (node.outcome !== "fail")
1104
+ continue;
1105
+ const target = Array.isArray(node.target) ? node.target.map(String).join(" ") : "target";
1106
+ push("hit", `${target}: ${str(node.message, "below the minimum target size")}`, asRect(node.rect));
1107
+ }
1108
+ }
1109
+ return hits;
1110
+ }
1111
+ /** Tile ids whose rect intersects `rect` (both in document-space px). */
1112
+ export function tilesCoveringRect(tiles, rect) {
1113
+ const x1 = rect.x + Math.max(rect.width, 1);
1114
+ const y1 = rect.y + Math.max(rect.height, 1);
1115
+ return tiles
1116
+ .filter((tile) => tile.x < x1 &&
1117
+ tile.x + tile.width > rect.x &&
1118
+ tile.scrollY < y1 &&
1119
+ tile.scrollY + tile.height > rect.y)
1120
+ .map((tile) => tile.id);
1121
+ }
1122
+ export function buildInspectionPlan(contexts, critique, gates) {
1123
+ return {
1124
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1125
+ purpose: "Bound a complete review to representative tiles instead of every PNG. Open primary tiles; treat the rest as drill-down for a named question.",
1126
+ contexts: contexts.map((ctx) => {
1127
+ const reasons = new Map();
1128
+ const add = (id, reason) => {
1129
+ const list = reasons.get(id) ?? [];
1130
+ list.push(reason);
1131
+ reasons.set(id, list);
1132
+ };
1133
+ // Hit bands sit past the cap, so they never stand in for the page bottom.
1134
+ const bands = ctx.tiles.filter((tile) => tile.scope === undefined && tile.kind !== "hit-band");
1135
+ const first = bands[0] ?? ctx.tiles[0];
1136
+ const last = bands[bands.length - 1] ?? ctx.tiles[ctx.tiles.length - 1];
1137
+ if (first)
1138
+ add(first.id, "page top: header, navigation, first fold");
1139
+ if (last && last.id !== first?.id)
1140
+ add(last.id, "page bottom: footer and final call to action");
1141
+ for (const tile of ctx.tiles) {
1142
+ if (tile.scope !== undefined)
1143
+ add(tile.id, `scoped tile for ${tile.scope}`);
1144
+ if (tile.kind === "hit-band")
1145
+ add(tile.id, "hit band: captured past the tile cap for a gate hit");
1146
+ }
1147
+ const row = critique?.find((entry) => entry.context_id === ctx.id);
1148
+ for (const finding of row?.findings ?? []) {
1149
+ add(finding.tile_id, `machine finding (${finding.severity}): ${finding.category}`);
1150
+ }
1151
+ const gateHits = [];
1152
+ for (const gate of gates ?? []) {
1153
+ if (gate.context_id !== ctx.id)
1154
+ continue;
1155
+ for (const hit of gate.hits ?? []) {
1156
+ const tiles = tilesCoveringRect(ctx.tiles, hit.rect);
1157
+ gateHits.push({ ...hit, check_id: gate.check_id, tiles });
1158
+ for (const id of tiles)
1159
+ add(id, `gate hit (${hit.rule}): ${hit.label}`);
1160
+ }
1161
+ }
1162
+ const byId = new Map(ctx.tiles.map((tile) => [tile.id, tile]));
1163
+ const primary = [...reasons.entries()]
1164
+ .map(([id, list]) => ({ id, file: byId.get(id)?.file ?? "", reason: list.join("; ") }))
1165
+ .filter((entry) => entry.file.length > 0)
1166
+ .sort((a, b) => a.id.localeCompare(b.id));
1167
+ return {
1168
+ context_id: ctx.id,
1169
+ full_page: ctx.files.full_page,
1170
+ primary_tiles: primary,
1171
+ drilldown_tiles: Math.max(0, ctx.tiles.length - primary.length),
1172
+ gate_hits: gateHits,
1173
+ };
1174
+ }),
1175
+ };
1176
+ }
1177
+ function walkFiles(root, dir, out) {
1178
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1179
+ const abs = join(dir, entry.name);
1180
+ if (entry.isDirectory())
1181
+ walkFiles(root, abs, out);
1182
+ else if (entry.isFile()) {
1183
+ out.push({
1184
+ path: relative(root, abs).split("\\").join("/"),
1185
+ bytes: readFileSync(abs).byteLength,
1186
+ });
1187
+ }
1188
+ }
1189
+ }
1190
+ /**
1191
+ * Write the pack's navigation layer: manifest, evidence files, the findings
1192
+ * skeleton and schema (never overwriting a findings file a reviewer already
1193
+ * filled), and `review.md`. Safe to call twice: once after capture, again
1194
+ * after the judge so machine findings land in the plan and the review.
1195
+ */
1196
+ export function finalizePageReviewPack(input) {
1197
+ const paths = packPaths(input.packDir);
1198
+ mkdirSync(paths.evidenceDir, { recursive: true });
1199
+ const rel = (abs) => relative(input.packDir, abs).split("\\").join("/");
1200
+ const createdAt = input.createdAt ?? new Date().toISOString();
1201
+ const critique = input.critique ?? null;
1202
+ const gates = input.gates ?? [];
1203
+ const notChecked = [...(input.not_checked ?? [])];
1204
+ const warnings = [...(input.warnings ?? [])];
1205
+ if (critique === null) {
1206
+ notChecked.push({
1207
+ check: "machine vision critique",
1208
+ reason: input.judgeCommand
1209
+ ? `The judge stage has not run for this pack. Run: ${input.judgeCommand}`
1210
+ : "The judge stage has not run for this pack.",
1211
+ });
1212
+ }
1213
+ else if (critique.some((row) => row.outcome === "skipped")) {
1214
+ notChecked.push({
1215
+ check: "machine vision critique",
1216
+ reason: "The judge ran without a vision provider (or the provider never answered) for at least one context; review those tiles directly.",
1217
+ });
1218
+ }
1219
+ for (const ctx of input.contexts) {
1220
+ if (ctx.coverage.capped) {
1221
+ warnings.push(`${ctx.id}: tile cap reached, ${ctx.coverage.bands_reviewed} of ${ctx.coverage.bands_total} bands ` +
1222
+ `(${ctx.coverage.reviewed_height_px} of ${ctx.coverage.page_height_px} px) are in the pack; the rest of the page is unreviewed.`);
1223
+ }
1224
+ if (ctx.hit_bands !== undefined && ctx.hit_bands > 0) {
1225
+ warnings.push(`${ctx.id}: ${ctx.hit_bands} band(s) below the tile cap were captured because gate hits land in them.`);
1226
+ }
1227
+ }
1228
+ notChecked.push({
1229
+ check: "interactive behavior",
1230
+ reason: "The pack holds frozen states only. Hover, focus, open menus, and scroll-triggered motion are not in it unless captured as named states.",
1231
+ });
1232
+ notChecked.push({
1233
+ check: "external copy facts",
1234
+ reason: "The pack shows rendered text. It does not verify prices, dates, names, or claims against an outside source.",
1235
+ });
1236
+ // A context captured by an older writer, or one whose sheet was removed,
1237
+ // gets its contact sheet here so every finalized pack carries one per
1238
+ // context with tiles. Failure is a warning, never a lost pack.
1239
+ const contexts = input.contexts.map((ctx) => {
1240
+ if (ctx.tiles.length === 0)
1241
+ return ctx;
1242
+ if (ctx.contact_sheet && existsSync(join(input.packDir, ctx.contact_sheet.file)))
1243
+ return ctx;
1244
+ try {
1245
+ return writePackContactSheet(input.packDir, ctx);
1246
+ }
1247
+ catch (err) {
1248
+ warnings.push(`${ctx.id}: contact sheet not written (${err instanceof Error ? err.message : String(err)})`);
1249
+ return ctx;
1250
+ }
1251
+ });
1252
+ const plan = buildInspectionPlan(contexts, critique, gates);
1253
+ writeJson(paths.inspectionPlan, plan);
1254
+ writeJson(paths.coverage, {
1255
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1256
+ contexts: contexts.map((ctx) => ({
1257
+ context_id: ctx.id,
1258
+ page_height_px: ctx.coverage.page_height_px,
1259
+ reviewed_height_px: ctx.coverage.reviewed_height_px,
1260
+ bands_total: ctx.coverage.bands_total,
1261
+ bands_reviewed: ctx.coverage.bands_reviewed,
1262
+ capped: ctx.coverage.capped,
1263
+ tiles: ctx.tiles.length,
1264
+ scopes: ctx.scopes,
1265
+ })),
1266
+ warnings,
1267
+ not_checked: notChecked,
1268
+ });
1269
+ if (critique !== null) {
1270
+ writeJson(paths.critique, {
1271
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1272
+ ...(input.pool ? { pool: input.pool } : {}),
1273
+ contexts: critique,
1274
+ });
1275
+ }
1276
+ writeJson(paths.findingsSchema, findingsSchemaDocument());
1277
+ if (!existsSync(paths.findings)) {
1278
+ writeJson(paths.findings, {
1279
+ schema: PAGE_REVIEW_FINDINGS_SCHEMA,
1280
+ schema_path: PAGE_REVIEW_FINDINGS_SCHEMA_FILENAME,
1281
+ target: input.target,
1282
+ reviewer: null,
1283
+ reviewed_at: null,
1284
+ delegated_reviews: [],
1285
+ findings: [],
1286
+ dispositions: [],
1287
+ });
1288
+ }
1289
+ // Retention: what the caller says, else what the manifest already carries.
1290
+ let retention = input.retention;
1291
+ if (!retention && existsSync(paths.manifest)) {
1292
+ try {
1293
+ const prior = JSON.parse(readFileSync(paths.manifest, "utf8"));
1294
+ if (prior.retention)
1295
+ retention = prior.retention;
1296
+ }
1297
+ catch {
1298
+ // A malformed prior manifest is rewritten below; retention starts absent.
1299
+ }
1300
+ }
1301
+ const manifest = {
1302
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1303
+ created_at: createdAt,
1304
+ target: input.target,
1305
+ ...(input.tested_revision !== undefined ? { tested_revision: input.tested_revision } : {}),
1306
+ tool: input.tool ?? { name: "harnery" },
1307
+ contexts,
1308
+ gates,
1309
+ critique,
1310
+ ...(input.pool ? { pool: input.pool } : {}),
1311
+ not_checked: notChecked,
1312
+ warnings,
1313
+ ...(retention ? { retention } : {}),
1314
+ files: {
1315
+ review: rel(paths.review),
1316
+ findings: rel(paths.findings),
1317
+ findings_schema: rel(paths.findingsSchema),
1318
+ inspection_plan: rel(paths.inspectionPlan),
1319
+ coverage: rel(paths.coverage),
1320
+ index: rel(paths.index),
1321
+ inventory: rel(paths.inventory),
1322
+ critique: rel(paths.critique),
1323
+ },
1324
+ };
1325
+ writeJson(paths.manifest, manifest);
1326
+ writeJson(paths.index, {
1327
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1328
+ purpose: "Small map for agents that should not have to probe the manifest before finding evidence.",
1329
+ target: input.target,
1330
+ counts: {
1331
+ contexts: contexts.length,
1332
+ tiles: contexts.reduce((sum, ctx) => sum + ctx.tiles.length, 0),
1333
+ machine_findings: critique?.reduce((sum, row) => sum + row.findings.length, 0) ?? null,
1334
+ },
1335
+ start_here: {
1336
+ review: manifest.files.review,
1337
+ inspection_plan: manifest.files.inspection_plan,
1338
+ coverage: manifest.files.coverage,
1339
+ critique: critique !== null ? manifest.files.critique : null,
1340
+ findings: manifest.files.findings,
1341
+ contexts: Object.fromEntries(contexts.map((ctx) => [ctx.id, ctx.files.context])),
1342
+ contact_sheets: Object.fromEntries(contexts.flatMap((ctx) => (ctx.contact_sheet ? [[ctx.id, ctx.contact_sheet.file]] : []))),
1343
+ },
1344
+ });
1345
+ // A verdict written by `review-pack verdict` survives every refinalize so
1346
+ // review.md keeps its "Reviewed outcome" section; the section itself notes
1347
+ // when the verdict predates the current critique.
1348
+ let verdict;
1349
+ try {
1350
+ verdict = readPackVerdict(input.packDir);
1351
+ }
1352
+ catch {
1353
+ verdict = undefined;
1354
+ }
1355
+ writeFileSync(paths.review, renderReviewMarkdown(manifest, plan, input.judgeCommand, verdict));
1356
+ const inventory = [];
1357
+ walkFiles(input.packDir, input.packDir, inventory);
1358
+ writeJson(paths.inventory, {
1359
+ schema: PAGE_REVIEW_PACK_SCHEMA,
1360
+ files: inventory.sort((a, b) => a.path.localeCompare(b.path)),
1361
+ });
1362
+ // The manifest is rewritten once with the pack's size so `list` and the
1363
+ // run result can report it without walking the tree again.
1364
+ manifest.size_bytes = inventory.reduce((sum, file) => sum + file.bytes, 0);
1365
+ writeJson(paths.manifest, manifest);
1366
+ return { manifest: paths.manifest, review: paths.review };
1367
+ }
1368
+ function severityCounts(findings) {
1369
+ const high = findings.filter((f) => f.severity === "high").length;
1370
+ const medium = findings.filter((f) => f.severity === "medium").length;
1371
+ const low = findings.filter((f) => f.severity === "low").length;
1372
+ return `${high} high / ${medium} medium / ${low} low`;
1373
+ }
1374
+ export function renderReviewMarkdown(manifest, plan, judgeCommand, verdict) {
1375
+ const lines = [];
1376
+ const contexts = manifest.contexts;
1377
+ lines.push(`# Page review: ${manifest.target}`);
1378
+ lines.push("");
1379
+ lines.push(`Captured ${manifest.created_at}. ${contexts.length} rendering context(s).`);
1380
+ if (manifest.tested_revision)
1381
+ lines.push(`Tested revision: \`${manifest.tested_revision}\`.`);
1382
+ lines.push("");
1383
+ lines.push("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.");
1384
+ lines.push("");
1385
+ lines.push("## Delegated review protocol");
1386
+ lines.push("");
1387
+ lines.push("1. The coordinating agent reads the context table and coverage below. A capped context has unreviewed page below its last tile.");
1388
+ lines.push("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.");
1389
+ lines.push("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.");
1390
+ lines.push("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.");
1391
+ lines.push("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.");
1392
+ lines.push("6. Review subagents read `not checked`. Do not invent evidence for anything listed there; record the gap in their report instead.");
1393
+ lines.push("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.");
1394
+ lines.push("");
1395
+ lines.push("## Contexts");
1396
+ lines.push("");
1397
+ lines.push("| Context | Viewport | Theme | State | Page (w×h px) | Tiles | Coverage | Contacts | Full page |");
1398
+ lines.push("|---|---|---|---|---|---|---|---|---|");
1399
+ for (const ctx of contexts) {
1400
+ const cov = ctx.coverage.capped
1401
+ ? `${ctx.coverage.bands_reviewed}/${ctx.coverage.bands_total} bands, capped`
1402
+ : "complete";
1403
+ const contacts = ctx.contact_sheet
1404
+ ? `[${CONTACT_SHEET_FILENAME}](${ctx.contact_sheet.file})`
1405
+ : "none";
1406
+ lines.push(`| ${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}) |`);
1407
+ }
1408
+ lines.push("");
1409
+ lines.push("## Coverage and warnings");
1410
+ lines.push("");
1411
+ if (manifest.warnings.length === 0)
1412
+ lines.push("- No warnings from the capture.");
1413
+ for (const warning of manifest.warnings)
1414
+ lines.push(`- ${warning}`);
1415
+ lines.push("");
1416
+ lines.push("## Not checked");
1417
+ lines.push("");
1418
+ for (const entry of manifest.not_checked)
1419
+ lines.push(`- **${entry.check}:** ${entry.reason}`);
1420
+ lines.push("");
1421
+ lines.push("## Deterministic gates");
1422
+ lines.push("");
1423
+ if (manifest.gates.length === 0) {
1424
+ lines.push("- No gate results were recorded in this pack.");
1425
+ }
1426
+ else {
1427
+ const shown = 20;
1428
+ for (const gate of manifest.gates) {
1429
+ const detail = gate.failures.length > 0 ? `: ${gate.failures.join("; ")}` : "";
1430
+ lines.push(`- ${gate.context_id} · ${gate.check_id} · **${gate.outcome}**${detail}`);
1431
+ const ctx = contexts.find((c) => c.id === gate.context_id);
1432
+ const planned = plan.contexts.find((c) => c.context_id === gate.context_id);
1433
+ const hits = planned?.gate_hits.filter((hit) => hit.check_id === gate.check_id) ?? [];
1434
+ for (const hit of hits.slice(0, shown)) {
1435
+ const r = hit.rect;
1436
+ const where = `(${Math.round(r.x)}, ${Math.round(r.y)}) ${Math.round(r.width)}×${Math.round(r.height)} px`;
1437
+ const tiles = hit.tiles.length > 0
1438
+ ? hit.tiles
1439
+ .map((id) => {
1440
+ const tile = ctx?.tiles.find((t) => t.id === id);
1441
+ return tile ? `[${id}](${tile.file})` : id;
1442
+ })
1443
+ .join(", ")
1444
+ : "no tile covers this rect (outside the reviewed page area)";
1445
+ lines.push(` - ${hit.rule} · ${hit.label} · at ${where} → ${tiles}`);
1446
+ }
1447
+ if (hits.length > shown) {
1448
+ lines.push(` - ${hits.length - shown} more hit(s) with rectangles in \`evidence/inspection-plan.json\``);
1449
+ }
1450
+ }
1451
+ }
1452
+ lines.push("");
1453
+ lines.push("## Machine findings");
1454
+ lines.push("");
1455
+ if (manifest.critique === null) {
1456
+ lines.push(judgeCommand
1457
+ ? `The judge stage has not run. Run \`${judgeCommand}\` to add machine findings, then dispatch review subagents over the primary tiles.`
1458
+ : "The judge stage has not run. Dispatch review subagents over the primary tiles.");
1459
+ }
1460
+ else {
1461
+ for (const row of manifest.critique) {
1462
+ const reused = row.tiles_reused > 0 ? `, ${row.tiles_reused} reused from the baseline` : "";
1463
+ lines.push(`### ${row.context_id} · ${row.outcome.toUpperCase()} · ${row.tiles_reviewed} of ${row.tiles_total} tiles judged${reused} · ${severityCounts(row.findings)}`);
1464
+ lines.push("");
1465
+ if (row.error)
1466
+ lines.push(`- Error: ${row.error}`);
1467
+ if (row.findings.length === 0)
1468
+ lines.push("- No machine findings.");
1469
+ for (const finding of row.findings) {
1470
+ const ctx = contexts.find((c) => c.id === row.context_id);
1471
+ const tile = ctx?.tiles.find((t) => t.id === finding.tile_id);
1472
+ const link = tile ? `[${finding.tile_id}](${tile.file})` : finding.tile_id;
1473
+ lines.push(`- ${link} · **${finding.severity}** · ${finding.category}: ${finding.description}`);
1474
+ }
1475
+ lines.push("");
1476
+ }
1477
+ }
1478
+ if (verdict) {
1479
+ lines.push("## Reviewed outcome");
1480
+ lines.push("");
1481
+ lines.push(`**${verdict.reviewed_outcome.toUpperCase()}** (machine outcome ${verdict.machine_outcome}), recorded ${verdict.reviewed_at} in \`evidence/${PAGE_REVIEW_VERDICT_FILENAME}\`.`);
1482
+ lines.push("");
1483
+ lines.push(`- 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.`);
1484
+ lines.push(`- Review-subagent findings at critical or high: ${verdict.reviewer_high}. Dispositions applied: ${verdict.dispositions_applied}.`);
1485
+ lines.push(`- Delegated primary-tile coverage: ${verdict.primary_tiles_reviewed}/${verdict.primary_tiles_total}.`);
1486
+ if (verdict.uncovered_primary_tiles.length > 0) {
1487
+ lines.push(`- Uncovered primary tiles: ${verdict.uncovered_primary_tiles.join(", ")}.`);
1488
+ }
1489
+ if (verdict.unmatched_dispositions.length > 0) {
1490
+ lines.push(`- Dispositions naming no machine finding (ignored): ${verdict.unmatched_dispositions.join(", ")}.`);
1491
+ }
1492
+ lines.push("- 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.");
1493
+ lines.push("");
1494
+ }
1495
+ lines.push("## Inspection plan");
1496
+ lines.push("");
1497
+ for (const ctx of plan.contexts) {
1498
+ lines.push(`### ${ctx.context_id} · ${ctx.primary_tiles.length} primary tile(s), ${ctx.drilldown_tiles} drill-down`);
1499
+ lines.push("");
1500
+ for (const tile of ctx.primary_tiles) {
1501
+ lines.push(`- [${tile.id}](${tile.file}) · ${tile.reason}`);
1502
+ }
1503
+ lines.push("");
1504
+ }
1505
+ lines.push("## Tile index");
1506
+ lines.push("");
1507
+ for (const ctx of contexts) {
1508
+ lines.push(`### ${ctx.id}`);
1509
+ lines.push("");
1510
+ if (ctx.contact_sheet) {
1511
+ lines.push(`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.`);
1512
+ lines.push("");
1513
+ }
1514
+ lines.push("| Tile | Label | Kind | Scope | y (px) | Size (px) | File |");
1515
+ lines.push("|---|---|---|---|---|---|---|");
1516
+ for (const tile of ctx.tiles) {
1517
+ const kind = tile.kind ?? (tile.scope !== undefined ? "scope" : "band");
1518
+ lines.push(`| ${tile.id} | ${tile.label} | ${kind} | ${tile.scope ?? "full page"} | ${tile.scrollY} | ${tile.width}×${tile.height} | [${tile.id}.png](${tile.file}) |`);
1519
+ }
1520
+ lines.push("");
1521
+ if (ctx.expanded && ctx.expanded.length > 0) {
1522
+ lines.push("Expanded tiles (the same region re-rendered at a higher device scale factor; the source tile above is unchanged):");
1523
+ lines.push("");
1524
+ for (const entry of ctx.expanded) {
1525
+ lines.push(`- ${entry.tile} at ${entry.dpr}× · ${entry.width}×${entry.height} px · [${expandedTileFilename(entry.tile, entry.dpr)}](${entry.file})`);
1526
+ }
1527
+ lines.push("");
1528
+ }
1529
+ }
1530
+ lines.push("## Write findings");
1531
+ lines.push("");
1532
+ lines.push("`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.");
1533
+ lines.push("");
1534
+ lines.push("## Files");
1535
+ lines.push("");
1536
+ lines.push(`- [Manifest](${PAGE_REVIEW_MANIFEST_FILENAME}) · [Inspection plan](${manifest.files.inspection_plan}) · [Coverage](${manifest.files.coverage}) · [Index](${manifest.files.index}) · [Inventory](${manifest.files.inventory})`);
1537
+ if (manifest.critique !== null)
1538
+ lines.push(`- [Machine critique](${manifest.files.critique})`);
1539
+ lines.push("");
1540
+ return `${lines.join("\n")}\n`;
1541
+ }
1542
+ // ---------------------------------------------------------------------------
1543
+ // Expiry: find packs, delete the expired ones, leave a stub behind
1544
+ // ---------------------------------------------------------------------------
1545
+ /** Schema of the stub left behind when an expired pack is deleted. */
1546
+ export const PAGE_REVIEW_EXPIRED_SCHEMA = "harnery-page-review-expired/v1";
1547
+ /** The artifact store's per-workspace manifest. A standalone review-pack
1548
+ * workspace IS the pack, so deletion keeps this one file beside the stub.
1549
+ * Named here rather than imported: this toolkit module must not reach into
1550
+ * `src/core`. */
1551
+ const ARTIFACT_WORKSPACE_MANIFEST_FILENAME = ".harnery-artifact.json";
1552
+ /** The manifest at `dir`, or null when `dir` is not a page review pack. A
1553
+ * malformed or foreign `manifest.json` is not a pack. */
1554
+ function readPackManifestIfPack(dir) {
1555
+ const manifestPath = join(dir, PAGE_REVIEW_MANIFEST_FILENAME);
1556
+ if (!existsSync(manifestPath))
1557
+ return null;
1558
+ try {
1559
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1560
+ if (manifest.schema !== PAGE_REVIEW_PACK_SCHEMA)
1561
+ return null;
1562
+ if (typeof manifest.target !== "string" || typeof manifest.created_at !== "string")
1563
+ return null;
1564
+ return manifest;
1565
+ }
1566
+ catch {
1567
+ return null;
1568
+ }
1569
+ }
1570
+ function listChildDirs(dir) {
1571
+ try {
1572
+ return readdirSync(dir, { withFileTypes: true })
1573
+ .filter((entry) => entry.isDirectory())
1574
+ .map((entry) => join(dir, entry.name))
1575
+ .sort();
1576
+ }
1577
+ catch {
1578
+ return [];
1579
+ }
1580
+ }
1581
+ /** A workspace and the qa-run packs beneath it (`run-<id>/pack`). */
1582
+ function packCandidatesUnder(workspace) {
1583
+ const out = [workspace];
1584
+ for (const runDir of listChildDirs(workspace)) {
1585
+ if (!runDir.split(/[\\/]/).pop()?.startsWith("run-"))
1586
+ continue;
1587
+ out.push(join(runDir, PAGE_REVIEW_PACK_DIRNAME));
1588
+ }
1589
+ return out;
1590
+ }
1591
+ /**
1592
+ * Every page review pack under the given roots. A pack is a directory whose
1593
+ * `manifest.json` carries the pack schema. Each root is searched as: the root
1594
+ * itself, each immediate child workspace, and `run-<id>/pack` under the root and
1595
+ * under each workspace (the layout of the managed artifact store, where a
1596
+ * `review-pack` workspace is itself the pack and a `qa-run` workspace holds
1597
+ * one pack per run). Nothing deeper is walked.
1598
+ */
1599
+ export function findPageReviewPacks(roots) {
1600
+ const found = new Set();
1601
+ for (const rawRoot of roots) {
1602
+ const root = resolve(rawRoot);
1603
+ if (!existsSync(root))
1604
+ continue;
1605
+ const candidates = [
1606
+ ...packCandidatesUnder(root),
1607
+ ...listChildDirs(root).flatMap((workspace) => packCandidatesUnder(workspace)),
1608
+ ];
1609
+ for (const candidate of candidates) {
1610
+ if (readPackManifestIfPack(candidate))
1611
+ found.add(candidate);
1612
+ }
1613
+ }
1614
+ return [...found].sort();
1615
+ }
1616
+ function packRow(dir, manifest, nowMs) {
1617
+ const expiresAt = manifest.retention?.expires_at ?? null;
1618
+ const expiresMs = expiresAt === null ? Number.NaN : Date.parse(expiresAt);
1619
+ return {
1620
+ dir,
1621
+ target: manifest.target,
1622
+ created_at: manifest.created_at,
1623
+ expires_at: expiresAt,
1624
+ size_bytes: typeof manifest.size_bytes === "number" ? manifest.size_bytes : null,
1625
+ managed: typeof manifest.retention?.managed === "boolean" ? manifest.retention.managed : null,
1626
+ expired: Number.isFinite(expiresMs) && expiresMs <= nowMs,
1627
+ };
1628
+ }
1629
+ /** Whether the sweep may delete this pack: expired, and either managed by the
1630
+ * store or explicitly included. A pack without retention never qualifies. */
1631
+ export function isPackDeletable(row, includeUnmanaged = false) {
1632
+ if (!row.expired || row.expires_at === null)
1633
+ return false;
1634
+ if (row.managed === true)
1635
+ return true;
1636
+ return includeUnmanaged && row.managed === false;
1637
+ }
1638
+ /** One verdict for the whole pack from its per-context critique rows: any
1639
+ * fail is a fail, otherwise the weakest non-pass outcome, otherwise pass. */
1640
+ export function aggregateMachineOutcome(critique) {
1641
+ if (!critique || critique.length === 0)
1642
+ return null;
1643
+ if (critique.some((row) => row.outcome === "fail"))
1644
+ return "fail";
1645
+ if (critique.some((row) => row.outcome === "incomplete"))
1646
+ return "incomplete";
1647
+ if (critique.some((row) => row.outcome === "skipped"))
1648
+ return "skipped";
1649
+ return "pass";
1650
+ }
1651
+ /**
1652
+ * Delete every expired pack under the roots and leave `pack-expired.json` in
1653
+ * its place. Deletion removes every entry inside the pack directory except
1654
+ * the artifact store's own `.harnery-artifact.json` (a standalone review-pack
1655
+ * workspace is the pack, and the store still owns the workspace). Nothing
1656
+ * outside the pack directory is touched. Only a pack whose manifest carries
1657
+ * `retention.expires_at` in the past is eligible, and an unmanaged pack
1658
+ * (explicit `--out`) only when `includeUnmanaged` is set.
1659
+ */
1660
+ export function deleteExpiredPacks(input) {
1661
+ const now = input.now ?? new Date();
1662
+ const nowMs = now.getTime();
1663
+ const candidates = [];
1664
+ const deleted = [];
1665
+ for (const dir of findPageReviewPacks(input.roots)) {
1666
+ const manifest = readPackManifestIfPack(dir);
1667
+ if (!manifest)
1668
+ continue;
1669
+ const row = packRow(dir, manifest, nowMs);
1670
+ candidates.push(row);
1671
+ if (!isPackDeletable(row, input.includeUnmanaged === true))
1672
+ continue;
1673
+ const protection = packArtifactProtection(dir);
1674
+ if (protection.protected)
1675
+ continue;
1676
+ let locked = false;
1677
+ if (!input.dryRun && protection.lock) {
1678
+ try {
1679
+ mkdirSync(protection.lock);
1680
+ locked = true;
1681
+ }
1682
+ catch {
1683
+ continue;
1684
+ }
1685
+ }
1686
+ try {
1687
+ if (packArtifactProtection(dir).protected)
1688
+ continue;
1689
+ if (!input.dryRun) {
1690
+ const stub = {
1691
+ schema: PAGE_REVIEW_EXPIRED_SCHEMA,
1692
+ target: manifest.target,
1693
+ created_at: manifest.created_at,
1694
+ // Guarded by isPackDeletable: a deletable row always has expires_at.
1695
+ expires_at: row.expires_at ?? "",
1696
+ deleted_at: now.toISOString(),
1697
+ contexts: Array.isArray(manifest.contexts) ? manifest.contexts.length : 0,
1698
+ machine_outcome: aggregateMachineOutcome(manifest.critique),
1699
+ };
1700
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1701
+ if (entry.name === ARTIFACT_WORKSPACE_MANIFEST_FILENAME)
1702
+ continue;
1703
+ rmSync(join(dir, entry.name), { recursive: true, force: true });
1704
+ }
1705
+ writeJson(join(dir, PAGE_REVIEW_EXPIRED_STUB_FILENAME), stub);
1706
+ }
1707
+ deleted.push(row);
1708
+ }
1709
+ finally {
1710
+ if (locked)
1711
+ rmdirSync(protection.lock);
1712
+ }
1713
+ }
1714
+ return { candidates, deleted };
1715
+ }
1716
+ /** Read the artifact wire contract without importing the product tier. The same
1717
+ * lock protocol covers core hold writes and pack payload deletion. Unknown or
1718
+ * malformed manifests are retained, including legacy manifests awaiting migration. */
1719
+ function packArtifactProtection(dir) {
1720
+ let current = resolve(dir);
1721
+ let lock;
1722
+ let protectedByManifest = false;
1723
+ while (dirname(current) !== current) {
1724
+ try {
1725
+ if (lstatSync(current).isSymbolicLink())
1726
+ return { protected: true };
1727
+ if (basename(dirname(current)) === "artifacts" &&
1728
+ basename(dirname(dirname(current))) === ".harnery") {
1729
+ lock = join(dirname(dirname(current)), "artifacts-mutation.lock");
1730
+ if (!existsSync(join(current, ARTIFACT_WORKSPACE_MANIFEST_FILENAME)))
1731
+ protectedByManifest = true;
1732
+ }
1733
+ const path = join(current, ARTIFACT_WORKSPACE_MANIFEST_FILENAME);
1734
+ if (existsSync(path)) {
1735
+ const stat = lstatSync(path);
1736
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
1737
+ return { protected: true };
1738
+ const manifest = JSON.parse(readFileSync(path, "utf8"));
1739
+ if (manifest?.schema_version !== 2 ||
1740
+ !Array.isArray(manifest.holds) ||
1741
+ manifest.holds.length !== 0)
1742
+ protectedByManifest = true;
1743
+ }
1744
+ }
1745
+ catch {
1746
+ return { protected: true };
1747
+ }
1748
+ current = dirname(current);
1749
+ }
1750
+ return { protected: protectedByManifest, lock };
1751
+ }