@xoxoai/checkmate 0.6.7 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. package/README.md +54 -257
  2. package/dist/checkmate.js +13375 -0
  3. package/package.json +30 -94
  4. package/plugin/.claude-plugin/plugin.json +10 -0
  5. package/plugin/bin/checkmate.ts +3 -0
  6. package/plugin/contracts/finding.schema.json +78 -0
  7. package/plugin/contracts/gap.schema.json +56 -0
  8. package/plugin/contracts/plan-candidate.schema.json +103 -0
  9. package/plugin/contracts/plan-delta.schema.json +63 -0
  10. package/plugin/contracts/plan-patch.schema.json +116 -0
  11. package/plugin/contracts/plan-request.schema.json +108 -0
  12. package/plugin/contracts/plan.schema.json +313 -0
  13. package/plugin/contracts/profile.schema.json +154 -0
  14. package/plugin/contracts/response.schema.json +25 -0
  15. package/plugin/contracts/result.schema.json +410 -0
  16. package/plugin/dist/checkmate.js +13012 -0
  17. package/plugin/package.json +4 -0
  18. package/plugin/references/browser.md +35 -0
  19. package/plugin/references/http.md +30 -0
  20. package/plugin/references/workflow.md +122 -0
  21. package/plugin/skills/execute/SKILL.md +92 -0
  22. package/plugin/skills/plan/SKILL.md +94 -0
  23. package/plugin/skills/report/SKILL.md +28 -0
  24. package/plugin/skills/revise/SKILL.md +43 -0
  25. package/plugin/skills/setup/SKILL.md +82 -0
  26. package/plugin/skills/status/SKILL.md +20 -0
  27. package/plugin/skills/ui/SKILL.md +26 -0
  28. package/plugin/src/actor.ts +21 -0
  29. package/plugin/src/cli.ts +1133 -0
  30. package/plugin/src/contracts.ts +342 -0
  31. package/plugin/src/index.ts +52 -0
  32. package/plugin/src/plans.ts +1028 -0
  33. package/plugin/src/render.ts +544 -0
  34. package/plugin/src/runs.ts +868 -0
  35. package/plugin/src/store.ts +384 -0
  36. package/plugin/src/targets.ts +205 -0
  37. package/plugin/src/types.ts +319 -0
  38. package/plugin/src/ui/assets/client.js +501 -0
  39. package/plugin/src/ui/assets/favicon.svg +1 -0
  40. package/plugin/src/ui/assets/fonts/inter-OFL.txt +93 -0
  41. package/plugin/src/ui/assets/fonts/inter.woff2 +0 -0
  42. package/plugin/src/ui/assets/fonts/jetbrains-mono-OFL.txt +93 -0
  43. package/plugin/src/ui/assets/fonts/jetbrains-mono.woff2 +0 -0
  44. package/plugin/src/ui/assets/fonts/space-grotesk-OFL.txt +93 -0
  45. package/plugin/src/ui/assets/fonts/space-grotesk.woff2 +0 -0
  46. package/plugin/src/ui/assets/style.css +1926 -0
  47. package/plugin/src/ui/edit.ts +305 -0
  48. package/plugin/src/ui/forms.ts +209 -0
  49. package/plugin/src/ui/html.ts +130 -0
  50. package/plugin/src/ui/layout.ts +198 -0
  51. package/plugin/src/ui/lists.ts +645 -0
  52. package/plugin/src/ui/plan.ts +577 -0
  53. package/plugin/src/ui/run.ts +489 -0
  54. package/plugin/src/ui/server.ts +684 -0
  55. package/plugin/src/validators.d.ts +13 -0
  56. package/plugin/src/validators.js +1 -0
  57. package/plugin/src/views.ts +839 -0
  58. package/bin/checkmate.js +0 -13
  59. package/dist/ai/client.d.ts +0 -64
  60. package/dist/ai/client.d.ts.map +0 -1
  61. package/dist/ai/client.js +0 -313
  62. package/dist/ai/client.js.map +0 -1
  63. package/dist/ai/message-handler.d.ts +0 -9
  64. package/dist/ai/message-handler.d.ts.map +0 -1
  65. package/dist/ai/message-handler.js +0 -50
  66. package/dist/ai/message-handler.js.map +0 -1
  67. package/dist/ai/message-history.d.ts +0 -17
  68. package/dist/ai/message-history.d.ts.map +0 -1
  69. package/dist/ai/message-history.js +0 -45
  70. package/dist/ai/message-history.js.map +0 -1
  71. package/dist/ai/prompts.d.ts +0 -4
  72. package/dist/ai/prompts.d.ts.map +0 -1
  73. package/dist/ai/prompts.js +0 -24
  74. package/dist/ai/prompts.js.map +0 -1
  75. package/dist/ai/rate-limit-policy.d.ts +0 -9
  76. package/dist/ai/rate-limit-policy.d.ts.map +0 -1
  77. package/dist/ai/rate-limit-policy.js +0 -34
  78. package/dist/ai/rate-limit-policy.js.map +0 -1
  79. package/dist/ai/tool-response-handler.d.ts +0 -16
  80. package/dist/ai/tool-response-handler.d.ts.map +0 -1
  81. package/dist/ai/tool-response-handler.js +0 -85
  82. package/dist/ai/tool-response-handler.js.map +0 -1
  83. package/dist/ai/turn-processor.d.ts +0 -34
  84. package/dist/ai/turn-processor.d.ts.map +0 -1
  85. package/dist/ai/turn-processor.js +0 -107
  86. package/dist/ai/turn-processor.js.map +0 -1
  87. package/dist/api/allocate-run-identity.d.ts +0 -8
  88. package/dist/api/allocate-run-identity.d.ts.map +0 -1
  89. package/dist/api/allocate-run-identity.js +0 -10
  90. package/dist/api/allocate-run-identity.js.map +0 -1
  91. package/dist/api/execute-prepared-run.d.ts +0 -22
  92. package/dist/api/execute-prepared-run.d.ts.map +0 -1
  93. package/dist/api/execute-prepared-run.js +0 -184
  94. package/dist/api/execute-prepared-run.js.map +0 -1
  95. package/dist/api/index.d.ts +0 -25
  96. package/dist/api/index.d.ts.map +0 -1
  97. package/dist/api/index.js +0 -69
  98. package/dist/api/index.js.map +0 -1
  99. package/dist/api/prepare-run.d.ts +0 -44
  100. package/dist/api/prepare-run.d.ts.map +0 -1
  101. package/dist/api/prepare-run.js +0 -261
  102. package/dist/api/prepare-run.js.map +0 -1
  103. package/dist/cli/diagnostic-relay.d.ts +0 -6
  104. package/dist/cli/diagnostic-relay.d.ts.map +0 -1
  105. package/dist/cli/diagnostic-relay.js +0 -6
  106. package/dist/cli/diagnostic-relay.js.map +0 -1
  107. package/dist/cli/main.d.ts +0 -21
  108. package/dist/cli/main.d.ts.map +0 -1
  109. package/dist/cli/main.js +0 -166
  110. package/dist/cli/main.js.map +0 -1
  111. package/dist/cli/protocol.d.ts +0 -57
  112. package/dist/cli/protocol.d.ts.map +0 -1
  113. package/dist/cli/protocol.js +0 -116
  114. package/dist/cli/protocol.js.map +0 -1
  115. package/dist/cli/reconcile-result.d.ts +0 -13
  116. package/dist/cli/reconcile-result.d.ts.map +0 -1
  117. package/dist/cli/reconcile-result.js +0 -300
  118. package/dist/cli/reconcile-result.js.map +0 -1
  119. package/dist/cli/run-parent.d.ts +0 -37
  120. package/dist/cli/run-parent.d.ts.map +0 -1
  121. package/dist/cli/run-parent.js +0 -491
  122. package/dist/cli/run-parent.js.map +0 -1
  123. package/dist/cli/signals.d.ts +0 -36
  124. package/dist/cli/signals.d.ts.map +0 -1
  125. package/dist/cli/signals.js +0 -85
  126. package/dist/cli/signals.js.map +0 -1
  127. package/dist/cli/static-commands.d.ts +0 -11
  128. package/dist/cli/static-commands.d.ts.map +0 -1
  129. package/dist/cli/static-commands.js +0 -83
  130. package/dist/cli/static-commands.js.map +0 -1
  131. package/dist/cli/worker-stream-guard.d.ts +0 -20
  132. package/dist/cli/worker-stream-guard.d.ts.map +0 -1
  133. package/dist/cli/worker-stream-guard.js +0 -41
  134. package/dist/cli/worker-stream-guard.js.map +0 -1
  135. package/dist/cli/worker.d.ts +0 -3
  136. package/dist/cli/worker.d.ts.map +0 -1
  137. package/dist/cli/worker.js +0 -111
  138. package/dist/cli/worker.js.map +0 -1
  139. package/dist/config/ingestion.d.ts +0 -58
  140. package/dist/config/ingestion.d.ts.map +0 -1
  141. package/dist/config/ingestion.js +0 -283
  142. package/dist/config/ingestion.js.map +0 -1
  143. package/dist/config/manifest.d.ts +0 -20
  144. package/dist/config/manifest.d.ts.map +0 -1
  145. package/dist/config/manifest.js +0 -91
  146. package/dist/config/manifest.js.map +0 -1
  147. package/dist/config/model-egress.d.ts +0 -10
  148. package/dist/config/model-egress.d.ts.map +0 -1
  149. package/dist/config/model-egress.js +0 -62
  150. package/dist/config/model-egress.js.map +0 -1
  151. package/dist/config/package-resolution.d.ts +0 -4
  152. package/dist/config/package-resolution.d.ts.map +0 -1
  153. package/dist/config/package-resolution.js +0 -24
  154. package/dist/config/package-resolution.js.map +0 -1
  155. package/dist/config/policy.d.ts +0 -44
  156. package/dist/config/policy.d.ts.map +0 -1
  157. package/dist/config/policy.js +0 -141
  158. package/dist/config/policy.js.map +0 -1
  159. package/dist/config/record.d.ts +0 -4
  160. package/dist/config/record.d.ts.map +0 -1
  161. package/dist/config/record.js +0 -10
  162. package/dist/config/record.js.map +0 -1
  163. package/dist/config/secrets.d.ts +0 -11
  164. package/dist/config/secrets.d.ts.map +0 -1
  165. package/dist/config/secrets.js +0 -42
  166. package/dist/config/secrets.js.map +0 -1
  167. package/dist/contracts/diagnostics.d.ts +0 -4
  168. package/dist/contracts/diagnostics.d.ts.map +0 -1
  169. package/dist/contracts/diagnostics.js +0 -31
  170. package/dist/contracts/diagnostics.js.map +0 -1
  171. package/dist/contracts/serialize.d.ts +0 -2
  172. package/dist/contracts/serialize.d.ts.map +0 -1
  173. package/dist/contracts/serialize.js +0 -90
  174. package/dist/contracts/serialize.js.map +0 -1
  175. package/dist/contracts/types.d.ts +0 -316
  176. package/dist/contracts/types.d.ts.map +0 -1
  177. package/dist/contracts/types.js +0 -74
  178. package/dist/contracts/types.js.map +0 -1
  179. package/dist/contracts/validator.d.ts +0 -8
  180. package/dist/contracts/validator.d.ts.map +0 -1
  181. package/dist/contracts/validator.js +0 -71
  182. package/dist/contracts/validator.js.map +0 -1
  183. package/dist/driver.d.ts +0 -97
  184. package/dist/driver.d.ts.map +0 -1
  185. package/dist/driver.js +0 -24
  186. package/dist/driver.js.map +0 -1
  187. package/dist/drivers/descriptor.d.ts +0 -15
  188. package/dist/drivers/descriptor.d.ts.map +0 -1
  189. package/dist/drivers/descriptor.js +0 -122
  190. package/dist/drivers/descriptor.js.map +0 -1
  191. package/dist/drivers/loader.d.ts +0 -18
  192. package/dist/drivers/loader.d.ts.map +0 -1
  193. package/dist/drivers/loader.js +0 -97
  194. package/dist/drivers/loader.js.map +0 -1
  195. package/dist/drivers/web/checkmate-driver.json +0 -47
  196. package/dist/drivers/web/index.d.ts +0 -6
  197. package/dist/drivers/web/index.d.ts.map +0 -1
  198. package/dist/drivers/web/index.js +0 -27
  199. package/dist/drivers/web/index.js.map +0 -1
  200. package/dist/drivers/web/session.d.ts +0 -25
  201. package/dist/drivers/web/session.d.ts.map +0 -1
  202. package/dist/drivers/web/session.js +0 -132
  203. package/dist/drivers/web/session.js.map +0 -1
  204. package/dist/drivers/web/tools/network-request-recorder.d.ts +0 -46
  205. package/dist/drivers/web/tools/network-request-recorder.d.ts.map +0 -1
  206. package/dist/drivers/web/tools/network-request-recorder.js +0 -164
  207. package/dist/drivers/web/tools/network-request-recorder.js.map +0 -1
  208. package/dist/drivers/web/tools/screenshot-service.d.ts +0 -10
  209. package/dist/drivers/web/tools/screenshot-service.d.ts.map +0 -1
  210. package/dist/drivers/web/tools/screenshot-service.js +0 -23
  211. package/dist/drivers/web/tools/screenshot-service.js.map +0 -1
  212. package/dist/drivers/web/tools/snapshot-filter/index.d.ts +0 -4
  213. package/dist/drivers/web/tools/snapshot-filter/index.d.ts.map +0 -1
  214. package/dist/drivers/web/tools/snapshot-filter/index.js +0 -4
  215. package/dist/drivers/web/tools/snapshot-filter/index.js.map +0 -1
  216. package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.d.ts +0 -15
  217. package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.d.ts.map +0 -1
  218. package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.js +0 -99
  219. package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.js.map +0 -1
  220. package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.d.ts +0 -5
  221. package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.d.ts.map +0 -1
  222. package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.js +0 -56
  223. package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.js.map +0 -1
  224. package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.d.ts +0 -3
  225. package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.d.ts.map +0 -1
  226. package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.js +0 -85
  227. package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.js.map +0 -1
  228. package/dist/drivers/web/tools/snapshot-service.d.ts +0 -31
  229. package/dist/drivers/web/tools/snapshot-service.d.ts.map +0 -1
  230. package/dist/drivers/web/tools/snapshot-service.js +0 -96
  231. package/dist/drivers/web/tools/snapshot-service.js.map +0 -1
  232. package/dist/drivers/web/tools/tool.d.ts +0 -76
  233. package/dist/drivers/web/tools/tool.d.ts.map +0 -1
  234. package/dist/drivers/web/tools/tool.js +0 -562
  235. package/dist/drivers/web/tools/tool.js.map +0 -1
  236. package/dist/drivers/web/tools/transient-state-tracker.d.ts +0 -26
  237. package/dist/drivers/web/tools/transient-state-tracker.d.ts.map +0 -1
  238. package/dist/drivers/web/tools/transient-state-tracker.js +0 -301
  239. package/dist/drivers/web/tools/transient-state-tracker.js.map +0 -1
  240. package/dist/drivers/web/tools/types.d.ts +0 -11
  241. package/dist/drivers/web/tools/types.d.ts.map +0 -1
  242. package/dist/drivers/web/tools/types.js +0 -2
  243. package/dist/drivers/web/tools/types.js.map +0 -1
  244. package/dist/evidence/atomic-file.d.ts +0 -23
  245. package/dist/evidence/atomic-file.d.ts.map +0 -1
  246. package/dist/evidence/atomic-file.js +0 -104
  247. package/dist/evidence/atomic-file.js.map +0 -1
  248. package/dist/evidence/layout.d.ts +0 -30
  249. package/dist/evidence/layout.d.ts.map +0 -1
  250. package/dist/evidence/layout.js +0 -152
  251. package/dist/evidence/layout.js.map +0 -1
  252. package/dist/evidence/retention.d.ts +0 -5
  253. package/dist/evidence/retention.d.ts.map +0 -1
  254. package/dist/evidence/retention.js +0 -10
  255. package/dist/evidence/retention.js.map +0 -1
  256. package/dist/evidence/store.d.ts +0 -101
  257. package/dist/evidence/store.d.ts.map +0 -1
  258. package/dist/evidence/store.js +0 -375
  259. package/dist/evidence/store.js.map +0 -1
  260. package/dist/evidence/terminal-finalizer.d.ts +0 -19
  261. package/dist/evidence/terminal-finalizer.d.ts.map +0 -1
  262. package/dist/evidence/terminal-finalizer.js +0 -56
  263. package/dist/evidence/terminal-finalizer.js.map +0 -1
  264. package/dist/index.d.ts +0 -9
  265. package/dist/index.d.ts.map +0 -1
  266. package/dist/index.js +0 -12
  267. package/dist/index.js.map +0 -1
  268. package/dist/logging/invocation-logger.d.ts +0 -4
  269. package/dist/logging/invocation-logger.d.ts.map +0 -1
  270. package/dist/logging/invocation-logger.js +0 -25
  271. package/dist/logging/invocation-logger.js.map +0 -1
  272. package/dist/logging/level-logger.d.ts +0 -11
  273. package/dist/logging/level-logger.d.ts.map +0 -1
  274. package/dist/logging/level-logger.js +0 -82
  275. package/dist/logging/level-logger.js.map +0 -1
  276. package/dist/logging/types.d.ts +0 -8
  277. package/dist/logging/types.d.ts.map +0 -1
  278. package/dist/logging/types.js +0 -7
  279. package/dist/logging/types.js.map +0 -1
  280. package/dist/redaction/diagnostic-sanitizer.d.ts +0 -7
  281. package/dist/redaction/diagnostic-sanitizer.d.ts.map +0 -1
  282. package/dist/redaction/diagnostic-sanitizer.js +0 -42
  283. package/dist/redaction/diagnostic-sanitizer.js.map +0 -1
  284. package/dist/redaction/redactor.d.ts +0 -40
  285. package/dist/redaction/redactor.d.ts.map +0 -1
  286. package/dist/redaction/redactor.js +0 -176
  287. package/dist/redaction/redactor.js.map +0 -1
  288. package/dist/redaction/scrub.d.ts +0 -29
  289. package/dist/redaction/scrub.d.ts.map +0 -1
  290. package/dist/redaction/scrub.js +0 -54
  291. package/dist/redaction/scrub.js.map +0 -1
  292. package/dist/runtime/config.d.ts +0 -16
  293. package/dist/runtime/config.d.ts.map +0 -1
  294. package/dist/runtime/config.js +0 -2
  295. package/dist/runtime/config.js.map +0 -1
  296. package/dist/runtime/driver-boundary.d.ts +0 -16
  297. package/dist/runtime/driver-boundary.d.ts.map +0 -1
  298. package/dist/runtime/driver-boundary.js +0 -71
  299. package/dist/runtime/driver-boundary.js.map +0 -1
  300. package/dist/runtime/driver-session.d.ts +0 -9
  301. package/dist/runtime/driver-session.d.ts.map +0 -1
  302. package/dist/runtime/driver-session.js +0 -64
  303. package/dist/runtime/driver-session.js.map +0 -1
  304. package/dist/runtime/internal-step-evidence.d.ts +0 -30
  305. package/dist/runtime/internal-step-evidence.d.ts.map +0 -1
  306. package/dist/runtime/internal-step-evidence.js +0 -82
  307. package/dist/runtime/internal-step-evidence.js.map +0 -1
  308. package/dist/runtime/result-adapter.d.ts +0 -9
  309. package/dist/runtime/result-adapter.d.ts.map +0 -1
  310. package/dist/runtime/result-adapter.js +0 -77
  311. package/dist/runtime/result-adapter.js.map +0 -1
  312. package/dist/runtime/runner.d.ts +0 -43
  313. package/dist/runtime/runner.d.ts.map +0 -1
  314. package/dist/runtime/runner.js +0 -72
  315. package/dist/runtime/runner.js.map +0 -1
  316. package/dist/runtime/scenario-control.d.ts +0 -40
  317. package/dist/runtime/scenario-control.d.ts.map +0 -1
  318. package/dist/runtime/scenario-control.js +0 -94
  319. package/dist/runtime/scenario-control.js.map +0 -1
  320. package/dist/runtime/scenario-runner.d.ts +0 -41
  321. package/dist/runtime/scenario-runner.d.ts.map +0 -1
  322. package/dist/runtime/scenario-runner.js +0 -274
  323. package/dist/runtime/scenario-runner.js.map +0 -1
  324. package/dist/runtime/scenario-state.d.ts +0 -55
  325. package/dist/runtime/scenario-state.d.ts.map +0 -1
  326. package/dist/runtime/scenario-state.js +0 -128
  327. package/dist/runtime/scenario-state.js.map +0 -1
  328. package/dist/runtime/step-execution.d.ts +0 -27
  329. package/dist/runtime/step-execution.d.ts.map +0 -1
  330. package/dist/runtime/step-execution.js +0 -143
  331. package/dist/runtime/step-execution.js.map +0 -1
  332. package/dist/runtime/text.d.ts +0 -10
  333. package/dist/runtime/text.d.ts.map +0 -1
  334. package/dist/runtime/text.js +0 -25
  335. package/dist/runtime/text.js.map +0 -1
  336. package/dist/runtime/types.d.ts +0 -53
  337. package/dist/runtime/types.d.ts.map +0 -1
  338. package/dist/runtime/types.js +0 -2
  339. package/dist/runtime/types.js.map +0 -1
  340. package/dist/runtime/usage-tracker.d.ts +0 -37
  341. package/dist/runtime/usage-tracker.d.ts.map +0 -1
  342. package/dist/runtime/usage-tracker.js +0 -110
  343. package/dist/runtime/usage-tracker.js.map +0 -1
  344. package/dist/tools/define-agent-tool.d.ts +0 -20
  345. package/dist/tools/define-agent-tool.d.ts.map +0 -1
  346. package/dist/tools/define-agent-tool.js +0 -30
  347. package/dist/tools/define-agent-tool.js.map +0 -1
  348. package/dist/tools/dispatcher.d.ts +0 -19
  349. package/dist/tools/dispatcher.d.ts.map +0 -1
  350. package/dist/tools/dispatcher.js +0 -91
  351. package/dist/tools/dispatcher.js.map +0 -1
  352. package/dist/tools/loop-detector.d.ts +0 -27
  353. package/dist/tools/loop-detector.d.ts.map +0 -1
  354. package/dist/tools/loop-detector.js +0 -76
  355. package/dist/tools/loop-detector.js.map +0 -1
  356. package/dist/tools/registry.d.ts +0 -19
  357. package/dist/tools/registry.d.ts.map +0 -1
  358. package/dist/tools/registry.js +0 -54
  359. package/dist/tools/registry.js.map +0 -1
  360. package/dist/tools/step/result-tool.d.ts +0 -7
  361. package/dist/tools/step/result-tool.d.ts.map +0 -1
  362. package/dist/tools/step/result-tool.js +0 -34
  363. package/dist/tools/step/result-tool.js.map +0 -1
  364. package/dist/tools/tool-contract.d.ts +0 -3
  365. package/dist/tools/tool-contract.d.ts.map +0 -1
  366. package/dist/tools/tool-contract.js +0 -2
  367. package/dist/tools/tool-contract.js.map +0 -1
  368. package/dist/tools/types.d.ts +0 -210
  369. package/dist/tools/types.d.ts.map +0 -1
  370. package/dist/tools/types.js +0 -4
  371. package/dist/tools/types.js.map +0 -1
  372. package/docs/CLI.md +0 -59
  373. package/docs/CONFIGURATION.md +0 -105
  374. package/docs/DRIVERS.md +0 -79
  375. package/docs/EVIDENCE.md +0 -64
  376. package/schemas/checkmate-config.v1.json +0 -176
  377. package/schemas/describe-result.v1.json +0 -414
  378. package/schemas/driver-descriptor.v1.json +0 -66
  379. package/schemas/run-request.v1.json +0 -67
  380. package/schemas/run-result.v1.json +0 -333
  381. package/schemas/validation-result.v1.json +0 -86
@@ -0,0 +1,1028 @@
1
+ import type { Gap, Plan, PlanCandidate, PlanDelta, PlanPatch, PlanRequest, Profile } from './types.ts'
2
+ import type { Change, Reply, Store } from './store.ts'
3
+ import { all, canonical, completed, digest, execute, one, ulid, write } from './store.ts'
4
+ import type { PlanDraft, Test } from './contracts.ts'
5
+ import {
6
+ approvalPolicy,
7
+ dependencyCheck,
8
+ derivePlan,
9
+ expectDigest,
10
+ gapsAffecting,
11
+ index,
12
+ planCheck,
13
+ planTargetCheck,
14
+ references,
15
+ requireChoice,
16
+ requireCondition,
17
+ requireIdentifier,
18
+ requireText,
19
+ revisionReference,
20
+ same,
21
+ testReference,
22
+ validate,
23
+ } from './contracts.ts'
24
+ import { targetRevision, toolCheck } from './targets.ts'
25
+
26
+ export type PlanRevision = { plan: Plan; sha256: string }
27
+ export type PlanTool = Omit<Profile['tools'][number], 'reachable'> & { reachable?: boolean }
28
+ export type GapAnswer = {
29
+ planId: string
30
+ gapId: string
31
+ kind: string
32
+ value: string
33
+ source?: string | null
34
+ rationale: string
35
+ expectSha256?: string
36
+ }
37
+ export type Verdict = { reference: string; rationale: string; expectSha256?: string }
38
+ export type GateInput = {
39
+ reference: string
40
+ tests?: string[]
41
+ build: string
42
+ partial?: string
43
+ retestOf?: string | null
44
+ }
45
+ export type PatchOperation = { op: 'add' | 'remove' | 'replace'; path: string; value?: unknown }
46
+ type RequestRow = { plan_id: string; target_id: string; target_revision: number; body: string }
47
+ type DecisionRow = {
48
+ id: string
49
+ gap_id: string
50
+ kind: 'clarify' | 'accept_assumption' | 'defer'
51
+ value: string
52
+ source: string | null
53
+ rationale: string
54
+ actor: string
55
+ }
56
+
57
+ const decisionKinds = ['clarify', 'accept_assumption', 'defer'] as const
58
+ const partialModes = ['wait_for_resolution', 'ready_only'] as const
59
+
60
+ export function openPlan(
61
+ store: Store,
62
+ actor: string | undefined,
63
+ input: { planId: string; target: string; request: unknown }
64
+ ) {
65
+ return write(store, { actor, command: 'plan open', subject: input.planId, input }, (operationId) => {
66
+ const planId = requireIdentifier(input.planId, 'plan ID')
67
+ const target = revisionReference(requireText(input.target, 'target (--target)'), 'target reference')
68
+ requireCondition(target.revision !== null, `Name the target revision: --target ${target.id}@<revision>`)
69
+ const profile = targetRevision(store, target.id, target.revision).profile
70
+ const request = validate('plan-request', input.request)
71
+ requireCondition(
72
+ request.requirements?.length || request.context?.sources.length,
73
+ 'A plan request needs requirements or context sources'
74
+ )
75
+ index(request.requirements ?? [], 'id', 'requirement')
76
+ for (const tool of request.tools ?? [])
77
+ if (typeof tool === 'string')
78
+ requireCondition(
79
+ profile.tools.some((known) => known.name === tool),
80
+ `Unknown tool ${tool}. The target profile has: ${profile.tools.map((known) => known.name).join(', ') || 'no tools'}.`
81
+ )
82
+ else {
83
+ toolCheck(tool)
84
+ requireCondition(
85
+ !profile.tools.some((known) => known.name === tool.name),
86
+ `Tool ${tool.name} is in the target profile. Name it as a string.`
87
+ )
88
+ }
89
+ const names = planTools(request, profile).map((tool) => tool.name)
90
+ requireCondition(new Set(names).size === names.length, 'Duplicate tool name in the plan request')
91
+ const body = canonical(request)
92
+ const data = { planId, target: `${target.id}@${target.revision}` }
93
+ const existing = one<RequestRow>(store, 'SELECT * FROM plan_requests WHERE plan_id = ?', planId)
94
+ if (existing) {
95
+ requireCondition(
96
+ existing.target_id === target.id &&
97
+ existing.target_revision === target.revision &&
98
+ existing.body === body,
99
+ `Plan ${planId} is open with another request. Use a new plan ID.`
100
+ )
101
+ return { reply: completed(planId, { ...data, recorded: false }), subjects: [] }
102
+ }
103
+ execute(
104
+ store,
105
+ 'INSERT INTO plan_requests (plan_id, target_id, target_revision, body, operation_id) VALUES (?, ?, ?, ?, ?)',
106
+ planId,
107
+ target.id,
108
+ target.revision,
109
+ body,
110
+ operationId
111
+ )
112
+ return { reply: completed(planId, { ...data, recorded: true }), subjects: [`plan:${planId}`, 'inbox'] }
113
+ })
114
+ }
115
+
116
+ export function planRequest(store: Store, planId: string) {
117
+ const row = one<RequestRow>(store, 'SELECT * FROM plan_requests WHERE plan_id = ?', planId)
118
+ requireCondition(row, `Unknown plan: ${planId}. Run plan open first.`)
119
+ const request: PlanRequest = JSON.parse(row.body)
120
+ return { request, target: targetRevision(store, row.target_id, row.target_revision) }
121
+ }
122
+
123
+ export function planTools(request: PlanRequest, profile: Profile): PlanTool[] {
124
+ const named = (request.tools ?? []).filter((tool) => typeof tool === 'string')
125
+ const added = (request.tools ?? []).filter((tool) => typeof tool !== 'string')
126
+ const kept = named.length ? profile.tools.filter((tool) => named.includes(tool.name)) : profile.tools
127
+ return [...kept, ...added]
128
+ }
129
+
130
+ export function showPlanRequest(store: Store, planId: string) {
131
+ const { request, target } = planRequest(store, planId)
132
+ return completed(planId, {
133
+ planId,
134
+ target: `${target.profile.targetId}@${target.revision}`,
135
+ objective: request.objective,
136
+ requirements: request.requirements ?? [],
137
+ sourceContext: request.sourceContext ?? [],
138
+ context: request.context ?? { sources: [] },
139
+ tools: planTools(request, target.profile),
140
+ capabilities: target.profile.capabilities,
141
+ allowedOperations: target.profile.allowedOperations,
142
+ })
143
+ }
144
+
145
+ export function planRevision(store: Store, planId: string, revision: number | null = null): PlanRevision {
146
+ const row = one<{ body: string; sha256: string }>(
147
+ store,
148
+ revision === null
149
+ ? 'SELECT body, sha256 FROM latest_plans WHERE plan_id = ?'
150
+ : 'SELECT body, sha256 FROM plan_revisions WHERE plan_id = ? AND revision = ?',
151
+ planId,
152
+ ...(revision === null ? [] : [revision])
153
+ )
154
+ if (!row) {
155
+ planRequest(store, planId)
156
+ throw new Error(
157
+ revision === null
158
+ ? `Plan ${planId} has no revision. Run plan save first.`
159
+ : `Plan ${planId} has no revision ${revision}`
160
+ )
161
+ }
162
+ return { plan: JSON.parse(row.body), sha256: row.sha256 }
163
+ }
164
+
165
+ export function latestPlans(store: Store) {
166
+ const rows = all<{ plan_id: string; body: string }>(
167
+ store,
168
+ 'SELECT plan_id, body FROM latest_plans ORDER BY plan_id'
169
+ )
170
+ return new Map(rows.map((row) => [row.plan_id, JSON.parse(row.body) as Plan]))
171
+ }
172
+
173
+ function checkedRevision(store: Store, draft: PlanDraft) {
174
+ const plan = planCheck(derivePlan(draft))
175
+ planTargetCheck(plan, targetRevision(store, plan.target.targetId, plan.target.revision).profile)
176
+ const others = latestPlans(store)
177
+ others.delete(plan.planId)
178
+ dependencyCheck(plan, others)
179
+ const body = canonical(plan)
180
+ return { plan, body, sha256: digest(body) }
181
+ }
182
+
183
+ function revisionChange(plan: Plan, sha256: string): Change {
184
+ const blockers = plan.gaps.filter((gap) => gap.status === 'open' && gap.severity === 'blocking')
185
+ return {
186
+ reply: {
187
+ status: blockers.length ? 'needs_decision' : 'completed',
188
+ subject: `${plan.planId}@${plan.revision}`,
189
+ reasons: blockers.map((gap) => `${gap.id}: ${gap.question}`),
190
+ data: { planId: plan.planId, revision: plan.revision, sha256 },
191
+ },
192
+ subjects: [`plan:${plan.planId}`, 'inbox'],
193
+ }
194
+ }
195
+
196
+ function storeRevision(store: Store, draft: PlanDraft, operationId: string): Change {
197
+ const { plan, body, sha256 } = checkedRevision(store, draft)
198
+ execute(
199
+ store,
200
+ 'INSERT INTO plan_revisions (plan_id, revision, previous_revision, sha256, body, operation_id) VALUES (?, ?, ?, ?, ?, ?)',
201
+ plan.planId,
202
+ plan.revision,
203
+ plan.previousRevision,
204
+ sha256,
205
+ body,
206
+ operationId
207
+ )
208
+ return revisionChange(plan, sha256)
209
+ }
210
+
211
+ export function planCandidate(plan: Plan): Required<PlanCandidate> {
212
+ return {
213
+ requirements: plan.requirements,
214
+ tests: plan.tests.map(({ readiness, gapIds, ...test }) => test),
215
+ gaps: plan.gaps,
216
+ }
217
+ }
218
+
219
+ export function frozenRevision(store: Store, planId: string, revision: number) {
220
+ if (one(store, 'SELECT 1 FROM runs WHERE plan_id = ? AND plan_revision = ?', planId, revision)) return 'run'
221
+ const approved = one(
222
+ store,
223
+ "SELECT 1 FROM approvals WHERE subject_kind = 'plan' AND subject_id = ? AND verdict = 'approved'",
224
+ `${planId}@${revision}`
225
+ )
226
+ return approved ? 'approved' : null
227
+ }
228
+
229
+ export function editPlan(
230
+ store: Store,
231
+ actor: string | undefined,
232
+ input: { reference: string; candidate: unknown; expectSha256?: string }
233
+ ) {
234
+ return write(store, { actor, command: 'plan edit', subject: input.reference, input }, (operationId) =>
235
+ storeEdit(store, operationId, editableRevision(store, input.reference, input.expectSha256), input.candidate)
236
+ )
237
+ }
238
+
239
+ export function patchPlan(
240
+ store: Store,
241
+ actor: string | undefined,
242
+ input: { reference: string; patch: unknown; expectSha256?: string }
243
+ ) {
244
+ return write(store, { actor, command: 'plan edit', subject: input.reference, input }, (operationId) => {
245
+ const base = editableRevision(store, input.reference, input.expectSha256)
246
+ const patch = validate('plan-patch', input.patch)
247
+ return storeEdit(store, operationId, base, patchedCandidate(base.plan, patch))
248
+ })
249
+ }
250
+
251
+ export function adoptGaps(
252
+ store: Store,
253
+ actor: string | undefined,
254
+ input: { reference: string; runId: string; gapIds?: string[]; expectSha256?: string }
255
+ ) {
256
+ return write(store, { actor, command: 'plan adopt-gaps', subject: input.reference, input }, (operationId) => {
257
+ const base = editableRevision(store, input.reference, input.expectSha256)
258
+ const planId = base.plan.planId
259
+ const run = one<{ plan_id: string }>(store, 'SELECT plan_id FROM runs WHERE run_id = ?', input.runId)
260
+ requireCondition(run, `Unknown run: ${input.runId}`)
261
+ requireCondition(run.plan_id === planId, `Run ${input.runId} tests plan ${run.plan_id}, not ${planId}`)
262
+ const recorded = planRunGaps(store, planId).filter((item) => item.runId === input.runId)
263
+ const gaps =
264
+ input.gapIds === undefined
265
+ ? recorded.filter((item) => !item.adopted).map((item) => item.gap)
266
+ : input.gapIds.map((id) => {
267
+ const item = recorded.find((item) => item.gap.id === id)
268
+ requireCondition(item, `Run ${input.runId} has no gap ${id}`)
269
+ requireCondition(!item.adopted, `Gap ${id} of run ${input.runId} is in plan ${planId}`)
270
+ return item.gap
271
+ })
272
+ requireCondition(gaps.length, `Run ${input.runId} has no gap to adopt`)
273
+ return adoption(store, operationId, base, input.runId, gaps)
274
+ })
275
+ }
276
+
277
+ function adoption(store: Store, operationId: string, base: PlanRevision, runId: string, gaps: Gap[]): Change {
278
+ const candidate = planCandidate(base.plan)
279
+ const change = storeEdit(store, operationId, base, { ...candidate, gaps: [...candidate.gaps, ...gaps] })
280
+ change.reply.data.runId = runId
281
+ change.reply.data.adopted = gaps.map((gap) => gap.id)
282
+ change.subjects.push(`run:${runId}`)
283
+ return change
284
+ }
285
+
286
+ export type RunGap = { runId: string; gap: Gap; adopted: boolean; at: string }
287
+
288
+ export function planRunGaps(store: Store, planId: string): RunGap[] {
289
+ return all<{ run_id: string; body: string; adopted: number; at: string }>(
290
+ store,
291
+ `SELECT g.run_id, g.body, o.at, EXISTS (SELECT 1 FROM plan_gaps p
292
+ WHERE p.plan_id = r.plan_id AND p.revision > r.plan_revision AND p.gap_id = g.id) AS adopted
293
+ FROM run_gaps g JOIN runs r ON r.run_id = g.run_id JOIN operations o ON o.id = g.operation_id
294
+ WHERE r.plan_id = ? ORDER BY g.rowid`,
295
+ planId
296
+ ).map((row) => ({ runId: row.run_id, gap: JSON.parse(row.body), adopted: row.adopted === 1, at: row.at }))
297
+ }
298
+
299
+ function editableRevision(store: Store, text: string, expectSha256: string | undefined) {
300
+ const reference = revisionReference(requireText(text, 'plan reference'), 'plan reference')
301
+ requireCondition(reference.revision !== null, `Name the plan revision: ${reference.id}@<revision>`)
302
+ const base = planRevision(store, reference.id, reference.revision)
303
+ const subject = `${reference.id}@${reference.revision}`
304
+ const latest = planRevision(store, reference.id).plan.revision
305
+ requireCondition(
306
+ latest === reference.revision,
307
+ `${subject} is not the latest revision. Edit ${reference.id}@${latest}.`
308
+ )
309
+ expectDigest(base.sha256, expectSha256, subject)
310
+ return base
311
+ }
312
+
313
+ function storeEdit(store: Store, operationId: string, base: PlanRevision, value: unknown): Change {
314
+ const candidate = validate('plan-candidate', value)
315
+ const planId = base.plan.planId
316
+ const frozen = frozenRevision(store, planId, base.plan.revision)
317
+ const { coverage, tests, ...kept } = structuredClone(base.plan)
318
+ const draft: PlanDraft = {
319
+ ...kept,
320
+ ...(frozen ? { revision: base.plan.revision + 1, previousRevision: base.plan.revision } : {}),
321
+ requirements: candidate.requirements ?? kept.requirements,
322
+ tests: candidate.tests,
323
+ gaps: candidate.gaps,
324
+ }
325
+ for (const decision of pendingDecisions(store, planId))
326
+ requireCondition(
327
+ draft.gaps.some((gap) => gap.id === decision.gap_id && gap.status === 'open'),
328
+ `Gap ${decision.gap_id} has a pending decision ${decision.id}. Keep the gap open.`
329
+ )
330
+ for (const test of draft.tests) {
331
+ const before = base.plan.tests.find((item) => item.id === test.id)?.assertions ?? []
332
+ const used = usedAssertionIds(store, planId, test.id)
333
+ for (const assertion of test.assertions)
334
+ requireCondition(
335
+ before.some((item) => item.id === assertion.id) || !used.includes(assertion.id),
336
+ `Assertion ID ${test.id}.${assertion.id} was used in an earlier revision. Use a new ID.`
337
+ )
338
+ }
339
+ if (frozen) {
340
+ const change = storeRevision(store, draft, operationId)
341
+ change.reply.data.edit = 'new_revision'
342
+ change.reply.data.reason = frozen
343
+ return change
344
+ }
345
+ const { plan, body, sha256 } = checkedRevision(store, draft)
346
+ execute(
347
+ store,
348
+ 'UPDATE plan_revisions SET sha256 = ?, body = ?, operation_id = ? WHERE plan_id = ? AND revision = ?',
349
+ sha256,
350
+ body,
351
+ operationId,
352
+ plan.planId,
353
+ plan.revision
354
+ )
355
+ const change = revisionChange(plan, sha256)
356
+ change.reply.data.edit = 'in_place'
357
+ change.reply.data.previousSha256 = base.sha256
358
+ return change
359
+ }
360
+
361
+ function patchedCandidate(plan: Plan, patch: PlanPatch): Required<PlanCandidate> {
362
+ const candidate = planCandidate(structuredClone(plan))
363
+ return {
364
+ requirements: patchList(candidate.requirements, patch.requirements, 'requirement', (item, id, change) =>
365
+ item ? { ...item, ...change } : { id, ...newFields(`requirement ${id}`, change, ['text', 'source']) }
366
+ ),
367
+ tests: patchList(candidate.tests, patch.tests, 'test', (item, id, change) => {
368
+ const { assertions, ...fields } = change
369
+ const test = item
370
+ ? { ...item, ...fields }
371
+ : {
372
+ id,
373
+ assertions: [],
374
+ ...newFields(
375
+ `test ${id}`,
376
+ fields,
377
+ ['title', 'requirementIds', 'capability', 'operations', 'action'],
378
+ { dependsOn: [], preconditions: [] }
379
+ ),
380
+ }
381
+ test.assertions = patchList(test.assertions, assertions, `assertion in test ${id}`, (old, key, value) =>
382
+ old
383
+ ? { ...old, ...value }
384
+ : { id: key, ...newFields(`assertion ${id}.${key}`, value, ['check', 'checkKind']) }
385
+ )
386
+ return test
387
+ }),
388
+ gaps: patchList(candidate.gaps, patch.gaps, 'gap', (item, id, change) =>
389
+ item
390
+ ? { ...item, ...change }
391
+ : {
392
+ id,
393
+ status: 'open',
394
+ decisionId: null,
395
+ ...newFields(`gap ${id}`, change, [
396
+ 'severity',
397
+ 'requirementIds',
398
+ 'testIds',
399
+ 'question',
400
+ 'consequence',
401
+ ]),
402
+ }
403
+ ),
404
+ }
405
+ }
406
+
407
+ function patchList<T extends { id: string }, C extends object>(
408
+ items: T[],
409
+ changes: Record<string, C | null> | undefined,
410
+ name: string,
411
+ apply: (item: T | undefined, id: string, change: C) => T
412
+ ) {
413
+ const result = [...items]
414
+ for (const [id, change] of Object.entries(changes ?? {})) {
415
+ const position = result.findIndex((item) => item.id === id)
416
+ if (change === null) {
417
+ requireCondition(position >= 0, `Cannot remove unknown ${name} ${id}`)
418
+ result.splice(position, 1)
419
+ } else if (position >= 0) result[position] = apply(result[position], id, change)
420
+ else result.push(apply(undefined, id, change))
421
+ }
422
+ return result
423
+ }
424
+
425
+ function newFields<C extends object>(name: string, change: C, required: string[], defaults: Partial<C> = {}) {
426
+ const missing = required.filter((field) => !(field in change))
427
+ requireCondition(!missing.length, `New ${name} needs ${missing.join(', ')}`)
428
+ return { ...defaults, ...change } as Required<C>
429
+ }
430
+
431
+ export function savePlan(store: Store, actor: string | undefined, input: { planId: string; candidate: unknown }) {
432
+ return write(store, { actor, command: 'plan save', subject: input.planId, input }, (operationId) => {
433
+ const { request, target } = planRequest(store, input.planId)
434
+ requireCondition(
435
+ !one(store, 'SELECT 1 FROM plan_revisions WHERE plan_id = ?', input.planId),
436
+ `Plan ${input.planId} has revision 1. Use plan edit, or gap answer and plan revise, to change it.`
437
+ )
438
+ const candidate = validate('plan-candidate', input.candidate)
439
+ const requirements = candidateRequirements(request, candidate.requirements)
440
+ return storeRevision(
441
+ store,
442
+ {
443
+ contractVersion: 'toolkit-2',
444
+ planId: input.planId,
445
+ revision: 1,
446
+ previousRevision: null,
447
+ target: { targetId: target.profile.targetId, revision: target.revision, sha256: target.sha256 },
448
+ objective: request.objective,
449
+ requirements,
450
+ tests: candidate.tests,
451
+ gaps: candidate.gaps,
452
+ assumptions: [],
453
+ decisions: [],
454
+ },
455
+ operationId
456
+ )
457
+ })
458
+ }
459
+
460
+ function candidateRequirements(request: PlanRequest, drafted: Plan['requirements'] | undefined) {
461
+ if (request.requirements?.length) {
462
+ requireCondition(!drafted, 'The plan request names the requirements. Do not add requirements to the candidate.')
463
+ return request.requirements
464
+ }
465
+ const sources = request.context?.sources ?? []
466
+ requireCondition(
467
+ drafted?.length,
468
+ `The plan request has no requirements. Draft them from the context sources: ${sources.join(', ')}`
469
+ )
470
+ index(drafted, 'id', 'requirement')
471
+ for (const requirement of drafted)
472
+ requireCondition(
473
+ sources.some((source) => requirement.source.startsWith(source)),
474
+ `Requirement ${requirement.id} cites ${requirement.source}, which is not a context source. Use one of: ${sources.join(', ')}`
475
+ )
476
+ return drafted
477
+ }
478
+
479
+ function pendingDecisions(store: Store, planId: string) {
480
+ return all<DecisionRow>(
481
+ store,
482
+ 'SELECT * FROM decisions WHERE plan_id = ? AND applied_revision IS NULL ORDER BY rowid',
483
+ planId
484
+ )
485
+ }
486
+
487
+ export function answerGap(store: Store, actor: string | undefined, input: GapAnswer) {
488
+ return write(store, { actor, command: 'gap answer', subject: input.planId, input }, (operationId, at, actor) => {
489
+ const kind = requireChoice(input.kind, decisionKinds, 'decision kind (--kind)')
490
+ const value = requireText(input.value, 'decision value (--value)')
491
+ const rationale = requireText(input.rationale, 'rationale (--rationale)')
492
+ const source = input.source ?? null
493
+ if (source !== null) requireText(source, 'source (--source)')
494
+ requireCondition(kind !== 'clarify' || source, 'A clarification needs a source (--source)')
495
+ const answered = planRevision(store, input.planId)
496
+ expectDigest(answered.sha256, input.expectSha256, `${input.planId}@${answered.plan.revision}`)
497
+ const adopted = answered.plan.gaps.some((gap) => gap.id === input.gapId)
498
+ ? null
499
+ : adoptRunGap(store, operationId, answered, input.gapId)
500
+ const latest = adopted ? planRevision(store, input.planId) : answered
501
+ const subject = `${input.planId}@${latest.plan.revision}`
502
+ const gap = latest.plan.gaps.find((gap) => gap.id === input.gapId)!
503
+ requireCondition(
504
+ gap.status === 'open',
505
+ `Gap ${gap.id} is ${gap.status} in ${subject}. Only an open gap takes a decision.`
506
+ )
507
+ const pending = pendingDecisions(store, input.planId).find((decision) => decision.gap_id === gap.id)
508
+ requireCondition(
509
+ !pending,
510
+ `Gap ${gap.id} has a pending decision ${pending?.id}. Run plan revise to apply it first.`
511
+ )
512
+ const id = ulid()
513
+ execute(
514
+ store,
515
+ 'INSERT INTO decisions (id, plan_id, base_revision, gap_id, kind, value, source, rationale, actor, operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
516
+ id,
517
+ input.planId,
518
+ latest.plan.revision,
519
+ gap.id,
520
+ kind,
521
+ value,
522
+ source,
523
+ rationale,
524
+ actor,
525
+ operationId
526
+ )
527
+ return {
528
+ reply: completed(input.planId, {
529
+ decisionId: id,
530
+ planId: input.planId,
531
+ baseRevision: latest.plan.revision,
532
+ gapId: gap.id,
533
+ kind,
534
+ ...(adopted
535
+ ? {
536
+ adopted: {
537
+ runId: adopted.reply.data.runId,
538
+ revision: latest.plan.revision,
539
+ edit: adopted.reply.data.edit,
540
+ },
541
+ }
542
+ : {}),
543
+ }),
544
+ subjects: adopted ? adopted.subjects : [`plan:${input.planId}`, 'inbox'],
545
+ }
546
+ })
547
+ }
548
+
549
+ function adoptRunGap(store: Store, operationId: string, base: PlanRevision, gapId: string) {
550
+ const subject = `${base.plan.planId}@${base.plan.revision}`
551
+ const found = planRunGaps(store, base.plan.planId).filter((item) => item.gap.id === gapId && !item.adopted)
552
+ requireCondition(found.length, `Unknown gap ${gapId} in ${subject}`)
553
+ requireCondition(
554
+ found.length === 1,
555
+ `Gap ${gapId} is in runs ${found.map((item) => item.runId).join(', ')}. Run plan adopt-gaps ${subject} --run <runId> --gaps ${gapId} first.`
556
+ )
557
+ return adoption(store, operationId, base, found[0].runId, [found[0].gap])
558
+ }
559
+
560
+ function affectedTests(plan: Plan, gapIds: string[]) {
561
+ const gaps = plan.gaps.filter((gap) => gapIds.includes(gap.id))
562
+ return plan.tests.filter((test) => gapsAffecting(gaps, test).length > 0)
563
+ }
564
+
565
+ export function showPending(store: Store, planId: string) {
566
+ const latest = planRevision(store, planId)
567
+ const decisions = pendingDecisions(store, planId)
568
+ const gapIds = decisions.map((decision) => decision.gap_id)
569
+ return completed(`${planId}@${latest.plan.revision}`, {
570
+ planId,
571
+ revision: latest.plan.revision,
572
+ sha256: latest.sha256,
573
+ decisions: decisions.map(decisionSnapshot),
574
+ gaps: latest.plan.gaps.filter((gap) => gapIds.includes(gap.id)),
575
+ tests: affectedTests(latest.plan, gapIds),
576
+ })
577
+ }
578
+
579
+ function decisionSnapshot(row: DecisionRow): Plan['decisions'][number] {
580
+ return {
581
+ id: row.id,
582
+ gapId: row.gap_id,
583
+ kind: row.kind,
584
+ actor: row.actor,
585
+ rationale: row.rationale,
586
+ value: row.value,
587
+ source: row.source,
588
+ }
589
+ }
590
+
591
+ export function revisePlan(
592
+ store: Store,
593
+ actor: string | undefined,
594
+ input: { planId: string; delta: unknown; expectSha256?: string }
595
+ ) {
596
+ return write(store, { actor, command: 'plan revise', subject: input.planId, input }, (operationId) => {
597
+ const base = planRevision(store, input.planId)
598
+ expectDigest(base.sha256, input.expectSha256, `${input.planId}@${base.plan.revision}`)
599
+ const decisions = pendingDecisions(store, input.planId)
600
+ requireCondition(
601
+ decisions.length > 0,
602
+ `Plan ${input.planId} has no pending decision. Record one with gap answer first.`
603
+ )
604
+ const delta = validate('plan-delta', input.delta)
605
+ const { coverage, ...draft } = structuredClone(base.plan)
606
+ draft.revision = base.plan.revision + 1
607
+ draft.previousRevision = base.plan.revision
608
+ for (const decision of decisions) {
609
+ const gap = draft.gaps.find((gap) => gap.id === decision.gap_id)!
610
+ gap.status = decision.kind === 'defer' ? 'deferred' : 'resolved'
611
+ gap.decisionId = decision.id
612
+ draft.decisions.push(decisionSnapshot(decision))
613
+ if (decision.kind === 'accept_assumption')
614
+ draft.assumptions.push({ decisionId: decision.id, text: decision.value })
615
+ }
616
+ const affected = affectedTests(
617
+ base.plan,
618
+ decisions.map((decision) => decision.gap_id)
619
+ ).map((test) => test.id)
620
+ for (const [testId, change] of Object.entries(delta.tests)) {
621
+ const test = draft.tests.find((test) => test.id === testId)
622
+ requireCondition(test, `Unknown test in delta: ${testId}`)
623
+ requireCondition(
624
+ affected.includes(testId),
625
+ `Test ${testId} is not affected by a pending decision. A revision changes only affected tests.`
626
+ )
627
+ applyTestChange(test, change, usedAssertionIds(store, input.planId, testId))
628
+ }
629
+ const change = storeRevision(store, draft, operationId)
630
+ for (const decision of decisions)
631
+ execute(store, 'UPDATE decisions SET applied_revision = ? WHERE id = ?', draft.revision, decision.id)
632
+ change.reply.data.applied = decisions.map((decision) => decision.id)
633
+ return change
634
+ })
635
+ }
636
+
637
+ function usedAssertionIds(store: Store, planId: string, testId: string) {
638
+ return all<{ id: string }>(
639
+ store,
640
+ "SELECT DISTINCT a.value ->> '$.id' AS id FROM plan_tests t, json_each(t.test, '$.assertions') a WHERE t.plan_id = ? AND t.test_id = ?",
641
+ planId,
642
+ testId
643
+ ).map((row) => row.id)
644
+ }
645
+
646
+ function applyTestChange(test: Test, change: PlanDelta['tests'][string], used: string[]) {
647
+ if (change.action !== undefined) test.action = change.action
648
+ if (change.preconditions !== undefined) test.preconditions = change.preconditions
649
+ for (const [id, assertion] of Object.entries(change.assertions ?? {})) {
650
+ const position = test.assertions.findIndex((existing) => existing.id === id)
651
+ if (assertion === null) {
652
+ requireCondition(position >= 0, `Cannot remove unknown assertion ${test.id}.${id}`)
653
+ test.assertions.splice(position, 1)
654
+ } else if (position >= 0) test.assertions[position] = { ...test.assertions[position], ...assertion }
655
+ else {
656
+ requireCondition(
657
+ !used.includes(id),
658
+ `Assertion ID ${test.id}.${id} was used in an earlier revision. Use a new ID.`
659
+ )
660
+ requireCondition(
661
+ assertion.check !== undefined && assertion.checkKind !== undefined,
662
+ `New assertion ${test.id}.${id} needs check and checkKind`
663
+ )
664
+ test.assertions.push({ id, check: assertion.check, checkKind: assertion.checkKind })
665
+ }
666
+ }
667
+ }
668
+
669
+ export function approvePlan(store: Store, actor: string | undefined, input: Verdict) {
670
+ return decidePlan(store, actor, input, 'approved')
671
+ }
672
+
673
+ export function rejectPlan(store: Store, actor: string | undefined, input: Verdict) {
674
+ return decidePlan(store, actor, input, 'rejected')
675
+ }
676
+
677
+ function decidePlan(store: Store, actor: string | undefined, input: Verdict, verdict: 'approved' | 'rejected') {
678
+ const command = verdict === 'approved' ? 'plan approve' : 'plan reject'
679
+ return write(store, { actor, command, subject: input.reference, input }, (operationId, at, actor) => {
680
+ const reference = revisionReference(requireText(input.reference, 'plan reference'), 'plan reference')
681
+ requireCondition(reference.revision !== null, `Name the plan revision: ${reference.id}@<revision>`)
682
+ const revision = planRevision(store, reference.id, reference.revision)
683
+ const subject = `${reference.id}@${reference.revision}`
684
+ expectDigest(revision.sha256, input.expectSha256, subject)
685
+ return {
686
+ reply: recordApproval(store, actor, operationId, {
687
+ kind: 'plan',
688
+ subject,
689
+ sha256: revision.sha256,
690
+ verdict,
691
+ rationale: input.rationale,
692
+ }),
693
+ subjects: [`plan:${reference.id}`, 'inbox'],
694
+ }
695
+ })
696
+ }
697
+
698
+ export function recordApproval(
699
+ store: Store,
700
+ actor: string,
701
+ operationId: string,
702
+ input: {
703
+ kind: 'plan' | 'result'
704
+ subject: string
705
+ sha256: string
706
+ verdict: 'approved' | 'rejected'
707
+ rationale: string
708
+ }
709
+ ): Reply {
710
+ const rationale = requireText(input.rationale, 'rationale (--rationale)')
711
+ const id = ulid()
712
+ execute(
713
+ store,
714
+ 'INSERT INTO approvals (id, subject_kind, subject_id, subject_sha256, verdict, rationale, actor, operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
715
+ id,
716
+ input.kind,
717
+ input.subject,
718
+ input.sha256,
719
+ input.verdict,
720
+ rationale,
721
+ actor,
722
+ operationId
723
+ )
724
+ return completed(input.subject, { approvalId: id, verdict: input.verdict, sha256: input.sha256 })
725
+ }
726
+
727
+ export type Approval = {
728
+ verdict: 'approved' | 'rejected'
729
+ sha256: string
730
+ actor: string
731
+ rationale: string
732
+ at: string
733
+ }
734
+
735
+ export function latestApproval(store: Store, kind: 'plan' | 'result', subject: string) {
736
+ return one<Approval>(
737
+ store,
738
+ `SELECT a.verdict, a.subject_sha256 AS sha256, a.actor, a.rationale, o.at FROM approvals a
739
+ JOIN operations o ON o.id = a.operation_id
740
+ WHERE a.subject_kind = ? AND a.subject_id = ? ORDER BY a.rowid DESC LIMIT 1`,
741
+ kind,
742
+ subject
743
+ )
744
+ }
745
+
746
+ export function approvalState(
747
+ store: Store,
748
+ kind: 'plan' | 'result',
749
+ subject: string,
750
+ sha256: string,
751
+ policy: 'required' | 'none'
752
+ ) {
753
+ const latest = latestApproval(store, kind, subject) ?? null
754
+ const current = latest?.sha256 === sha256
755
+ return {
756
+ policy,
757
+ latest,
758
+ approved: current && latest?.verdict === 'approved',
759
+ rejected: current && latest?.verdict === 'rejected',
760
+ }
761
+ }
762
+
763
+ export function planApproval(store: Store, plan: Plan, sha256: string) {
764
+ const policy = approvalPolicy(targetRevision(store, plan.target.targetId, plan.target.revision).profile).plan
765
+ return approvalState(store, 'plan', `${plan.planId}@${plan.revision}`, sha256, policy)
766
+ }
767
+
768
+ export function gate(store: Store, input: GateInput) {
769
+ const reference = revisionReference(requireText(input.reference, 'plan reference'), 'plan reference')
770
+ requireCondition(reference.revision !== null, `Name the plan revision: ${reference.id}@<revision>`)
771
+ const revision = planRevision(store, reference.id, reference.revision)
772
+ const { plan } = revision
773
+ const subject = `${plan.planId}@${plan.revision}`
774
+ const target = targetRevision(store, plan.target.targetId, plan.target.revision)
775
+ const build = requireText(input.build, 'application build (--build)')
776
+ const partial = requireChoice(input.partial ?? 'wait_for_resolution', partialModes, 'partial execution (--partial)')
777
+ const tests = index(plan.tests, 'id', 'test')
778
+ const selection =
779
+ input.tests === undefined && partial === 'ready_only'
780
+ ? readySelection(store, plan, build)
781
+ : givenSelection(input.tests, tests, subject)
782
+ const selected = selection.tests
783
+ if (input.retestOf) retestCheck(store, input.retestOf, plan, build)
784
+ const reasons: string[] = []
785
+ if (!selected.length) reasons.push(`No test is ready in ${subject}`)
786
+ if (!target.profile.readyForLiveExecution)
787
+ reasons.push(`Target prerequisites are not ready: ${plan.target.targetId}@${plan.target.revision}`)
788
+ const approval = planApproval(store, plan, revision.sha256)
789
+ if (approval.policy === 'required' && !approval.approved) reasons.push(`Plan ${subject} is not approved`)
790
+ if (partial === 'wait_for_resolution')
791
+ for (const gap of plan.gaps)
792
+ if (gap.status === 'open' && gap.severity === 'blocking') reasons.push(`Open blocking gap: ${gap.id}`)
793
+ for (const id of selected) {
794
+ const test = tests.get(id)!
795
+ if (test.readiness !== 'ready') reasons.push(`Test is not ready: ${id}`)
796
+ if (!target.profile.capabilities.includes(test.capability))
797
+ reasons.push(`Missing capability: ${test.capability}`)
798
+ if (!test.operations.every((operation) => target.profile.allowedOperations.includes(operation)))
799
+ reasons.push(`Operation is not permitted: ${id}`)
800
+ if (!test.assertions.length) reasons.push(`Test has no assertions: ${id}`)
801
+ for (const dependency of test.dependsOn)
802
+ if (!dependency.includes(':') && !selected.includes(dependency))
803
+ reasons.push(`Missing prerequisite: ${dependency}`)
804
+ }
805
+ for (const id of selected)
806
+ for (const dependency of tests.get(id)!.dependsOn.filter((dependency) => dependency.includes(':'))) {
807
+ const cause = dependencyBlock(store, dependency, build)
808
+ if (cause) reasons.push(`Blocked by ${dependency}: ${cause}`)
809
+ }
810
+ return {
811
+ reply: {
812
+ status: reasons.length ? ('needs_decision' as const) : ('completed' as const),
813
+ subject,
814
+ reasons,
815
+ data: {
816
+ planId: plan.planId,
817
+ revision: plan.revision,
818
+ sha256: revision.sha256,
819
+ tests: selected,
820
+ leftOut: selection.leftOut,
821
+ },
822
+ },
823
+ tests: selected,
824
+ revision,
825
+ target,
826
+ build,
827
+ partial,
828
+ }
829
+ }
830
+
831
+ type LeftOut = { testId: string; reason: string }
832
+
833
+ function givenSelection(selected: string[] | undefined, tests: Map<string, Test>, subject: string) {
834
+ requireCondition(
835
+ Array.isArray(selected) && selected.length > 0,
836
+ 'Select one or more tests with --tests, or add --partial ready_only'
837
+ )
838
+ requireCondition(new Set(selected).size === selected.length, 'Duplicate test in --tests')
839
+ references(selected, tests, `test in ${subject}`)
840
+ return { tests: selected, leftOut: [] as LeftOut[] }
841
+ }
842
+
843
+ function readySelection(store: Store, plan: Plan, build: string) {
844
+ const leftOut: LeftOut[] = []
845
+ for (const test of plan.tests) {
846
+ const reason = notReadyReason(store, plan, test, build)
847
+ if (reason) leftOut.push({ testId: test.id, reason })
848
+ }
849
+ let selected = plan.tests.map((test) => test.id).filter((id) => !leftOut.some((item) => item.testId === id))
850
+ for (let changed = true; changed;) {
851
+ changed = false
852
+ for (const test of plan.tests.filter((test) => selected.includes(test.id))) {
853
+ const missing = test.dependsOn.find((id) => !id.includes(':') && !selected.includes(id))
854
+ if (!missing) continue
855
+ leftOut.push({ testId: test.id, reason: `needs ${missing}, which is left out` })
856
+ selected = selected.filter((id) => id !== test.id)
857
+ changed = true
858
+ }
859
+ }
860
+ const order = plan.tests.map((test) => test.id)
861
+ leftOut.sort((a, b) => order.indexOf(a.testId) - order.indexOf(b.testId))
862
+ return { tests: selected, leftOut }
863
+ }
864
+
865
+ function notReadyReason(store: Store, plan: Plan, test: Test, build: string) {
866
+ if (test.readiness !== 'ready') {
867
+ const gaps = plan.gaps.filter(
868
+ (gap) =>
869
+ test.gapIds.includes(gap.id) &&
870
+ (test.readiness === 'deferred'
871
+ ? gap.status === 'deferred' && gap.severity === 'blocking'
872
+ : gap.status === 'open' && gap.severity === 'blocking')
873
+ )
874
+ return `${test.readiness} by ${gaps.map((gap) => gap.id).join(', ')}`
875
+ }
876
+ for (const dependency of test.dependsOn.filter((id) => id.includes(':'))) {
877
+ const cause = dependencyBlock(store, dependency, build)
878
+ if (cause) return `blocked by ${dependency}: ${cause}`
879
+ }
880
+ return null
881
+ }
882
+
883
+ function retestCheck(store: Store, runId: string, plan: Plan, build: string) {
884
+ const previous = one<{
885
+ plan_id: string
886
+ target_id: string
887
+ target_revision: number
888
+ application_build: string
889
+ finished: number
890
+ }>(
891
+ store,
892
+ 'SELECT r.plan_id, r.target_id, r.target_revision, r.application_build, s.run_id IS NOT NULL AS finished FROM runs r LEFT JOIN results s ON s.run_id = r.run_id WHERE r.run_id = ?',
893
+ runId
894
+ )
895
+ requireCondition(previous, `Unknown run to retest: ${runId}`)
896
+ requireCondition(previous.finished, `Run ${runId} is not finished. Retest a finished run.`)
897
+ requireCondition(
898
+ previous.plan_id === plan.planId &&
899
+ previous.target_id === plan.target.targetId &&
900
+ previous.target_revision === plan.target.revision,
901
+ `Retest scope changed: ${runId} ran plan ${previous.plan_id} on ${previous.target_id}@${previous.target_revision}`
902
+ )
903
+ requireCondition(
904
+ previous.application_build !== build,
905
+ `Retest needs a new build. Run ${runId} used build ${build}.`
906
+ )
907
+ }
908
+
909
+ export function dependencyBlock(store: Store, reference: string, build: string | null): string | null {
910
+ const { planId, testId } = testReference(reference)
911
+ const result = one<{ run_id: string; target_id: string; target_revision: number; sha256: string; outcome: string }>(
912
+ store,
913
+ `SELECT r.run_id, r.target_id, r.target_revision, s.sha256,
914
+ (SELECT t.value ->> '$.outcome' FROM json_each(s.body, '$.tests') t WHERE t.value ->> '$.testId' = ?) AS outcome
915
+ FROM runs r JOIN results s ON s.run_id = r.run_id
916
+ WHERE r.plan_id = ? AND (? IS NULL OR r.application_build = ?)
917
+ AND EXISTS (SELECT 1 FROM json_each(s.body, '$.selectedTestIds') WHERE value = ?)
918
+ ORDER BY s.rowid DESC LIMIT 1`,
919
+ testId,
920
+ planId,
921
+ build,
922
+ build,
923
+ testId
924
+ )
925
+ if (!result) return build === null ? 'no finalized result' : `no finalized result for build ${build}`
926
+ if (result.outcome !== 'passed') return `latest result ${result.run_id} shows ${result.outcome}`
927
+ const policy = approvalPolicy(targetRevision(store, result.target_id, result.target_revision).profile).result
928
+ if (policy === 'required' && !approvalState(store, 'result', result.run_id, result.sha256, policy).approved)
929
+ return `result ${result.run_id} is not accepted`
930
+ for (const [owner, plan] of latestPlans(store)) {
931
+ const open = plan.gaps.filter((gap) => gap.status === 'open' && gap.severity === 'blocking')
932
+ const test = plan.tests.find((test) => test.id === testId)
933
+ const gap =
934
+ owner === planId
935
+ ? test && gapsAffecting(open, test)[0]
936
+ : open.find((gap) => gap.testIds.includes(reference))
937
+ if (gap) return `open blocking gap ${gap.id} in ${owner}`
938
+ }
939
+ return null
940
+ }
941
+
942
+ export function planChanges(before: Plan, after: Plan): string[] {
943
+ const lines: string[] = []
944
+ const fields = (prefix: string, a: object, b: object, skip: string[] = []) => {
945
+ const x = a as Record<string, unknown>
946
+ const y = b as Record<string, unknown>
947
+ for (const key of [...new Set([...Object.keys(x), ...Object.keys(y)])].sort())
948
+ if (!skip.includes(key) && !same(x[key], y[key]))
949
+ lines.push(`${prefix} ${key}: ${JSON.stringify(x[key])} → ${JSON.stringify(y[key])}`)
950
+ }
951
+ for (const test of after.tests) {
952
+ const old = before.tests.find((item) => item.id === test.id)
953
+ if (!old) {
954
+ lines.push(`${test.id} added`)
955
+ continue
956
+ }
957
+ fields(test.id, old, test, ['assertions'])
958
+ for (const assertion of old.assertions)
959
+ if (!test.assertions.some((item) => item.id === assertion.id))
960
+ lines.push(`${test.id}.${assertion.id} removed`)
961
+ for (const assertion of test.assertions) {
962
+ const previous = old.assertions.find((item) => item.id === assertion.id)
963
+ if (previous) fields(`${test.id}.${assertion.id}`, previous, assertion)
964
+ else lines.push(`${test.id}.${assertion.id} added: ${JSON.stringify(assertion.check)}`)
965
+ }
966
+ }
967
+ for (const gap of after.gaps) {
968
+ const old = before.gaps.find((item) => item.id === gap.id)
969
+ if (old) fields(gap.id, old, gap)
970
+ else lines.push(`${gap.id} added`)
971
+ }
972
+ for (const decision of after.decisions.filter((item) => !before.decisions.some((old) => old.id === item.id)))
973
+ lines.push(`decision ${decision.id} ${decision.kind} ${decision.gapId}: ${JSON.stringify(decision.value)}`)
974
+ for (const assumption of after.assumptions.filter(
975
+ (item) => !before.assumptions.some((old) => old.decisionId === item.decisionId)
976
+ ))
977
+ lines.push(`assumption ${assumption.decisionId}: ${JSON.stringify(assumption.text)}`)
978
+ for (const row of after.coverage) {
979
+ const old = before.coverage.find((item) => item.requirementId === row.requirementId)
980
+ if (old && old.status !== row.status)
981
+ lines.push(`${row.requirementId} coverage: ${JSON.stringify(old.status)} → ${JSON.stringify(row.status)}`)
982
+ }
983
+ return lines
984
+ }
985
+
986
+ export function jsonPatch(before: unknown, after: unknown, path = ''): PatchOperation[] {
987
+ if (same(before, after)) return []
988
+ if (Array.isArray(before) && Array.isArray(after)) {
989
+ const common = Math.min(before.length, after.length)
990
+ const operations: PatchOperation[] = []
991
+ for (let i = 0; i < common; i++) operations.push(...jsonPatch(before[i], after[i], `${path}/${i}`))
992
+ for (let i = common; i < after.length; i++)
993
+ operations.push({ op: 'add', path: `${path}/${i}`, value: after[i] })
994
+ for (let i = before.length - 1; i >= after.length; i--) operations.push({ op: 'remove', path: `${path}/${i}` })
995
+ return operations
996
+ }
997
+ if (isRecord(before) && isRecord(after)) {
998
+ const operations: PatchOperation[] = []
999
+ const pointer = (key: string) => `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`
1000
+ for (const key of Object.keys(before).sort())
1001
+ if (!(key in after)) operations.push({ op: 'remove', path: pointer(key) })
1002
+ for (const key of Object.keys(after).sort())
1003
+ if (key in before) operations.push(...jsonPatch(before[key], after[key], pointer(key)))
1004
+ else operations.push({ op: 'add', path: pointer(key), value: after[key] })
1005
+ return operations
1006
+ }
1007
+ return [{ op: 'replace', path, value: after }]
1008
+ }
1009
+
1010
+ function isRecord(value: unknown): value is Record<string, unknown> {
1011
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1012
+ }
1013
+
1014
+ export function diffPlan(store: Store, input: { planId: string; from: string; to: string }) {
1015
+ const revision = (text: string) => {
1016
+ requireCondition(/^[1-9][0-9]*$/.test(text), `Invalid revision: ${text}. Use a positive integer.`)
1017
+ return planRevision(store, input.planId, Number(text)).plan
1018
+ }
1019
+ const before = revision(input.from)
1020
+ const after = revision(input.to)
1021
+ return completed(`${input.planId}@${before.revision}..${after.revision}`, {
1022
+ planId: input.planId,
1023
+ from: before.revision,
1024
+ to: after.revision,
1025
+ patch: jsonPatch(before, after),
1026
+ changes: planChanges(before, after),
1027
+ })
1028
+ }