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,1283 @@
1
+ // One-command page-QA matrix runner: planner, deterministic gates per
2
+ // context, interaction assertions, manifest-required critique, and the QA
3
+ // snapshot — executed as child `browse` processes through a bounded pool, and
4
+ // reported as one fail-closed machine-readable result.
5
+ //
6
+ // The runner never builds a shell string: every child is an argv array handed
7
+ // to an injectable exec function (default: node:child_process execFile with a
8
+ // closed stdin, bounded output, and an explicit timeout). Every child that
9
+ // fails, times out, or dies silently becomes a named blocker or a failed
10
+ // outcome in the result — nothing is filtered away.
11
+ //
12
+ // Toolkit tier: this module must not import src/core (layering check).
13
+ import { spawn } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { cpus, freemem, loadavg, totalmem } from "node:os";
17
+ import { basename, join } from "node:path";
18
+ import { DEFAULT_CRITIQUE_RUBRIC } from "./critique.js";
19
+ import { judgePageReviewPack, toCritiqueRecords } from "./page-review-judge.js";
20
+ import { finalizePageReviewPack, gateHitsFromEnvelope, PAGE_REVIEW_DEFAULT_RETENTION_MINUTES, PAGE_REVIEW_FINDINGS_FILENAME, PAGE_REVIEW_PACK_DIRNAME, PAGE_REVIEW_PACK_SCHEMA, PAGE_REVIEW_REVIEW_FILENAME, readPackContext, readPackDom, readPackFullPage, readPackManifest, readPackSignature, } from "./page-review-pack.js";
21
+ import { QA_CRITIQUE_CONTRACT_VERSION, rubricDigest } from "./qa-reuse.js";
22
+ import { computeJobDigest, computeVerdict, mergeCoverage, QA_RUN_RESULT_SCHEMA_VERSION, QA_RUN_STATUS_SCHEMA_VERSION, } from "./qa-run-contracts.js";
23
+ import { saveQaSnapshot } from "./qa-snapshot.js";
24
+ /** Set on the runner's own environment before the host's critique provider is
25
+ * loaded for the judge stage, unless the job permits metered critique: the
26
+ * provider must stay on subscription-backed headless harnesses and surface
27
+ * exhaustion instead of falling back to a metered API. */
28
+ export const QA_RUN_HEADLESS_ONLY_ENV = "HARNERY_CRITIQUE_HEADLESS_ONLY";
29
+ /** Result document written into the run's output directory. */
30
+ export const QA_RUN_RESULT_FILENAME = "page-qa-result.json";
31
+ /** Pointer document written into the parent output directory after every
32
+ * run, naming the newest run's directory and verdict. Consumers resolve the
33
+ * current result through this pointer instead of guessing at loose files. */
34
+ export const QA_RUN_LATEST_FILENAME = "latest.json";
35
+ /**
36
+ * Publish the parent directory's latest-result pointer without allowing an
37
+ * older completion to replace a newer one. Both runner and manual evidence
38
+ * use this writer so every producer preserves the same ordering invariant.
39
+ */
40
+ export function writeLatestPointer(outParent, input) {
41
+ const pointerPath = join(outParent, QA_RUN_LATEST_FILENAME);
42
+ let pointerIsNewer = true;
43
+ try {
44
+ const existing = JSON.parse(readFileSync(pointerPath, "utf8"));
45
+ if (typeof existing.completed_at === "string") {
46
+ const existingCompletedAt = Date.parse(existing.completed_at);
47
+ const candidateCompletedAt = Date.parse(input.completed_at);
48
+ pointerIsNewer =
49
+ Number.isNaN(existingCompletedAt) || candidateCompletedAt >= existingCompletedAt;
50
+ }
51
+ }
52
+ catch {
53
+ // No readable pointer yet: this result becomes the first one.
54
+ }
55
+ if (pointerIsNewer) {
56
+ const pointer = {
57
+ schema_version: 1,
58
+ ...input,
59
+ result: join(input.dir, QA_RUN_RESULT_FILENAME),
60
+ };
61
+ const pointerTmp = join(outParent, `.${QA_RUN_LATEST_FILENAME}.${input.run_id}.tmp`);
62
+ writeFileSync(pointerTmp, `${JSON.stringify(pointer, null, 2)}\n`);
63
+ renameSync(pointerTmp, pointerPath);
64
+ }
65
+ return pointerPath;
66
+ }
67
+ /** Live status document beside the result (QaRunStatusDocument): written at
68
+ * start, every stage boundary, and on a heartbeat timer, so a disconnected
69
+ * client can tell a running job from a dead one without guessing. */
70
+ export const QA_RUN_STATUS_FILENAME = "run-status.json";
71
+ /** The effective validated job, written into the run directory so a
72
+ * reconnecting client can re-derive the job digest (`qa-verify --job`). */
73
+ export const QA_RUN_JOB_FILENAME = "job.json";
74
+ const STATUS_HEARTBEAT_MS = 15_000;
75
+ const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
76
+ const DEFAULT_RUN_DEADLINE_MS = 900_000;
77
+ const DEFAULT_COMMAND_CONCURRENCY = 2;
78
+ /** Gate-hit rectangles forwarded to one context's capture child (`--review-pack-hit-rect`). */
79
+ const REVIEW_PACK_HIT_RECTS_PER_CONTEXT = 50;
80
+ const EXEC_MAX_BUFFER = 16 * 1024 * 1024;
81
+ /** The planner's deterministic vocabulary is an executable contract, not a
82
+ * report-only hint. A job may add checks, but these arguments always run for
83
+ * every manifest context. `console` is enforced from the browse envelope
84
+ * because browse captures diagnostics without a separate fail flag. */
85
+ const MANIFEST_DETERMINISTIC_ARGS = {
86
+ console: [],
87
+ overflow: ["--check-overflow", "--check-overflow-fail"],
88
+ truncation: ["--check-truncation", "--check-truncation-fail"],
89
+ placeholder: ["--check-placeholder", "--check-placeholder-fail"],
90
+ };
91
+ /** Grace between the timeout's SIGTERM and the follow-up SIGKILL. A child
92
+ * that catches SIGTERM (Bun installs a handler by default) gets this long to
93
+ * exit before the kill is made non-negotiable. */
94
+ export const QA_RUN_KILL_GRACE_MS = 5_000;
95
+ /** After the group SIGKILL, how long to wait for stdio to drain before
96
+ * destroying the streams and settling anyway. An escaped grandchild (setsid)
97
+ * can hold the pipes open forever; the result must not wait on it. */
98
+ const KILL_DRAIN_MS = 2_000;
99
+ /** Default executor: spawn with argv arrays only (never a shell string),
100
+ * closed stdin, bounded output buffers, and the policy timeout.
101
+ *
102
+ * Timeout enforcement is escalated and group-wide, via `spawn` rather than
103
+ * `execFile` for two live-verified reasons. First, a child that catches
104
+ * SIGTERM while awaiting its own grandchildren turns a single polite kill
105
+ * into an unbounded wait (a critique command outlived its 120s cap by 10x).
106
+ * Second, `execFile` resolves only when the child's stdio closes, and an
107
+ * orphaned grandchild inheriting the pipe keeps it open after the child is
108
+ * dead — so even a delivered kill did not settle the call. The child is
109
+ * therefore spawned detached into its own process group; at the deadline the
110
+ * whole group gets SIGTERM, then SIGKILL after a grace, and the result
111
+ * settles on exit with whatever output drained, never waiting on a pipe an
112
+ * orphan still holds. A timed-out command reports an error even if the child
113
+ * then exits 0 — a result produced after the deadline cannot be trusted. */
114
+ export const defaultQaRunExec = (argv, options) => new Promise((resolvePromise) => {
115
+ const [command, ...args] = argv;
116
+ if (!command) {
117
+ resolvePromise({ exitCode: null, stdout: "", stderr: "", error: "empty argv" });
118
+ return;
119
+ }
120
+ // Windows has no process groups; the direct-child kill is the best
121
+ // available fallback there.
122
+ const groupKill = process.platform !== "win32";
123
+ const child = spawn(command, args, {
124
+ env: options.env,
125
+ stdio: ["ignore", "pipe", "pipe"],
126
+ ...(groupKill ? { detached: true } : {}),
127
+ });
128
+ let stdout = "";
129
+ let stderr = "";
130
+ let outBytes = 0;
131
+ let timedOut = false;
132
+ let overflowed = false;
133
+ let settled = false;
134
+ let termTimer;
135
+ let killTimer;
136
+ let drainTimer;
137
+ const settle = (result) => {
138
+ if (settled)
139
+ return;
140
+ settled = true;
141
+ clearTimeout(termTimer);
142
+ clearTimeout(killTimer);
143
+ clearTimeout(drainTimer);
144
+ resolvePromise(result);
145
+ };
146
+ const signalGroup = (signal) => {
147
+ const pid = child.pid;
148
+ if (!pid)
149
+ return;
150
+ if (groupKill) {
151
+ try {
152
+ process.kill(-pid, signal);
153
+ return;
154
+ }
155
+ catch {
156
+ // Group already gone, or the leader died before setpgid: fall
157
+ // through to the direct child so the kill still lands somewhere.
158
+ }
159
+ }
160
+ try {
161
+ child.kill(signal);
162
+ }
163
+ catch {
164
+ // Nothing left to kill.
165
+ }
166
+ };
167
+ const collect = (chunk, sink) => {
168
+ const text = String(chunk);
169
+ outBytes += text.length;
170
+ if (sink === "stdout")
171
+ stdout += text;
172
+ else
173
+ stderr += text;
174
+ if (outBytes > EXEC_MAX_BUFFER && !overflowed) {
175
+ overflowed = true;
176
+ signalGroup("SIGKILL");
177
+ }
178
+ };
179
+ child.stdout?.on("data", (chunk) => collect(chunk, "stdout"));
180
+ child.stderr?.on("data", (chunk) => collect(chunk, "stderr"));
181
+ child.on("error", (err) => {
182
+ settle({ exitCode: null, stdout, stderr, error: err.message || "spawn failed" });
183
+ });
184
+ const finish = (code, signal) => {
185
+ if (timedOut) {
186
+ settle({
187
+ exitCode: null,
188
+ stdout,
189
+ stderr,
190
+ error: `timed out after ${options.timeoutMs}ms (process group killed)`,
191
+ });
192
+ }
193
+ else if (overflowed) {
194
+ settle({
195
+ exitCode: null,
196
+ stdout,
197
+ stderr,
198
+ error: `output exceeded ${EXEC_MAX_BUFFER} bytes (process group killed)`,
199
+ });
200
+ }
201
+ else if (signal) {
202
+ settle({ exitCode: null, stdout, stderr, error: `killed by ${signal}` });
203
+ }
204
+ else {
205
+ settle({ exitCode: code, stdout, stderr });
206
+ }
207
+ };
208
+ // "close" is the clean path: process exited AND stdio drained. "exit"
209
+ // arms the drain failsafe so an orphan holding the pipes cannot postpone
210
+ // the result forever.
211
+ child.on("close", (code, signal) => finish(code, signal));
212
+ child.on("exit", (code, signal) => {
213
+ drainTimer = setTimeout(() => {
214
+ child.stdout?.destroy();
215
+ child.stderr?.destroy();
216
+ finish(code, signal);
217
+ }, KILL_DRAIN_MS);
218
+ drainTimer.unref?.();
219
+ });
220
+ termTimer = setTimeout(() => {
221
+ timedOut = true;
222
+ signalGroup("SIGTERM");
223
+ }, options.timeoutMs);
224
+ killTimer = setTimeout(() => signalGroup("SIGKILL"), options.timeoutMs + QA_RUN_KILL_GRACE_MS);
225
+ termTimer.unref?.();
226
+ killTimer.unref?.();
227
+ });
228
+ /** One-line tail excerpt of a failed child's output for blockers and
229
+ * failures, preferring stderr and falling back to stdout (browse prints its
230
+ * JSON error envelope there). Without this, a blocker like "planner exited 1"
231
+ * gives the reader nothing to act on. */
232
+ function execErrorExcerpt(res, maxLength = 240) {
233
+ const source = res.stderr.trim().length > 0 ? res.stderr : res.stdout;
234
+ const flat = source.trim().replace(/\s+/g, " ");
235
+ if (flat.length === 0)
236
+ return undefined;
237
+ return flat.length > maxLength ? `…${flat.slice(-maxLength)}` : flat;
238
+ }
239
+ /** Fixed-size promise pool: at most `limit` workers in flight, items claimed
240
+ * in order, results written by index so completion order never matters. */
241
+ async function runPool(items, limit, worker) {
242
+ let next = 0;
243
+ const lanes = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
244
+ while (true) {
245
+ const index = next;
246
+ next += 1;
247
+ if (index >= items.length)
248
+ return;
249
+ const item = items[index];
250
+ await worker(item, index);
251
+ }
252
+ });
253
+ await Promise.all(lanes);
254
+ }
255
+ function readEnvelope(jsonPath) {
256
+ try {
257
+ const parsed = JSON.parse(readFileSync(jsonPath, "utf8"));
258
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
259
+ return parsed;
260
+ }
261
+ }
262
+ catch {
263
+ // Best-effort: a missing or unparseable artifact is handled by the caller.
264
+ }
265
+ return undefined;
266
+ }
267
+ function gatherArtifacts(outPrefix) {
268
+ const artifacts = {};
269
+ const png = `${outPrefix}.png`;
270
+ const html = `${outPrefix}.html`;
271
+ const json = `${outPrefix}.json`;
272
+ if (existsSync(png))
273
+ artifacts.png = png;
274
+ if (existsSync(html))
275
+ artifacts.html = html;
276
+ if (existsSync(json))
277
+ artifacts.json = json;
278
+ return artifacts;
279
+ }
280
+ /** Best-effort extraction of failing-check details from a browse JSON
281
+ * envelope. The exit-code entry the caller records is the guaranteed
282
+ * minimum; these lines add human-readable specifics when parseable. */
283
+ function parseGateFailures(envelope) {
284
+ if (!envelope)
285
+ return [];
286
+ const failures = [];
287
+ const overflow = envelope.overflow;
288
+ if (overflow?.hasHorizontalOverflow) {
289
+ failures.push(`overflow: +${overflow.overflowPx ?? "?"}px horizontal overflow`);
290
+ }
291
+ const asserts = envelope.asserts;
292
+ for (const entry of asserts ?? []) {
293
+ if (entry.outcome === "fail") {
294
+ failures.push(`assert ${entry.op ?? "?"} ${entry.selector ?? "?"} → "${entry.actual ?? ""}"`);
295
+ }
296
+ }
297
+ const visibility = envelope.visibility;
298
+ for (const entry of visibility ?? []) {
299
+ if (entry.found === false)
300
+ failures.push(`visibility ${entry.selector ?? "?"}: not found`);
301
+ else if (entry.cssVisible === false) {
302
+ failures.push(`visibility ${entry.selector ?? "?"}: CSS-hidden`);
303
+ }
304
+ }
305
+ const critique = envelope.critique;
306
+ if (critique?.outcome === "fail") {
307
+ for (const finding of critique.findings ?? []) {
308
+ failures.push(`critique [${finding.severity ?? "?"}]: ${finding.description ?? ""}`);
309
+ }
310
+ }
311
+ return failures;
312
+ }
313
+ function diagnosticSummary(entry) {
314
+ if (typeof entry === "string")
315
+ return entry;
316
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
317
+ const record = entry;
318
+ for (const key of ["text", "message", "errorText", "failure", "url"]) {
319
+ const value = record[key];
320
+ if (typeof value === "string" && value.length > 0)
321
+ return value;
322
+ }
323
+ }
324
+ return JSON.stringify(entry);
325
+ }
326
+ /** `console` in the planner contract covers every browser diagnostic that the
327
+ * browse envelope records. Keep the first three details actionable and name
328
+ * any remainder without allowing an unbounded page log to bloat the result. */
329
+ function parseConsoleFailures(envelope) {
330
+ if (!envelope)
331
+ return [];
332
+ const failures = [];
333
+ const groups = [
334
+ ["console", envelope.consoleErrors],
335
+ ["page", envelope.pageErrors],
336
+ ["request", envelope.failedRequests],
337
+ ];
338
+ for (const [label, value] of groups) {
339
+ if (!Array.isArray(value) || value.length === 0)
340
+ continue;
341
+ for (const entry of value.slice(0, 3))
342
+ failures.push(`${label}: ${diagnosticSummary(entry)}`);
343
+ if (value.length > 3)
344
+ failures.push(`${label}: ${value.length - 3} additional failure(s)`);
345
+ }
346
+ return failures;
347
+ }
348
+ /**
349
+ * Lift per-backend vision latency out of the envelope's provider_meta. The
350
+ * shape is host-owned (`providers[name].latency_ms = {count, p50, p95}`), so
351
+ * read defensively: a malformed entry or one with zero calls is dropped.
352
+ * Without this the runner kept only the provider label, and a slow tile was
353
+ * visible in `ps` and nowhere in the result document.
354
+ */
355
+ function critiqueLatency(providerMeta) {
356
+ const providers = providerMeta?.providers;
357
+ if (!providers || typeof providers !== "object" || Array.isArray(providers))
358
+ return undefined;
359
+ const out = {};
360
+ for (const [name, state] of Object.entries(providers)) {
361
+ const lat = state?.latency_ms;
362
+ if (!lat || typeof lat !== "object")
363
+ continue;
364
+ const { count, p50, p95 } = lat;
365
+ if (typeof count !== "number" || count <= 0)
366
+ continue;
367
+ if (typeof p50 !== "number" || typeof p95 !== "number")
368
+ continue;
369
+ out[name] = { count, p50, p95 };
370
+ }
371
+ return Object.keys(out).length > 0 ? out : undefined;
372
+ }
373
+ /**
374
+ * Execute the whole QA matrix for one validated job and return the result
375
+ * (also written to `<outDir>/page-qa-result.json`). Stages: plan →
376
+ * deterministic gates (bounded pool) → interactions (serial) → capture (each
377
+ * context rendered once into the run's page review pack through the same
378
+ * pool, browser closed) → critique (one in-process pool of vision calls over
379
+ * every tile of every context, no browser open) → snapshot (persisted from
380
+ * the pack's files in signoff). The verdict is computeVerdict over
381
+ * everything recorded — fail-closed.
382
+ */
383
+ export async function runQaMatrix(options) {
384
+ const { job, outParent, browseArgv } = options;
385
+ const exec = options.exec ?? defaultQaRunExec;
386
+ const log = options.onLog ?? (() => { });
387
+ const timeoutMs = job.policy?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS;
388
+ const concurrency = job.policy?.command_concurrency ?? DEFAULT_COMMAND_CONCURRENCY;
389
+ const hostSample = () => {
390
+ const holders = options.admission?.holders?.() ?? undefined;
391
+ return {
392
+ captured_at: new Date().toISOString(),
393
+ loadavg_1m: loadavg()[0] ?? 0,
394
+ free_mem_bytes: freemem(),
395
+ total_mem_bytes: totalmem(),
396
+ cpu_count: cpus().length,
397
+ // Exclude this run itself: a holder list that names the sampler tells
398
+ // the reader nothing about contention.
399
+ ...(holders ? { competing: holders.filter((holder) => holder.pid !== process.pid) } : {}),
400
+ };
401
+ };
402
+ const stageHostSamples = {};
403
+ const runId = options.runId ?? randomUUID();
404
+ const outDir = join(outParent, `run-${runId}`);
405
+ mkdirSync(outDir, { recursive: true });
406
+ const startedAtIso = new Date().toISOString();
407
+ writeFileSync(join(outDir, QA_RUN_JOB_FILENAME), `${JSON.stringify(job, null, 2)}\n`);
408
+ // Live status: state + stage + heartbeat, written atomically so a client
409
+ // that lost its terminal can distinguish this run being alive from dead.
410
+ let statusState = "running";
411
+ let statusStage = null;
412
+ let statusQueue;
413
+ let statusVerdict;
414
+ const writeStatus = () => {
415
+ const status = {
416
+ schema_version: QA_RUN_STATUS_SCHEMA_VERSION,
417
+ run_id: runId,
418
+ pid: process.pid,
419
+ state: statusState,
420
+ stage: statusStage,
421
+ started_at: startedAtIso,
422
+ updated_at: new Date().toISOString(),
423
+ ...(statusQueue ? { queue: statusQueue } : {}),
424
+ ...(statusVerdict ? { verdict: statusVerdict } : {}),
425
+ };
426
+ try {
427
+ const tmp = join(outDir, `.${QA_RUN_STATUS_FILENAME}.tmp`);
428
+ writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
429
+ renameSync(tmp, join(outDir, QA_RUN_STATUS_FILENAME));
430
+ }
431
+ catch {
432
+ // Status is advisory; the result document is the authoritative record.
433
+ }
434
+ };
435
+ const heartbeat = setInterval(writeStatus, STATUS_HEARTBEAT_MS);
436
+ heartbeat.unref();
437
+ /** Enter a stage: record the host pressure it starts under and refresh the
438
+ * live status document in one place. */
439
+ const enterStage = (stage) => {
440
+ statusStage = stage;
441
+ stageHostSamples[stage] = hostSample();
442
+ writeStatus();
443
+ };
444
+ const revision = job.tested_revision !== undefined
445
+ ? { tested_revision: job.tested_revision, revision_source: "job" }
446
+ : options.revisionProbe?.tested_revision !== undefined
447
+ ? {
448
+ tested_revision: options.revisionProbe.tested_revision,
449
+ revision_source: "git",
450
+ ...(options.revisionProbe.worktree_dirty !== undefined
451
+ ? { worktree_dirty: options.revisionProbe.worktree_dirty }
452
+ : {}),
453
+ }
454
+ : { revision_source: "unknown" };
455
+ const jobDigest = computeJobDigest(job);
456
+ const wall = {
457
+ plan: 0,
458
+ gates: 0,
459
+ interactions: 0,
460
+ capture: 0,
461
+ critique: 0,
462
+ snapshot: 0,
463
+ total: 0,
464
+ };
465
+ // ------------------------------------------------------------- admission
466
+ // Queue wait happens before the runner clock starts: wall_time_ms.total
467
+ // stays pure runner time and the wait is reported as wall_time_ms.queue.
468
+ let releaseAdmission;
469
+ let admissionFailure;
470
+ if (options.admission) {
471
+ statusState = "queued";
472
+ statusQueue = {
473
+ resource: options.admission.resource,
474
+ waiting_since: new Date().toISOString(),
475
+ };
476
+ writeStatus();
477
+ const queueStart = Date.now();
478
+ try {
479
+ releaseAdmission = await options.admission.acquire((message) => {
480
+ log(message);
481
+ writeStatus();
482
+ });
483
+ }
484
+ catch (err) {
485
+ admissionFailure = err instanceof Error ? err.message : String(err);
486
+ }
487
+ wall.queue = Date.now() - queueStart;
488
+ statusQueue = undefined;
489
+ }
490
+ statusState = "running";
491
+ writeStatus();
492
+ const hostStart = hostSample();
493
+ const startedAt = Date.now();
494
+ const blockers = [];
495
+ // Overall deadline: the clock starts after admission (pure runner time) and
496
+ // is consulted before every command, so the worst overshoot is one command
497
+ // timeout plus the kill grace. Exceeding it fails closed as incomplete.
498
+ const runDeadlineMs = job.policy?.run_deadline_ms ?? DEFAULT_RUN_DEADLINE_MS;
499
+ let deadlineHit = false;
500
+ const pastDeadline = () => {
501
+ if (deadlineHit)
502
+ return true;
503
+ if (Date.now() - startedAt < runDeadlineMs)
504
+ return false;
505
+ deadlineHit = true;
506
+ blockers.push({
507
+ stage: "deadline",
508
+ reason: `run deadline of ${runDeadlineMs}ms exceeded — remaining commands were skipped and ` +
509
+ "the result finalized as incomplete (raise policy.run_deadline_ms for a legitimately " +
510
+ "larger matrix)",
511
+ });
512
+ log(`deadline: ${runDeadlineMs}ms exceeded, skipping remaining commands`);
513
+ return true;
514
+ };
515
+ const commands = [];
516
+ const critique = [];
517
+ const stagesRun = [];
518
+ let manifest = null;
519
+ let contexts = [];
520
+ let snapshot = { saved: false };
521
+ let critiquePool;
522
+ let reviewPack;
523
+ const baseEnv = { ...process.env, ...options.childEnv };
524
+ const timedExec = async (argv, env) => {
525
+ const started = Date.now();
526
+ let res;
527
+ try {
528
+ res = await exec(argv, { timeoutMs, env });
529
+ }
530
+ catch (err) {
531
+ res = {
532
+ exitCode: null,
533
+ stdout: "",
534
+ stderr: "",
535
+ error: err instanceof Error ? err.message : String(err),
536
+ };
537
+ }
538
+ return { res, wallTimeMs: Date.now() - started };
539
+ };
540
+ const finalize = () => {
541
+ wall.total = Date.now() - startedAt;
542
+ // Prefix semantics: the last stage the run progressed THROUGH cleanly.
543
+ // Stages are pushed in execution order; the first blocked stage ends the
544
+ // clean prefix, so a gate-blocked run reports "plan" even though the
545
+ // (empty) interactions stage technically executed afterwards.
546
+ const blockedStages = new Set(blockers.map((blocker) => blocker.stage));
547
+ let lastCompletedStage = null;
548
+ for (const stage of stagesRun) {
549
+ if (blockedStages.has(stage))
550
+ break;
551
+ lastCompletedStage = stage;
552
+ }
553
+ const result = {
554
+ schema_version: QA_RUN_RESULT_SCHEMA_VERSION,
555
+ evidence_source: "runner",
556
+ run: {
557
+ run_id: runId,
558
+ started_at: startedAtIso,
559
+ completed_at: new Date().toISOString(),
560
+ ...revision,
561
+ job_digest: jobDigest,
562
+ out_dir: outDir,
563
+ },
564
+ host: {
565
+ start: hostStart,
566
+ finish: hostSample(),
567
+ ...(Object.keys(stageHostSamples).length > 0 ? { stages: stageHostSamples } : {}),
568
+ },
569
+ last_completed_stage: lastCompletedStage,
570
+ target: job.target,
571
+ ...(revision.tested_revision !== undefined
572
+ ? { tested_revision: revision.tested_revision }
573
+ : {}),
574
+ mode: job.mode,
575
+ qa_plan: manifest,
576
+ contexts,
577
+ commands,
578
+ critique,
579
+ ...(critiquePool ? { critique_pool: critiquePool } : {}),
580
+ ...(reviewPack ? { review_pack: reviewPack } : {}),
581
+ snapshot,
582
+ wall_time_ms: { ...wall },
583
+ blockers,
584
+ verdict: computeVerdict({
585
+ mode: job.mode,
586
+ blockers,
587
+ commands,
588
+ critique,
589
+ snapshotSaved: snapshot.saved,
590
+ }),
591
+ };
592
+ writeFileSync(join(outDir, QA_RUN_RESULT_FILENAME), `${JSON.stringify(result, null, 2)}\n`);
593
+ writeLatestPointer(outParent, {
594
+ run_id: runId,
595
+ dir: basename(outDir),
596
+ completed_at: result.run.completed_at,
597
+ verdict: result.verdict,
598
+ });
599
+ statusState = "completed";
600
+ statusStage = null;
601
+ statusVerdict = result.verdict;
602
+ writeStatus();
603
+ clearInterval(heartbeat);
604
+ releaseAdmission?.();
605
+ return result;
606
+ };
607
+ if (admissionFailure !== undefined) {
608
+ blockers.push({
609
+ stage: "admission",
610
+ reason: `no admission slot for browser work: ${admissionFailure}`,
611
+ });
612
+ return finalize();
613
+ }
614
+ // Base render arguments shared by every per-context invocation.
615
+ const contextRenderArgs = (ctx) => [
616
+ "--viewport",
617
+ ctx.viewport,
618
+ ...(ctx.theme === "dark" ? ["--color-scheme", "dark"] : []),
619
+ ...(ctx.args ?? []),
620
+ ];
621
+ // The planner's tile ceiling and every critique child must agree on the
622
+ // per-context band cap, or the predicted cost and the real coverage drift.
623
+ const critiqueMaxTilesArgs = job.policy?.critique_max_tiles !== undefined
624
+ ? ["--check-critique-max-tiles", String(job.policy.critique_max_tiles)]
625
+ : [];
626
+ // ------------------------------------------------------------------ plan
627
+ const planArgv = [
628
+ ...browseArgv,
629
+ job.target,
630
+ "--qa-plan",
631
+ "--json",
632
+ "--no-screenshot",
633
+ ...critiqueMaxTilesArgs,
634
+ ...(job.qa_hints?.scopes ?? []).flatMap((selector) => ["--qa-scope", selector]),
635
+ ...(job.qa_hints?.states?.length ? ["--qa-states", job.qa_hints.states.join(",")] : []),
636
+ ];
637
+ log(`plan: ${job.target}`);
638
+ enterStage("plan");
639
+ const planStart = Date.now();
640
+ const plan = await timedExec(planArgv, baseEnv);
641
+ wall.plan = Date.now() - planStart;
642
+ stagesRun.push("plan");
643
+ let planEnvelope;
644
+ if (!plan.res.error && plan.res.exitCode === 0) {
645
+ try {
646
+ const parsed = JSON.parse(plan.res.stdout);
647
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
648
+ planEnvelope = parsed;
649
+ }
650
+ }
651
+ catch {
652
+ // handled below as a plan blocker
653
+ }
654
+ }
655
+ const qaPlanReport = planEnvelope?.qaPlan;
656
+ manifest = qaPlanReport?.manifest ?? null;
657
+ const planUsable = manifest !== null && !manifest.incomplete && plan.res.exitCode === 0;
658
+ commands.push({
659
+ context_id: "plan",
660
+ check_id: "plan",
661
+ argv: planArgv,
662
+ exit_code: plan.res.exitCode,
663
+ outcome: planUsable ? "passed" : "unknown",
664
+ failures: planUsable
665
+ ? []
666
+ : [plan.res.error, execErrorExcerpt(plan.res)].filter((entry) => Boolean(entry)),
667
+ artifacts: {},
668
+ wall_time_ms: plan.wallTimeMs,
669
+ });
670
+ if (plan.res.error) {
671
+ blockers.push({ stage: "plan", reason: `planner command did not complete: ${plan.res.error}` });
672
+ }
673
+ else if (plan.res.exitCode !== 0) {
674
+ const excerpt = execErrorExcerpt(plan.res);
675
+ blockers.push({
676
+ stage: "plan",
677
+ reason: `planner exited ${plan.res.exitCode}${excerpt ? `: ${excerpt}` : ""}`,
678
+ });
679
+ }
680
+ else if (!manifest) {
681
+ blockers.push({
682
+ stage: "plan",
683
+ reason: "planner envelope carried no parseable qaPlan.manifest",
684
+ });
685
+ }
686
+ else if (manifest.incomplete) {
687
+ blockers.push({
688
+ stage: "plan",
689
+ reason: `planner manifest is incomplete: ${manifest.incomplete.reason}`,
690
+ });
691
+ }
692
+ if (!planUsable || !manifest) {
693
+ // Stop before any further browser work: an untrusted plan cannot gate.
694
+ contexts = manifest ? mergeCoverage(manifest, job) : [];
695
+ return finalize();
696
+ }
697
+ // ----------------------------------------------------------------- gates
698
+ contexts = mergeCoverage(manifest, job);
699
+ const unsupportedDeterministic = manifest.checks.deterministic.filter((check) => MANIFEST_DETERMINISTIC_ARGS[check] === undefined);
700
+ if (unsupportedDeterministic.length > 0) {
701
+ for (const check of unsupportedDeterministic) {
702
+ blockers.push({
703
+ stage: "gates",
704
+ reason: `planner requires deterministic check "${check}" but qa-run has no executable ` +
705
+ "mapping for it — coverage cannot be narrowed",
706
+ });
707
+ }
708
+ return finalize();
709
+ }
710
+ const manifestGateArgs = manifest.checks.deterministic.flatMap((check) => MANIFEST_DETERMINISTIC_ARGS[check] ?? []);
711
+ const enforceConsole = manifest.checks.deterministic.includes("console");
712
+ const checks = job.checks ?? [];
713
+ const gateOutcomes = new Array(contexts.length);
714
+ enterStage("gates");
715
+ const gatesStart = Date.now();
716
+ await runPool(contexts, concurrency, async (ctx, index) => {
717
+ if (pastDeadline())
718
+ return;
719
+ const applicable = checks.filter((check) => check.contexts === undefined || check.contexts.includes(ctx.id));
720
+ const checkId = [
721
+ ...manifest.checks.deterministic.map((check) => `manifest:${check}`),
722
+ ...applicable.map((check) => check.id),
723
+ ].join("+") || "capture";
724
+ const outPrefix = join(outDir, ctx.id);
725
+ const argv = [
726
+ ...browseArgv,
727
+ job.target,
728
+ ...contextRenderArgs(ctx),
729
+ "--out",
730
+ outPrefix,
731
+ // When the manifest requires a visual pass, the capture stage writes the
732
+ // full-page PNG into the pack, so the gate trio carries no screenshot;
733
+ // nothing reads the gate PNG (the envelope JSON is the evidence).
734
+ ...(manifest.checks.visual !== "none" ? ["--no-screenshot"] : []),
735
+ ...manifestGateArgs,
736
+ ...applicable.flatMap((check) => check.args),
737
+ ];
738
+ log(`gate ${ctx.id}: ${checkId}`);
739
+ const { res, wallTimeMs } = await timedExec(argv, baseEnv);
740
+ const jsonPath = `${outPrefix}.json`;
741
+ const failures = [];
742
+ let outcome;
743
+ const envelope = existsSync(jsonPath) ? readEnvelope(jsonPath) : undefined;
744
+ const consoleFailures = enforceConsole ? parseConsoleFailures(envelope) : [];
745
+ if (!res.error && res.exitCode === 0 && existsSync(jsonPath)) {
746
+ if (consoleFailures.length > 0) {
747
+ outcome = "failed";
748
+ failures.push(...consoleFailures);
749
+ }
750
+ else {
751
+ outcome = "passed";
752
+ }
753
+ }
754
+ else if (!res.error && res.exitCode === 2 && existsSync(jsonPath)) {
755
+ outcome = "failed";
756
+ failures.push(`exit code 2 (${jsonPath})`);
757
+ failures.push(...parseGateFailures(envelope), ...consoleFailures);
758
+ }
759
+ else {
760
+ outcome = "unknown";
761
+ const excerpt = execErrorExcerpt(res);
762
+ const base = res.error
763
+ ? res.error
764
+ : !existsSync(jsonPath)
765
+ ? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
766
+ : `exit code ${res.exitCode ?? "null"}`;
767
+ const reason = excerpt ? `${base}: ${excerpt}` : base;
768
+ failures.push(reason);
769
+ blockers.push({
770
+ stage: "gates",
771
+ context_id: ctx.id,
772
+ reason: `gate command did not complete: ${reason}`,
773
+ });
774
+ }
775
+ gateOutcomes[index] = {
776
+ context_id: ctx.id,
777
+ check_id: checkId,
778
+ argv,
779
+ exit_code: res.exitCode,
780
+ outcome,
781
+ failures,
782
+ artifacts: gatherArtifacts(outPrefix),
783
+ wall_time_ms: wallTimeMs,
784
+ };
785
+ });
786
+ wall.gates = Date.now() - gatesStart;
787
+ stagesRun.push("gates");
788
+ // Manifest order regardless of completion order: outcomes were written by
789
+ // context index, so a straight push preserves it. Deadline-skipped slots
790
+ // are empty: the single deadline blocker is their record.
791
+ commands.push(...gateOutcomes.filter((outcome) => outcome !== undefined));
792
+ // ---------------------------------------------------------- interactions
793
+ enterStage("interactions");
794
+ const interactionsStart = Date.now();
795
+ const declaredStates = new Set((job.interaction_states ?? []).map((state) => state.name));
796
+ const manifestStates = [
797
+ ...new Set(manifest.contexts.map((context) => context.state).filter((state) => state !== "default")),
798
+ ];
799
+ for (const state of manifestStates) {
800
+ if (!declaredStates.has(state)) {
801
+ blockers.push({
802
+ stage: "interactions",
803
+ reason: `manifest requires interaction state "${state}" but the job declares no matching ` +
804
+ "interaction_states entry — job coverage may never be narrower than the manifest",
805
+ });
806
+ }
807
+ }
808
+ for (const state of job.interaction_states ?? []) {
809
+ if (pastDeadline())
810
+ break;
811
+ const outPrefix = join(outDir, `interaction-${state.name}`);
812
+ const argv = [
813
+ ...browseArgv,
814
+ job.target,
815
+ "--out",
816
+ outPrefix,
817
+ ...state.setup,
818
+ ...state.assertions.flatMap((assertion) => ["--assert", assertion]),
819
+ "--assert-fail",
820
+ ];
821
+ log(`interaction ${state.name}`);
822
+ const { res, wallTimeMs } = await timedExec(argv, baseEnv);
823
+ const jsonPath = `${outPrefix}.json`;
824
+ const failures = [];
825
+ let outcome;
826
+ if (!res.error && res.exitCode === 0 && existsSync(jsonPath)) {
827
+ outcome = "passed";
828
+ }
829
+ else if (!res.error && res.exitCode === 2 && existsSync(jsonPath)) {
830
+ outcome = "failed";
831
+ failures.push(`exit code 2 (${jsonPath})`);
832
+ failures.push(...parseGateFailures(readEnvelope(jsonPath)));
833
+ }
834
+ else {
835
+ outcome = "unknown";
836
+ const excerpt = execErrorExcerpt(res);
837
+ const base = res.error
838
+ ? res.error
839
+ : !existsSync(jsonPath)
840
+ ? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
841
+ : `exit code ${res.exitCode ?? "null"}`;
842
+ const reason = excerpt ? `${base}: ${excerpt}` : base;
843
+ failures.push(reason);
844
+ blockers.push({
845
+ stage: "interactions",
846
+ context_id: state.name,
847
+ reason: `interaction command did not complete: ${reason}`,
848
+ });
849
+ }
850
+ commands.push({
851
+ context_id: state.name,
852
+ check_id: `interaction:${state.name}`,
853
+ argv,
854
+ exit_code: res.exitCode,
855
+ outcome,
856
+ failures,
857
+ artifacts: gatherArtifacts(outPrefix),
858
+ wall_time_ms: wallTimeMs,
859
+ });
860
+ }
861
+ wall.interactions = Date.now() - interactionsStart;
862
+ stagesRun.push("interactions");
863
+ // -------------------------------------------------------------- capture
864
+ // Each context is rendered ONCE into the run's page review pack (full-page
865
+ // screenshot, tiles as PNG files, DOM, signature) through the same bounded
866
+ // pool the gates used, and its browser closes before any vision call.
867
+ const visual = manifest.checks.visual;
868
+ const cleanSoFar = blockers.length === 0 && commands.every((command) => command.outcome === "passed");
869
+ const packDir = join(outDir, PAGE_REVIEW_PACK_DIRNAME);
870
+ const capturedRecords = [];
871
+ const judgeCommand = options.reviewPackJudgeCommand?.(packDir);
872
+ // Gate rectangles ride along from each gate's JSON artifact so the pack's
873
+ // inspection plan can point a runt, a contrast miss, or a clipped element
874
+ // at the tiles that show it. A missing or unparseable artifact simply
875
+ // contributes no hits; the gate's `failures` still carry the text.
876
+ const gateRecords = () => commands
877
+ .filter((command) => command.check_id !== "plan" && command.check_id !== "review-pack")
878
+ .map((command) => {
879
+ const hits = command.artifacts.json
880
+ ? gateHitsFromEnvelope(readEnvelope(command.artifacts.json))
881
+ : [];
882
+ return {
883
+ context_id: command.context_id,
884
+ check_id: command.check_id,
885
+ outcome: command.outcome,
886
+ failures: command.failures,
887
+ ...(hits.length > 0 ? { hits } : {}),
888
+ };
889
+ });
890
+ const finalizePack = (stage, critiqueRecords, pool) => {
891
+ try {
892
+ finalizePageReviewPack({
893
+ packDir,
894
+ target: job.target,
895
+ ...(revision.tested_revision !== undefined
896
+ ? { tested_revision: revision.tested_revision }
897
+ : {}),
898
+ contexts: capturedRecords,
899
+ gates: gateRecords(),
900
+ critique: critiqueRecords,
901
+ ...(pool ? { pool } : {}),
902
+ ...(judgeCommand ? { judgeCommand } : {}),
903
+ createdAt: startedAtIso,
904
+ // The retention clock restarts at each finalize, so it ends
905
+ // `retention_minutes` after the judge (or after capture when the
906
+ // judge never runs).
907
+ retention: {
908
+ expires_at: new Date(Date.now() +
909
+ (job.policy?.review_pack_retention_minutes ?? PAGE_REVIEW_DEFAULT_RETENTION_MINUTES) *
910
+ 60_000).toISOString(),
911
+ managed: options.reviewPackManaged === true,
912
+ },
913
+ });
914
+ const written = readPackManifest(packDir);
915
+ reviewPack = {
916
+ schema: PAGE_REVIEW_PACK_SCHEMA,
917
+ dir: packDir,
918
+ review: join(packDir, PAGE_REVIEW_REVIEW_FILENAME),
919
+ findings: join(packDir, PAGE_REVIEW_FINDINGS_FILENAME),
920
+ ...(written.retention ? { expires_at: written.retention.expires_at } : {}),
921
+ ...(written.size_bytes !== undefined ? { size_bytes: written.size_bytes } : {}),
922
+ };
923
+ }
924
+ catch (err) {
925
+ blockers.push({
926
+ stage,
927
+ reason: `page review pack could not be finalized: ${err instanceof Error ? err.message : String(err)}`,
928
+ });
929
+ }
930
+ };
931
+ // The provider is loaded once, before capture, so the capture children can
932
+ // clamp band height to the routed model's vision budget (what browse does
933
+ // for --check-critique) and the judge reuses the same instance.
934
+ const priorHeadlessOnly = process.env[QA_RUN_HEADLESS_ONLY_ENV];
935
+ const restoreHeadlessOnly = () => {
936
+ if (priorHeadlessOnly === undefined)
937
+ delete process.env[QA_RUN_HEADLESS_ONLY_ENV];
938
+ else
939
+ process.env[QA_RUN_HEADLESS_ONLY_ENV] = priorHeadlessOnly;
940
+ };
941
+ let provider;
942
+ const runsVisual = cleanSoFar && visual !== "none";
943
+ if (runsVisual) {
944
+ if (!job.policy?.allow_metered_critique)
945
+ process.env[QA_RUN_HEADLESS_ONLY_ENV] = "1";
946
+ try {
947
+ provider =
948
+ options.critiqueProvider ??
949
+ (options.critiqueProviderLoader ? await options.critiqueProviderLoader() : undefined);
950
+ }
951
+ catch (err) {
952
+ provider = undefined;
953
+ log(`critique provider failed to load: ${err instanceof Error ? err.message : String(err)}`);
954
+ }
955
+ }
956
+ const bandArgs = provider?.tileBudgetPx !== undefined
957
+ ? ["--check-critique-band", String(Math.max(200, Math.min(1400, provider.tileBudgetPx)))]
958
+ : [];
959
+ const scopeArgs = visual === "full-page"
960
+ ? []
961
+ : manifest.scopes.flatMap((s) => ["--review-pack-scope", s.selector]);
962
+ // Gate-hit rectangles ride into each context's capture child so the bands
963
+ // they land in are cut even past the tile cap (`hit band N` tiles). The
964
+ // gates ran first, so the rects are known before the page is captured.
965
+ const hitRectsByContext = new Map();
966
+ for (const gate of gateRecords()) {
967
+ for (const hit of gate.hits ?? []) {
968
+ const list = hitRectsByContext.get(gate.context_id) ?? [];
969
+ if (list.length >= REVIEW_PACK_HIT_RECTS_PER_CONTEXT)
970
+ break;
971
+ const r = hit.rect;
972
+ list.push([r.x, r.y, Math.max(0, r.width), Math.max(0, r.height)].map((n) => Math.round(n)).join(","));
973
+ hitRectsByContext.set(gate.context_id, list);
974
+ }
975
+ }
976
+ const hitRectArgs = (contextId) => (hitRectsByContext.get(contextId) ?? []).flatMap((spec) => ["--review-pack-hit-rect", spec]);
977
+ enterStage("capture");
978
+ const captureStart = Date.now();
979
+ if (runsVisual) {
980
+ const captureOutcomes = new Array(contexts.length);
981
+ const captureRecords = new Array(contexts.length);
982
+ await runPool(contexts, concurrency, async (ctx, index) => {
983
+ if (pastDeadline())
984
+ return;
985
+ const outPrefix = join(outDir, `${ctx.id}-capture`);
986
+ const argv = [
987
+ ...browseArgv,
988
+ job.target,
989
+ ...contextRenderArgs(ctx),
990
+ "--out",
991
+ outPrefix,
992
+ "--no-screenshot",
993
+ "--review-pack",
994
+ packDir,
995
+ "--review-pack-context",
996
+ ctx.id,
997
+ "--qa-theme",
998
+ ctx.theme,
999
+ "--qa-state",
1000
+ ctx.state,
1001
+ ...(visual === "scoped" ? ["--no-review-pack-bands"] : []),
1002
+ ...scopeArgs,
1003
+ ...critiqueMaxTilesArgs,
1004
+ ...bandArgs,
1005
+ ...hitRectArgs(ctx.id),
1006
+ ];
1007
+ log(`capture ${ctx.id}${scopeArgs.length > 0 ? ` [${manifest.scopes.map((s) => s.selector).join(",")}]` : ""}`);
1008
+ const { res, wallTimeMs } = await timedExec(argv, baseEnv);
1009
+ const jsonPath = `${outPrefix}.json`;
1010
+ const envelope = readEnvelope(jsonPath);
1011
+ const report = envelope?.reviewPack;
1012
+ const failures = [];
1013
+ let outcome;
1014
+ let record;
1015
+ if (!res.error && res.exitCode === 0 && envelope && report?.context_id) {
1016
+ try {
1017
+ record = readPackContext(packDir, report.context_id);
1018
+ outcome = "passed";
1019
+ }
1020
+ catch (err) {
1021
+ outcome = "unknown";
1022
+ const reason = `pack context unreadable: ${err instanceof Error ? err.message : String(err)}`;
1023
+ failures.push(reason);
1024
+ blockers.push({ stage: "capture", context_id: ctx.id, reason });
1025
+ }
1026
+ }
1027
+ else {
1028
+ outcome = "unknown";
1029
+ const excerpt = execErrorExcerpt(res);
1030
+ const base = res.error
1031
+ ? res.error
1032
+ : !envelope
1033
+ ? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
1034
+ : !report?.context_id
1035
+ ? `exit code ${res.exitCode ?? "null"}, envelope carries no reviewPack record`
1036
+ : `exit code ${res.exitCode ?? "null"}`;
1037
+ const reason = excerpt ? `${base}: ${excerpt}` : base;
1038
+ failures.push(reason);
1039
+ blockers.push({
1040
+ stage: "capture",
1041
+ context_id: ctx.id,
1042
+ reason: `capture command did not complete: ${reason}`,
1043
+ });
1044
+ }
1045
+ captureOutcomes[index] = {
1046
+ context_id: ctx.id,
1047
+ check_id: "review-pack",
1048
+ argv,
1049
+ exit_code: res.exitCode,
1050
+ outcome,
1051
+ failures,
1052
+ artifacts: gatherArtifacts(outPrefix),
1053
+ wall_time_ms: wallTimeMs,
1054
+ };
1055
+ captureRecords[index] = record;
1056
+ });
1057
+ commands.push(...captureOutcomes.filter((outcome) => outcome !== undefined));
1058
+ capturedRecords.push(...captureRecords.filter((record) => Boolean(record)));
1059
+ // The pack is readable from here on even if the judge never runs.
1060
+ if (capturedRecords.length > 0)
1061
+ finalizePack("capture", null);
1062
+ }
1063
+ wall.capture = Date.now() - captureStart;
1064
+ if (runsVisual)
1065
+ stagesRun.push("capture");
1066
+ // -------------------------------------------------------------- critique
1067
+ // One pool of vision calls across every captured context, from disk. No
1068
+ // browser is open during this stage.
1069
+ enterStage("critique");
1070
+ const critiqueStart = Date.now();
1071
+ const judgedById = new Map();
1072
+ if (runsVisual && capturedRecords.length > 0 && !pastDeadline()) {
1073
+ const judged = await judgePageReviewPack({
1074
+ packDir,
1075
+ provider,
1076
+ rubric: DEFAULT_CRITIQUE_RUBRIC,
1077
+ ...(job.policy?.critique_pool !== undefined ? { concurrency: job.policy.critique_pool } : {}),
1078
+ contextIds: capturedRecords.map((record) => record.id),
1079
+ ...(manifest.baseline_source !== "none"
1080
+ ? {
1081
+ reuse: {
1082
+ target: job.target,
1083
+ ...(options.snapshotStore ? { store: options.snapshotStore } : {}),
1084
+ },
1085
+ }
1086
+ : {}),
1087
+ deadlineAt: startedAt + runDeadlineMs,
1088
+ onLog: log,
1089
+ });
1090
+ const latency = critiqueLatency(judged.provider_meta);
1091
+ critiquePool = {
1092
+ concurrency: judged.pool.concurrency,
1093
+ tiles_total: judged.tiles_total,
1094
+ tiles_reviewed: judged.tiles_reviewed,
1095
+ tiles_reused: judged.tiles_reused,
1096
+ wall_time_ms: judged.pool.wall_time_ms,
1097
+ provider: judged.pool.provider,
1098
+ ...(latency ? { latency_ms: latency } : {}),
1099
+ };
1100
+ for (const row of judged.contexts) {
1101
+ judgedById.set(row.context_id, row);
1102
+ let contextOutcome = row.outcome === "pass" ? "passed" : row.outcome === "fail" ? "failed" : "unknown";
1103
+ if (row.outcome === "skipped") {
1104
+ const detail = row.error ?? "critique reported no conclusive outcome";
1105
+ blockers.push({
1106
+ stage: "critique",
1107
+ context_id: row.context_id,
1108
+ reason: job.policy?.allow_metered_critique
1109
+ ? `critique did not complete: ${detail}`
1110
+ : `critique did not complete under the headless-only policy ` +
1111
+ `(${QA_RUN_HEADLESS_ONLY_ENV}=1; permit metered fallback with ` +
1112
+ `policy.allow_metered_critique / --allow-metered): ${detail}`,
1113
+ });
1114
+ }
1115
+ else if (row.outcome === "incomplete") {
1116
+ blockers.push({
1117
+ stage: "critique",
1118
+ context_id: row.context_id,
1119
+ reason: `judge did not review ${row.tiles_unjudged} of ${row.tiles_total} tile(s) before the ` +
1120
+ "run deadline; nothing is proven for them",
1121
+ });
1122
+ }
1123
+ // A capped capture holds the top of the page and nothing below it.
1124
+ // Signoff cannot rest on that; review mode keeps the row honest via
1125
+ // `coverage` and leaves the verdict to the tiles that were seen.
1126
+ if (job.mode === "signoff" && row.coverage.capped && contextOutcome !== "failed") {
1127
+ contextOutcome = "unknown";
1128
+ blockers.push({
1129
+ stage: "critique",
1130
+ context_id: row.context_id,
1131
+ reason: `critique coverage capped: ${row.coverage.bands_reviewed} of ${row.coverage.bands_total} bands ` +
1132
+ `reviewed (${row.coverage.reviewed_height_px} of ${row.coverage.page_height_px} px); raise ` +
1133
+ "policy.critique_max_tiles to review the whole page in signoff mode",
1134
+ });
1135
+ }
1136
+ const scopeById = new Map(row.record.tiles.map((tile) => [tile.id, tile.scope]));
1137
+ critique.push({
1138
+ context_id: row.context_id,
1139
+ provider: row.provider,
1140
+ tiles_total: row.tiles_total,
1141
+ tiles_reviewed: row.tiles_reviewed,
1142
+ tiles_reused: row.tiles_reused,
1143
+ outcome: contextOutcome,
1144
+ findings: row.findings.map((finding) => {
1145
+ const selector = scopeById.get(finding.tile_id);
1146
+ return {
1147
+ severity: finding.severity,
1148
+ summary: `${finding.category}: ${finding.description}`,
1149
+ tile: `${row.context_id}/${finding.tile_id}`,
1150
+ ...(selector !== undefined ? { selector } : {}),
1151
+ };
1152
+ }),
1153
+ coverage: row.coverage,
1154
+ });
1155
+ }
1156
+ finalizePack("critique", toCritiqueRecords(judged), judged.pool);
1157
+ }
1158
+ if (runsVisual)
1159
+ restoreHeadlessOnly();
1160
+ wall.critique = Date.now() - critiqueStart;
1161
+ if (runsVisual)
1162
+ stagesRun.push("critique");
1163
+ // -------------------------------------------------------------- snapshot
1164
+ // Signoff persists each context's baseline from the pack's own files (no
1165
+ // browser). The critique rides along only when the whole tile set was
1166
+ // freshly judged, uncapped, and unscoped — a partial or scoped review must
1167
+ // never become the next baseline's finding record. When the manifest
1168
+ // required no visual pass, a dedicated browse --qa-snapshot pass still runs.
1169
+ enterStage("snapshot");
1170
+ const snapshotStart = Date.now();
1171
+ const savedSnapshots = new Map();
1172
+ if (job.mode === "signoff" && runsVisual) {
1173
+ for (const record of capturedRecords) {
1174
+ if (pastDeadline())
1175
+ break;
1176
+ const row = judgedById.get(record.id);
1177
+ const persistCritique = row !== undefined &&
1178
+ (row.outcome === "pass" || row.outcome === "fail") &&
1179
+ !record.coverage.capped &&
1180
+ row.tiles_reused === 0 &&
1181
+ row.tiles_unjudged === 0 &&
1182
+ record.scopes.length === 0;
1183
+ const persisted = persistCritique && row
1184
+ ? {
1185
+ contract_version: QA_CRITIQUE_CONTRACT_VERSION,
1186
+ rubric_digest: rubricDigest(DEFAULT_CRITIQUE_RUBRIC),
1187
+ outcome: row.outcome,
1188
+ findings: row.findings.map(({ tile_id: _tileId, ...finding }) => finding),
1189
+ tiles: record.tiles.map((tile) => ({
1190
+ index: tile.index,
1191
+ label: tile.label,
1192
+ x: tile.x,
1193
+ scrollY: tile.scrollY,
1194
+ width: tile.width,
1195
+ height: tile.height,
1196
+ })),
1197
+ }
1198
+ : undefined;
1199
+ try {
1200
+ const saved = saveQaSnapshot(job.target, { viewport: record.viewport, theme: record.theme, state: record.state }, {
1201
+ signature: readPackSignature(packDir, record),
1202
+ domHtml: readPackDom(packDir, record),
1203
+ screenshotPng: readPackFullPage(packDir, record),
1204
+ ...(persisted ? { critique: persisted } : {}),
1205
+ }, options.snapshotStore ?? {});
1206
+ savedSnapshots.set(record.id, saved.path);
1207
+ log(`snapshot ${record.id}: saved${persisted ? " with critique" : ""}`);
1208
+ }
1209
+ catch (err) {
1210
+ blockers.push({
1211
+ stage: "snapshot",
1212
+ context_id: record.id,
1213
+ reason: `snapshot could not be persisted from the pack: ${err instanceof Error ? err.message : String(err)}`,
1214
+ });
1215
+ }
1216
+ }
1217
+ for (const row of critique) {
1218
+ if (row.outcome === "passed" && !savedSnapshots.has(row.context_id)) {
1219
+ blockers.push({
1220
+ stage: "snapshot",
1221
+ context_id: row.context_id,
1222
+ reason: "signoff critique passed but no QA snapshot was persisted for the context",
1223
+ });
1224
+ }
1225
+ }
1226
+ }
1227
+ else if (job.mode === "signoff" && visual === "none" && blockers.length === 0) {
1228
+ const stillClean = commands.every((command) => command.outcome === "passed");
1229
+ if (stillClean) {
1230
+ for (const ctx of contexts) {
1231
+ if (pastDeadline())
1232
+ break;
1233
+ const outPrefix = join(outDir, `${ctx.id}-snapshot`);
1234
+ const argv = [
1235
+ ...browseArgv,
1236
+ job.target,
1237
+ ...contextRenderArgs(ctx),
1238
+ "--out",
1239
+ outPrefix,
1240
+ "--qa-snapshot",
1241
+ "--qa-theme",
1242
+ ctx.theme,
1243
+ "--qa-state",
1244
+ ctx.state,
1245
+ ];
1246
+ log(`snapshot ${ctx.id}`);
1247
+ const { res, wallTimeMs } = await timedExec(argv, baseEnv);
1248
+ const envelope = readEnvelope(`${outPrefix}.json`);
1249
+ const saved = envelope?.qaPlan
1250
+ ?.snapshotSaved;
1251
+ const ok = !res.error && res.exitCode === 0 && Boolean(saved?.path);
1252
+ if (ok && saved?.path)
1253
+ savedSnapshots.set(ctx.id, saved.path);
1254
+ else {
1255
+ blockers.push({
1256
+ stage: "snapshot",
1257
+ context_id: ctx.id,
1258
+ reason: `snapshot command did not persist a baseline${res.error ? `: ${res.error}` : ` (exit code ${res.exitCode ?? "null"})`}`,
1259
+ });
1260
+ }
1261
+ commands.push({
1262
+ context_id: ctx.id,
1263
+ check_id: "snapshot",
1264
+ argv,
1265
+ exit_code: res.exitCode,
1266
+ outcome: ok ? "passed" : "unknown",
1267
+ failures: ok ? [] : [res.error ?? `exit code ${res.exitCode ?? "null"}`],
1268
+ artifacts: gatherArtifacts(outPrefix),
1269
+ wall_time_ms: wallTimeMs,
1270
+ });
1271
+ }
1272
+ }
1273
+ }
1274
+ wall.snapshot = Date.now() - snapshotStart;
1275
+ if (job.mode === "signoff")
1276
+ stagesRun.push("snapshot");
1277
+ if (job.mode === "signoff") {
1278
+ const allSaved = contexts.length > 0 && contexts.every((ctx) => savedSnapshots.has(ctx.id));
1279
+ const lastPath = [...savedSnapshots.values()].pop();
1280
+ snapshot = { saved: allSaved, ...(lastPath ? { path: lastPath } : {}) };
1281
+ }
1282
+ return finalize();
1283
+ }