@intentius/chant 0.124.27 → 0.124.28

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 (450) hide show
  1. package/README.md +1 -1
  2. package/dist/__golden__/wave-loop/fan-out-gated.json +8 -0
  3. package/dist/__golden__/wave-loop/fan-out-no-members.json +4 -0
  4. package/dist/__golden__/wave-loop/fan-out-only.json +6 -0
  5. package/dist/__golden__/wave-loop/op-by-apply-hold.json +2 -0
  6. package/dist/__golden__/wave-loop/op-cost-member.json +3 -0
  7. package/dist/__golden__/wave-loop/op-held-on-before-apply.json +1 -0
  8. package/dist/__golden__/wave-loop/op-held-on.json +2 -0
  9. package/dist/__golden__/wave-loop/op-hooks-held.json +2 -0
  10. package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +2 -0
  11. package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +3 -0
  12. package/dist/__golden__/wave-loop/op-hooks-share.json +3 -0
  13. package/dist/__golden__/wave-loop/op-per-run.json +7 -0
  14. package/dist/__golden__/wave-loop/op-pr-review.json +7 -0
  15. package/dist/__golden__/wave-loop/op-sealed.json +3 -0
  16. package/dist/__golden__/wave-loop/op-spend.json +3 -0
  17. package/dist/__golden__/wave-loop/op-wave-commands.json +7 -0
  18. package/dist/__golden__/wave-loop/op-wide-waiting.json +4 -0
  19. package/dist/change-set-graph.d.ts +56 -0
  20. package/dist/change-set-graph.d.ts.map +1 -0
  21. package/dist/change-set-graph.js +128 -0
  22. package/dist/change-set.d.ts +52 -3
  23. package/dist/change-set.d.ts.map +1 -1
  24. package/dist/change-set.js +35 -4
  25. package/dist/chant-version.d.ts +9 -0
  26. package/dist/chant-version.d.ts.map +1 -0
  27. package/dist/chant-version.js +16 -0
  28. package/dist/cli/commands/build.d.ts +8 -0
  29. package/dist/cli/commands/build.d.ts.map +1 -1
  30. package/dist/cli/commands/build.js +9 -8
  31. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  32. package/dist/cli/commands/check-lexicon-docs.js +30 -2
  33. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  34. package/dist/cli/commands/check-lexicon.js +13 -2
  35. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +4 -1
  36. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  37. package/dist/cli/commands/init-lexicon/templates/docs.js +6 -6
  38. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  39. package/dist/cli/commands/init-lexicon.js +17 -8
  40. package/dist/cli/handlers/approve.d.ts.map +1 -1
  41. package/dist/cli/handlers/approve.js +19 -4
  42. package/dist/cli/handlers/build.d.ts.map +1 -1
  43. package/dist/cli/handlers/build.js +28 -0
  44. package/dist/cli/handlers/change-set.d.ts +10 -0
  45. package/dist/cli/handlers/change-set.d.ts.map +1 -1
  46. package/dist/cli/handlers/change-set.js +51 -1
  47. package/dist/cli/handlers/fan-out.d.ts +1 -1
  48. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  49. package/dist/cli/handlers/fan-out.js +14 -3
  50. package/dist/cli/handlers/pr.d.ts +8 -2
  51. package/dist/cli/handlers/pr.d.ts.map +1 -1
  52. package/dist/cli/handlers/pr.js +148 -24
  53. package/dist/cli/handlers/promote.d.ts.map +1 -1
  54. package/dist/cli/handlers/promote.js +256 -21
  55. package/dist/cli/handlers/release.d.ts +34 -0
  56. package/dist/cli/handlers/release.d.ts.map +1 -0
  57. package/dist/cli/handlers/release.js +203 -0
  58. package/dist/cli/handlers/run.d.ts +28 -2
  59. package/dist/cli/handlers/run.d.ts.map +1 -1
  60. package/dist/cli/handlers/run.js +170 -13
  61. package/dist/cli/main.d.ts.map +1 -1
  62. package/dist/cli/main.js +88 -13
  63. package/dist/cli/mcp/workspace-tools.js +1 -1
  64. package/dist/cli/parse-args.d.ts.map +1 -1
  65. package/dist/cli/parse-args.js +49 -3
  66. package/dist/cli/registry.d.ts +29 -3
  67. package/dist/cli/registry.d.ts.map +1 -1
  68. package/dist/cli/run-events.d.ts +29 -0
  69. package/dist/cli/run-events.d.ts.map +1 -0
  70. package/dist/cli/run-events.js +47 -0
  71. package/dist/cli/version.d.ts +3 -5
  72. package/dist/cli/version.d.ts.map +1 -1
  73. package/dist/cli/version.js +3 -13
  74. package/dist/codegen/docs-in-site.d.ts +71 -0
  75. package/dist/codegen/docs-in-site.d.ts.map +1 -0
  76. package/dist/codegen/docs-in-site.js +177 -0
  77. package/dist/codegen/docs-types.d.ts +14 -1
  78. package/dist/codegen/docs-types.d.ts.map +1 -1
  79. package/dist/codegen/docs.d.ts +18 -1
  80. package/dist/codegen/docs.d.ts.map +1 -1
  81. package/dist/codegen/docs.js +40 -7
  82. package/dist/codegen/rehype-base-url.mjs +15 -9
  83. package/dist/components/auto-release.d.ts +4 -1
  84. package/dist/components/auto-release.d.ts.map +1 -1
  85. package/dist/components/auto-release.js +2 -0
  86. package/dist/components/cli-support.d.ts +6 -0
  87. package/dist/components/cli-support.d.ts.map +1 -1
  88. package/dist/components/cli-support.js +10 -9
  89. package/dist/components/driver.d.ts +42 -9
  90. package/dist/components/driver.d.ts.map +1 -1
  91. package/dist/components/driver.js +81 -54
  92. package/dist/components/fan-out-run.d.ts +7 -0
  93. package/dist/components/fan-out-run.d.ts.map +1 -1
  94. package/dist/components/fan-out-run.js +35 -26
  95. package/dist/components/pr-run.d.ts +11 -0
  96. package/dist/components/pr-run.d.ts.map +1 -1
  97. package/dist/components/pr-run.js +20 -1
  98. package/dist/components/promote-order.d.ts +38 -0
  99. package/dist/components/promote-order.d.ts.map +1 -0
  100. package/dist/components/promote-order.js +62 -0
  101. package/dist/components/promote.d.ts +15 -2
  102. package/dist/components/promote.d.ts.map +1 -1
  103. package/dist/components/promote.js +17 -0
  104. package/dist/components/release-compile.d.ts +133 -0
  105. package/dist/components/release-compile.d.ts.map +1 -0
  106. package/dist/components/release-compile.js +376 -0
  107. package/dist/components/release-gate.d.ts +125 -0
  108. package/dist/components/release-gate.d.ts.map +1 -0
  109. package/dist/components/release-gate.js +282 -0
  110. package/dist/components/release-promote.d.ts +96 -0
  111. package/dist/components/release-promote.d.ts.map +1 -0
  112. package/dist/components/release-promote.js +204 -0
  113. package/dist/components/run-progress.d.ts +7 -0
  114. package/dist/components/run-progress.d.ts.map +1 -1
  115. package/dist/components/wave-plan.d.ts +3 -1
  116. package/dist/components/wave-plan.d.ts.map +1 -1
  117. package/dist/components/wave-plan.js +1 -1
  118. package/dist/config.d.ts +7 -0
  119. package/dist/config.d.ts.map +1 -1
  120. package/dist/config.js +1 -0
  121. package/dist/index.d.ts +1 -0
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +2 -0
  124. package/dist/lifecycle/change-set-store.d.ts +67 -0
  125. package/dist/lifecycle/change-set-store.d.ts.map +1 -0
  126. package/dist/lifecycle/change-set-store.js +96 -0
  127. package/dist/lifecycle/commit-sha.d.ts +13 -0
  128. package/dist/lifecycle/commit-sha.d.ts.map +1 -0
  129. package/dist/lifecycle/commit-sha.js +21 -0
  130. package/dist/lifecycle/gate-change-set.d.ts +69 -0
  131. package/dist/lifecycle/gate-change-set.d.ts.map +1 -0
  132. package/dist/lifecycle/gate-change-set.js +106 -0
  133. package/dist/lifecycle/gate-ledger.d.ts +49 -1
  134. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  135. package/dist/lifecycle/gate-ledger.js +18 -0
  136. package/dist/lifecycle/release-cut.d.ts +107 -0
  137. package/dist/lifecycle/release-cut.d.ts.map +1 -0
  138. package/dist/lifecycle/release-cut.js +273 -0
  139. package/dist/lifecycle/release-ledger.d.ts +54 -0
  140. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  141. package/dist/lifecycle/release-ledger.js +53 -20
  142. package/dist/lifecycle/release-store.d.ts +190 -0
  143. package/dist/lifecycle/release-store.d.ts.map +1 -0
  144. package/dist/lifecycle/release-store.js +318 -0
  145. package/dist/lifecycle/run-ledger.d.ts +4 -0
  146. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  147. package/dist/lifecycle/run-ledger.js +2 -0
  148. package/dist/op/activities/change-coverage.d.ts.map +1 -1
  149. package/dist/op/activities/change-coverage.js +4 -2
  150. package/dist/op/activities/source-release.d.ts.map +1 -1
  151. package/dist/op/activities/source-release.js +7 -3
  152. package/dist/op/activities/source-rollback.d.ts.map +1 -1
  153. package/dist/op/activities/source-rollback.js +7 -3
  154. package/dist/op/gate-name.d.ts +11 -2
  155. package/dist/op/gate-name.d.ts.map +1 -1
  156. package/dist/op/gate-name.js +12 -3
  157. package/dist/op/gate-run-locator.d.ts +22 -0
  158. package/dist/op/gate-run-locator.d.ts.map +1 -1
  159. package/dist/op/gate-run-locator.js +31 -0
  160. package/dist/op/gate.d.ts +27 -3
  161. package/dist/op/gate.d.ts.map +1 -1
  162. package/dist/op/gate.js +57 -3
  163. package/dist/op/local-executor.d.ts +36 -1
  164. package/dist/op/local-executor.d.ts.map +1 -1
  165. package/dist/op/local-executor.js +158 -52
  166. package/dist/op/op-waves-run.d.ts.map +1 -1
  167. package/dist/op/op-waves-run.js +4 -0
  168. package/dist/op/run-live.d.ts +2 -0
  169. package/dist/op/run-live.d.ts.map +1 -1
  170. package/dist/op/run-live.js +1 -1
  171. package/dist/op/runtime.d.ts +46 -0
  172. package/dist/op/runtime.d.ts.map +1 -1
  173. package/dist/op/runtimes/local.d.ts.map +1 -1
  174. package/dist/op/runtimes/local.js +8 -0
  175. package/dist/point-decision.d.ts +2 -0
  176. package/dist/point-decision.d.ts.map +1 -1
  177. package/dist/point-decision.js +9 -2
  178. package/dist/run-event-store.d.ts +77 -0
  179. package/dist/run-event-store.d.ts.map +1 -0
  180. package/dist/run-event-store.js +204 -0
  181. package/dist/run-event-stream.d.ts +132 -0
  182. package/dist/run-event-stream.d.ts.map +1 -0
  183. package/dist/run-event-stream.js +238 -0
  184. package/dist/run-event.d.ts +267 -0
  185. package/dist/run-event.d.ts.map +1 -0
  186. package/dist/run-event.js +207 -0
  187. package/dist/wave-runtime.mjs +204 -199
  188. package/dist/workspace/__fixtures__/run-event-check.d.ts +19 -0
  189. package/dist/workspace/__fixtures__/run-event-check.d.ts.map +1 -0
  190. package/dist/workspace/__fixtures__/run-event-check.js +59 -0
  191. package/dist/workspace/change-set-show.d.ts +56 -0
  192. package/dist/workspace/change-set-show.d.ts.map +1 -0
  193. package/dist/workspace/change-set-show.js +54 -0
  194. package/dist/workspace/change-set-show.schema.json +102 -0
  195. package/dist/workspace/change-set.schema.json +28 -1
  196. package/dist/workspace/changes-cli.d.ts.map +1 -1
  197. package/dist/workspace/changes-cli.js +10 -1
  198. package/dist/workspace/changes.d.ts +37 -0
  199. package/dist/workspace/changes.d.ts.map +1 -1
  200. package/dist/workspace/changes.js +49 -2
  201. package/dist/workspace/changes.schema.json +135 -0
  202. package/dist/workspace/ci-green-cli.d.ts +19 -4
  203. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  204. package/dist/workspace/ci-green-cli.js +75 -12
  205. package/dist/workspace/ci-green-forge.d.ts +20 -3
  206. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  207. package/dist/workspace/ci-green-forge.js +6 -4
  208. package/dist/workspace/ci-green-workflow.d.ts +11 -0
  209. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  210. package/dist/workspace/ci-green-workflow.js +6 -0
  211. package/dist/workspace/ci-green.d.ts +273 -13
  212. package/dist/workspace/ci-green.d.ts.map +1 -1
  213. package/dist/workspace/ci-green.js +449 -38
  214. package/dist/workspace/ci-last-green.schema.json +32 -2
  215. package/dist/workspace/ci-list.schema.json +525 -0
  216. package/dist/workspace/commit-links.d.ts +85 -0
  217. package/dist/workspace/commit-links.d.ts.map +1 -0
  218. package/dist/workspace/commit-links.js +163 -0
  219. package/dist/workspace/declaration.d.ts +19 -0
  220. package/dist/workspace/declaration.d.ts.map +1 -1
  221. package/dist/workspace/declaration.js +1 -1
  222. package/dist/workspace/declaration.schema.json +12 -0
  223. package/dist/workspace/identity-gate-rule.d.ts +37 -0
  224. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  225. package/dist/workspace/identity-gate-rule.js +72 -7
  226. package/dist/workspace/identity.d.ts +12 -0
  227. package/dist/workspace/identity.d.ts.map +1 -1
  228. package/dist/workspace/identity.js +13 -1
  229. package/dist/workspace/intent.d.ts +6 -1
  230. package/dist/workspace/intent.d.ts.map +1 -1
  231. package/dist/workspace/intent.js +51 -1
  232. package/dist/workspace/ls.d.ts +4 -0
  233. package/dist/workspace/ls.d.ts.map +1 -1
  234. package/dist/workspace/ls.js +2 -0
  235. package/dist/workspace/ls.schema.json +3 -1
  236. package/dist/workspace/member-pipeline.d.ts +6 -0
  237. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  238. package/dist/workspace/member-pipeline.js +6 -0
  239. package/dist/workspace/promotion-order.d.ts +41 -0
  240. package/dist/workspace/promotion-order.d.ts.map +1 -0
  241. package/dist/workspace/promotion-order.js +95 -0
  242. package/dist/workspace/reason-codes.d.ts +2 -0
  243. package/dist/workspace/reason-codes.d.ts.map +1 -1
  244. package/dist/workspace/reason-codes.js +4 -1
  245. package/dist/workspace/releases.d.ts +72 -0
  246. package/dist/workspace/releases.d.ts.map +1 -0
  247. package/dist/workspace/releases.js +94 -0
  248. package/dist/workspace/releases.schema.json +391 -0
  249. package/dist/workspace/run-event.schema.json +288 -0
  250. package/dist/workspace/status-gates.d.ts +26 -1
  251. package/dist/workspace/status-gates.d.ts.map +1 -1
  252. package/dist/workspace/status-gates.js +31 -2
  253. package/dist/workspace/status-stewards.d.ts +4 -0
  254. package/dist/workspace/status-stewards.d.ts.map +1 -1
  255. package/dist/workspace/status-stewards.js +2 -0
  256. package/dist/workspace/status.d.ts +39 -0
  257. package/dist/workspace/status.d.ts.map +1 -1
  258. package/dist/workspace/status.js +48 -1
  259. package/dist/workspace/status.schema.json +70 -1
  260. package/dist/workspace/trust/ci-tag-statement.d.ts +102 -0
  261. package/dist/workspace/trust/ci-tag-statement.d.ts.map +1 -0
  262. package/dist/workspace/trust/ci-tag-statement.js +123 -0
  263. package/dist/workspace/trust/op-run-statement.d.ts +91 -0
  264. package/dist/workspace/trust/op-run-statement.d.ts.map +1 -0
  265. package/dist/workspace/trust/op-run-statement.js +143 -0
  266. package/dist/workspace/trust/rotation.d.ts +18 -2
  267. package/dist/workspace/trust/rotation.d.ts.map +1 -1
  268. package/dist/workspace/trust/rotation.js +65 -16
  269. package/dist/workspace/trust/seal.d.ts +11 -0
  270. package/dist/workspace/trust/seal.d.ts.map +1 -1
  271. package/dist/workspace/trust/seal.js +9 -0
  272. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  273. package/dist/workspace/trust/ssh-commit.js +10 -10
  274. package/package.json +1 -1
  275. package/src/__golden__/wave-loop/fan-out-gated.json +8 -0
  276. package/src/__golden__/wave-loop/fan-out-no-members.json +4 -0
  277. package/src/__golden__/wave-loop/fan-out-only.json +6 -0
  278. package/src/__golden__/wave-loop/op-by-apply-hold.json +2 -0
  279. package/src/__golden__/wave-loop/op-cost-member.json +3 -0
  280. package/src/__golden__/wave-loop/op-held-on-before-apply.json +1 -0
  281. package/src/__golden__/wave-loop/op-held-on.json +2 -0
  282. package/src/__golden__/wave-loop/op-hooks-held.json +2 -0
  283. package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +2 -0
  284. package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +3 -0
  285. package/src/__golden__/wave-loop/op-hooks-share.json +3 -0
  286. package/src/__golden__/wave-loop/op-per-run.json +7 -0
  287. package/src/__golden__/wave-loop/op-pr-review.json +7 -0
  288. package/src/__golden__/wave-loop/op-sealed.json +3 -0
  289. package/src/__golden__/wave-loop/op-spend.json +3 -0
  290. package/src/__golden__/wave-loop/op-wave-commands.json +7 -0
  291. package/src/__golden__/wave-loop/op-wide-waiting.json +4 -0
  292. package/src/change-set-graph.test.ts +72 -0
  293. package/src/change-set-graph.ts +133 -0
  294. package/src/change-set.ts +76 -6
  295. package/src/chant-version.ts +16 -0
  296. package/src/cli/commands/build.ts +19 -8
  297. package/src/cli/commands/check-lexicon-docs.test.ts +51 -0
  298. package/src/cli/commands/check-lexicon-docs.ts +39 -2
  299. package/src/cli/commands/check-lexicon.ts +14 -2
  300. package/src/cli/commands/init-lexicon/templates/docs.ts +7 -6
  301. package/src/cli/commands/init-lexicon.test.ts +25 -0
  302. package/src/cli/commands/init-lexicon.ts +18 -8
  303. package/src/cli/handlers/approve-release.test.ts +80 -0
  304. package/src/cli/handlers/approve.ts +18 -2
  305. package/src/cli/handlers/build.test.ts +52 -0
  306. package/src/cli/handlers/build.ts +29 -0
  307. package/src/cli/handlers/change-set.ts +49 -1
  308. package/src/cli/handlers/fan-out.test.ts +48 -0
  309. package/src/cli/handlers/fan-out.ts +15 -3
  310. package/src/cli/handlers/operator.test.ts +37 -0
  311. package/src/cli/handlers/pr.test.ts +194 -0
  312. package/src/cli/handlers/pr.ts +168 -34
  313. package/src/cli/handlers/promote.test.ts +389 -5
  314. package/src/cli/handlers/promote.ts +279 -20
  315. package/src/cli/handlers/release-implicit.e2e.test.ts +153 -0
  316. package/src/cli/handlers/release.e2e.test.ts +85 -0
  317. package/src/cli/handlers/release.ts +206 -0
  318. package/src/cli/handlers/run-auto-release.test.ts +71 -0
  319. package/src/cli/handlers/run-events-cmd.test.ts +92 -0
  320. package/src/cli/handlers/run.test.ts +30 -0
  321. package/src/cli/handlers/run.ts +176 -15
  322. package/src/cli/main.test.ts +7 -0
  323. package/src/cli/main.ts +88 -13
  324. package/src/cli/mcp/never-over-mcp.test.ts +2 -0
  325. package/src/cli/mcp/workspace-tools.ts +1 -1
  326. package/src/cli/parse-args.ts +37 -3
  327. package/src/cli/registry.ts +29 -3
  328. package/src/cli/run-events.ts +54 -0
  329. package/src/cli/version.ts +3 -13
  330. package/src/codegen/docs-in-site.test.ts +166 -0
  331. package/src/codegen/docs-in-site.ts +182 -0
  332. package/src/codegen/docs-site/AgentPrompt.astro +26 -1
  333. package/src/codegen/docs-types.ts +14 -1
  334. package/src/codegen/docs.ts +60 -7
  335. package/src/codegen/rehype-base-url.d.mts +3 -1
  336. package/src/codegen/rehype-base-url.mjs +15 -9
  337. package/src/codegen/rehype-base-url.test.ts +34 -0
  338. package/src/components/auto-release.test.ts +21 -0
  339. package/src/components/auto-release.ts +6 -1
  340. package/src/components/cli-support.test.ts +82 -0
  341. package/src/components/cli-support.ts +16 -9
  342. package/src/components/driver.ts +129 -56
  343. package/src/components/fan-out-run.ts +56 -35
  344. package/src/components/pr-run.ts +30 -1
  345. package/src/components/promote-order.test.ts +90 -0
  346. package/src/components/promote-order.ts +75 -0
  347. package/src/components/promote.test.ts +10 -1
  348. package/src/components/promote.ts +26 -1
  349. package/src/components/release-compile.test.ts +147 -0
  350. package/src/components/release-compile.ts +445 -0
  351. package/src/components/release-gate.test.ts +215 -0
  352. package/src/components/release-gate.ts +341 -0
  353. package/src/components/release-promote.test.ts +159 -0
  354. package/src/components/release-promote.ts +232 -0
  355. package/src/components/run-progress.ts +7 -0
  356. package/src/components/wave-plan.ts +1 -1
  357. package/src/config.ts +7 -1
  358. package/src/index.ts +2 -0
  359. package/src/lifecycle/change-set-store.test.ts +217 -0
  360. package/src/lifecycle/change-set-store.ts +112 -0
  361. package/src/lifecycle/commit-sha.ts +23 -0
  362. package/src/lifecycle/gate-change-set.e2e.test.ts +122 -0
  363. package/src/lifecycle/gate-change-set.ts +133 -0
  364. package/src/lifecycle/gate-ledger.test.ts +33 -0
  365. package/src/lifecycle/gate-ledger.ts +66 -1
  366. package/src/lifecycle/release-cut-signed.test.ts +180 -0
  367. package/src/lifecycle/release-cut.test.ts +411 -0
  368. package/src/lifecycle/release-cut.ts +367 -0
  369. package/src/lifecycle/release-ledger.test.ts +14 -0
  370. package/src/lifecycle/release-ledger.ts +85 -17
  371. package/src/lifecycle/release-store.ts +423 -0
  372. package/src/lifecycle/run-ledger.test.ts +10 -0
  373. package/src/lifecycle/run-ledger.ts +6 -0
  374. package/src/op/activities/change-coverage.ts +4 -1
  375. package/src/op/activities/source-release.ts +8 -3
  376. package/src/op/activities/source-rollback.ts +8 -4
  377. package/src/op/commit-run-gate-deploy-join.test.ts +117 -0
  378. package/src/op/gate-name.ts +13 -3
  379. package/src/op/gate-release.test.ts +126 -0
  380. package/src/op/gate-run-locator.test.ts +110 -0
  381. package/src/op/gate-run-locator.ts +35 -0
  382. package/src/op/gate.ts +88 -6
  383. package/src/op/local-executor.ts +207 -52
  384. package/src/op/op-waves-run.ts +4 -0
  385. package/src/op/run-events.test.ts +166 -0
  386. package/src/op/run-live.ts +1 -1
  387. package/src/op/runtime.ts +39 -0
  388. package/src/op/runtimes/local.test.ts +16 -0
  389. package/src/op/runtimes/local.ts +9 -0
  390. package/src/point-decision.ts +12 -2
  391. package/src/run-event-store.test.ts +144 -0
  392. package/src/run-event-store.ts +213 -0
  393. package/src/run-event-stream.test.ts +175 -0
  394. package/src/run-event-stream.ts +306 -0
  395. package/src/run-event.test.ts +277 -0
  396. package/src/run-event.ts +451 -0
  397. package/src/wave-loop-golden.test.ts +9 -0
  398. package/src/workspace/__fixtures__/run-event-check.ts +58 -0
  399. package/src/workspace/ancestry-index.test.ts +93 -0
  400. package/src/workspace/change-set-show.schema.json +102 -0
  401. package/src/workspace/change-set-show.ts +72 -0
  402. package/src/workspace/change-set.schema.json +28 -1
  403. package/src/workspace/changes-cli.ts +10 -1
  404. package/src/workspace/changes.schema.json +135 -0
  405. package/src/workspace/changes.test.ts +1 -1
  406. package/src/workspace/changes.ts +84 -2
  407. package/src/workspace/ci-green-cli.ts +75 -13
  408. package/src/workspace/ci-green-forge.ts +24 -4
  409. package/src/workspace/ci-green-runs.test.ts +242 -0
  410. package/src/workspace/ci-green-signed.test.ts +302 -0
  411. package/src/workspace/ci-green-workflow.test.ts +16 -0
  412. package/src/workspace/ci-green-workflow.ts +12 -0
  413. package/src/workspace/ci-green.test.ts +131 -2
  414. package/src/workspace/ci-green.ts +624 -42
  415. package/src/workspace/ci-last-green.schema.json +32 -2
  416. package/src/workspace/ci-list.schema.json +525 -0
  417. package/src/workspace/commit-links.test.ts +279 -0
  418. package/src/workspace/commit-links.ts +183 -0
  419. package/src/workspace/declaration.schema.json +12 -0
  420. package/src/workspace/declaration.ts +22 -2
  421. package/src/workspace/identity-gate-rule.ts +91 -7
  422. package/src/workspace/identity.ts +14 -1
  423. package/src/workspace/intent.ts +48 -1
  424. package/src/workspace/ls.schema.json +3 -1
  425. package/src/workspace/ls.ts +6 -0
  426. package/src/workspace/member-pipeline.test.ts +17 -0
  427. package/src/workspace/member-pipeline.ts +9 -0
  428. package/src/workspace/promotion-order.ts +97 -0
  429. package/src/workspace/read-contract.e2e.test.ts +4 -1
  430. package/src/workspace/reason-codes.test.ts +2 -1
  431. package/src/workspace/reason-codes.ts +4 -1
  432. package/src/workspace/releases.schema.json +391 -0
  433. package/src/workspace/releases.test.ts +108 -0
  434. package/src/workspace/releases.ts +122 -0
  435. package/src/workspace/run-event.schema.json +288 -0
  436. package/src/workspace/status-contract.test.ts +52 -1
  437. package/src/workspace/status-gates.ts +56 -1
  438. package/src/workspace/status-stewards.ts +6 -0
  439. package/src/workspace/status.schema.json +70 -1
  440. package/src/workspace/status.ts +80 -1
  441. package/src/workspace/trust/ci-tag-statement.ts +151 -0
  442. package/src/workspace/trust/op-run-statement.ts +148 -0
  443. package/src/workspace/trust/rotation.ts +61 -14
  444. package/src/workspace/trust/seal.ts +10 -0
  445. package/src/workspace/trust/ssh-commit.ts +10 -9
  446. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -14
  447. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/package.json +0 -16
  448. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/index.mdx +0 -8
  449. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +0 -15
  450. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/tsconfig.json +0 -10
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Which work item a commit belongs to (#4029): the one judgement, and where
3
+ * `workspace check --changes`, `ci list` and `ci tick` report it.
4
+ *
5
+ * - c0 declares the workspace, the decision s-001 and the work items W-001
6
+ * and W-002, and a ci.green block.
7
+ * - c1 carries Chant-Record: work:W-001.
8
+ * - c2 carries only a Chant-Lease, whose token the lease history of W-002
9
+ * on chant/lifecycle names.
10
+ * - c3 carries nothing.
11
+ * - c4 names work:W-999, which no record has.
12
+ * - c5 names decision:s-001, a record of a kind that is not the work kind.
13
+ * - c6 carries a Chant-Lease no lease history names.
14
+ * - c7 carries a Chant-Record that is not <kind>:<id>.
15
+ * - c8 names decision:s-001 and work:W-002, so the decision beside the work
16
+ * item changes nothing.
17
+ * - m is a merge of a branch whose commit is not on the first-parent line.
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
23
+ import { writeBlobToPath } from "../lifecycle/git";
24
+ import { cleanScratch, contract, git, REPO, repo, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
25
+ import { checkChanges, type ChangesDocument } from "./changes";
26
+ import { formatChanges } from "./changes-cli";
27
+ import changesSchema from "./changes.schema.json";
28
+ import { judgeCommitLink, type WorkIndex } from "./commit-links";
29
+ import { ciList, ciTick, formatList, formatTick, whyWorkUnknown, type CiListDocument } from "./ci-green";
30
+ import type { CiForge } from "./ci-green-forge";
31
+ import ciListSchema from "./ci-list.schema.json";
32
+ import { parseFrontMatter } from "./records";
33
+
34
+ const REF = join(REPO, "reference-workspace");
35
+ const DECISION = (() => {
36
+ const fm = parseFrontMatter(readFileSync(join(REF, "decisions", "ref-001-how-the-app-is-deployed.md"), "utf-8"));
37
+ if (!fm.ok) throw new Error(fm.message);
38
+ return fm.value;
39
+ })();
40
+
41
+ const decision = (id: string) => `---\n${JSON.stringify({ ...DECISION, id, title: `Decision ${id}`, state: "decided", supersedes: [], evidence: [], constrains: ["path:app"] }, null, 2)}\n---\n\n# ${id}\n`;
42
+ const work = (id: string) => `---\n${JSON.stringify({ schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: ["path:app"], evidence: [], opened_on: "2026-10-01", source: { kind: "workspace", member: "app" }, supersedes: [] }, null, 2)}\n---\n\n# ${id}\n`;
43
+
44
+ const TOKEN = "7a1e5c90-lease-token";
45
+ const NOW = new Date("2026-10-01T13:00:00Z");
46
+ const changes = contract(changesSchema);
47
+ const list = contract(ciListSchema);
48
+ type Result = Exclude<ChangesDocument, { error: unknown }>;
49
+ type Listed = Extract<CiListDocument, { branch: string }>;
50
+
51
+ let root: string;
52
+ let origin: string;
53
+ const sha: Record<string, string> = {};
54
+ let tick = 0;
55
+
56
+ function commit(message: string[]): string {
57
+ const date = `2026-10-01T12:00:${String(tick++).padStart(2, "0")}Z`;
58
+ process.env.GIT_AUTHOR_DATE = date;
59
+ process.env.GIT_COMMITTER_DATE = date;
60
+ try {
61
+ git(root, "add", "-A");
62
+ git(root, "commit", "-q", ...message.flatMap((m) => ["-m", m]));
63
+ } finally {
64
+ delete process.env.GIT_AUTHOR_DATE;
65
+ delete process.env.GIT_COMMITTER_DATE;
66
+ }
67
+ return git(root, "rev-parse", "HEAD");
68
+ }
69
+
70
+ function edit(name: string, message: string[]): string {
71
+ writeFiles(root, { "app/main.js": `export const n = ${tick};\n` });
72
+ return (sha[name] = commit(message));
73
+ }
74
+
75
+ beforeAll(async () => {
76
+ root = repo({
77
+ "chant.workspace.json": JSON.stringify({
78
+ name: "links",
79
+ schema: 1,
80
+ members: [{ name: "app", dir: "app", kind: "other", because: "the app" }],
81
+ records: [{ kind: "decisions/decision.kind.mjs" }, { kind: "work/work.kind.mjs" }],
82
+ ci: { green: { branch: "main", phases: { lint: ["lint"] }, require: ["lint"] } },
83
+ }),
84
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
85
+ "decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
86
+ "decisions/s-001-app.md": decision("s-001"),
87
+ "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
88
+ "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
89
+ "work/W-001-one.md": work("W-001"),
90
+ "work/W-002-two.md": work("W-002"),
91
+ "app/main.js": "export const n = 0;\n",
92
+ });
93
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
94
+ sha.c0 = commit(["the workspace"]);
95
+ edit("c1", ["one", "Chant-Record: work:W-001"]);
96
+ edit("c2", ["two", `Chant-Lease: ${TOKEN}`]);
97
+ edit("c3", ["three"]);
98
+ edit("c4", ["four", "Chant-Record: work:W-999"]);
99
+ edit("c5", ["five", "Chant-Record: decision:s-001"]);
100
+ edit("c6", ["six", "Chant-Lease: no-such-token"]);
101
+ edit("c7", ["seven", "Chant-Record: nonsense"]);
102
+ edit("c8", ["eight", "Chant-Record: decision:s-001\nChant-Record: work:W-002"]);
103
+ git(root, "checkout", "-q", "-b", "side");
104
+ writeFiles(root, { "app/side.js": "export const side = 1;\n" });
105
+ commit(["on a side branch"]);
106
+ git(root, "checkout", "-q", "main");
107
+ process.env.GIT_AUTHOR_DATE = process.env.GIT_COMMITTER_DATE = "2026-10-01T12:30:00Z";
108
+ try {
109
+ git(root, "merge", "-q", "--no-ff", "-m", "Merge side", "side");
110
+ } finally {
111
+ delete process.env.GIT_AUTHOR_DATE;
112
+ delete process.env.GIT_COMMITTER_DATE;
113
+ }
114
+ sha.m = git(root, "rev-parse", "HEAD");
115
+ const line = { version: 1, event: "claim", item: "W-002", holder: "factory", by: "factory", token: TOKEN, acquiredAt: "2026-10-01T12:00:00Z", expiresAt: "2026-10-01T12:10:00Z", timestamp: "2026-10-01T12:00:00Z" };
116
+ await writeBlobToPath("_leases", "W-002.jsonl", JSON.stringify(line), "Work lease claim: W-002", { cwd: root });
117
+ });
118
+ afterAll(cleanScratch);
119
+
120
+ const RANGE = () => `${sha.c0}..${sha.m}`;
121
+
122
+ async function check(severity?: "off" | "warn" | "fail"): Promise<{ doc: Result; failed: boolean }> {
123
+ const { doc, failed } = await checkChanges({ cwd: root, range: RANGE(), ...(severity ? { severity } : {}) });
124
+ changes.expectValid(doc);
125
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
126
+ return { doc, failed };
127
+ }
128
+
129
+ describe("judging one commit's trailers", () => {
130
+ const index: WorkIndex = { kinds: new Map([["work", new Set(["W-1", "W-2"])]]), ledger: true };
131
+ const none = { records: [], lease: null };
132
+
133
+ test("a Chant-Record of a work kind with that record links, whatever else the commit names", () => {
134
+ expect(judgeCommitLink({ "Chant-Record": ["work:W-1"] }, { records: [{ kind: "work", id: "W-1" }], lease: null }, index)).toEqual({ state: "linked", ids: ["work:W-1"], problems: [] });
135
+ const both = judgeCommitLink({ "chant-record": ["decision:d-1", "work:W-2", "work:W-404"] }, { records: [{ kind: "decision", id: "d-1" }, { kind: "work", id: "W-2" }, { kind: "work", id: "W-404" }], lease: null }, index);
136
+ expect(both).toEqual({ state: "linked", ids: ["work:W-2"], problems: [] });
137
+ });
138
+
139
+ test("a Chant-Lease links through the work item its history names", () => {
140
+ expect(judgeCommitLink({ "Chant-Lease": ["t"] }, { records: [], lease: { token: "t", item: "W-2" } }, index)).toMatchObject({ state: "linked", ids: ["work:W-2"] });
141
+ expect(judgeCommitLink({ "Chant-Lease": ["t"] }, { records: [], lease: { token: "t", item: "W-404" } }, index)).toMatchObject({ state: "wrong" });
142
+ expect(judgeCommitLink({ "Chant-Lease": ["t"] }, { records: [], lease: { token: "t", item: null } }, index)).toMatchObject({ state: "wrong" });
143
+ });
144
+
145
+ test("a lease is not judged where the lease histories are not there to read", () => {
146
+ const link = judgeCommitLink({ "Chant-Lease": ["t"] }, { records: [], lease: { token: "t", item: null } }, { ...index, ledger: false });
147
+ expect(link).toEqual({ state: "linked", ids: [], problems: [] });
148
+ });
149
+
150
+ test("a commit with no trailer is unlinked, and a window match is not a trailer", () => {
151
+ expect(judgeCommitLink({}, none, index)).toEqual({ state: "unlinked", ids: [], problems: [] });
152
+ expect(judgeCommitLink({ "Chant-Agent": ["factory"], "Chant-Run": ["r-1"] }, none, index).state).toBe("unlinked");
153
+ });
154
+
155
+ test("each wrongly linked case says which trailer and why", () => {
156
+ const wrong = (value: string, ref: { kind: string; id: string } | null) => judgeCommitLink({ "Chant-Record": [value] }, { records: ref ? [ref] : [], lease: null }, index);
157
+ expect(wrong("work:W-9", { kind: "work", id: "W-9" })).toEqual({ state: "wrong", ids: [], problems: [{ trailer: "Chant-Record: work:W-9", message: "the work kind has no record W-9" }] });
158
+ expect(wrong("decision:d-1", { kind: "decision", id: "d-1" }).problems[0].message).toBe("decision is not a work kind (the work kinds are work)");
159
+ expect(wrong("nonsense", null).problems[0]).toEqual({ trailer: "Chant-Record: nonsense", message: "nonsense is not <kind>:<id>" });
160
+ });
161
+ });
162
+
163
+ describe("workspace check --changes (#4029)", () => {
164
+ test("judges the first-parent commits of the range, merges left out", async () => {
165
+ const { doc, failed } = await check();
166
+ expect(failed).toBe(false);
167
+ expect(doc.links!.kinds).toEqual(["work"]);
168
+ expect(doc.links!.commits.map((c) => [c.commit, c.state, c.ids])).toEqual([
169
+ [sha.c1, "linked", ["work:W-001"]],
170
+ [sha.c2, "linked", ["work:W-002"]],
171
+ [sha.c3, "unlinked", []],
172
+ [sha.c4, "wrong", []],
173
+ [sha.c5, "wrong", []],
174
+ [sha.c6, "wrong", []],
175
+ [sha.c7, "wrong", []],
176
+ [sha.c8, "linked", ["work:W-002"]],
177
+ ]);
178
+ expect(doc.links!.commits.find((c) => c.commit === sha.c4)!.problems).toEqual([{ trailer: "Chant-Record: work:W-999", message: "the work kind has no record W-999" }]);
179
+ expect(doc.links!.commits.find((c) => c.commit === sha.c5)!.problems[0].message).toContain("decision is not a work kind");
180
+ expect(doc.links!.commits.find((c) => c.commit === sha.c6)!.problems).toEqual([{ trailer: "Chant-Lease: no-such-token", message: "no lease history on chant/lifecycle names this token" }]);
181
+ expect(doc.links!.commits.find((c) => c.commit === sha.c7)!.problems[0].trailer).toBe("Chant-Record: nonsense");
182
+ });
183
+
184
+ test("reports an unlinked commit and each wrongly linked one with a fix line, at warn by default", async () => {
185
+ const { doc } = await check();
186
+ expect(doc.links!.findings.map((f) => [f.code, f.commit, f.severity])).toEqual([
187
+ ["commit-unlinked", sha.c3, "warn"],
188
+ ["commit-wrongly-linked", sha.c4, "warn"],
189
+ ["commit-wrongly-linked", sha.c5, "warn"],
190
+ ["commit-wrongly-linked", sha.c6, "warn"],
191
+ ["commit-wrongly-linked", sha.c7, "warn"],
192
+ ]);
193
+ expect(doc.links!.findings[0].id).toBe(`finding:commit-unlinked:${sha.c3}`);
194
+ expect(doc.links!.findings.every((f) => f.fix.includes("Chant-Record"))).toBe(true);
195
+ expect(doc.ok).toBe(true);
196
+ const text = formatChanges(doc);
197
+ expect(text).toContain("links 8 commits: 3 name a work item, 1 do not, 4 name one that is not there; work kinds: work");
198
+ expect(text).toContain(`warning commit-unlinked: ${sha.c3.slice(0, 8)} "three" carries no Chant-Record trailer of a work kind and no Chant-Lease trailer`);
199
+ expect(text).toContain(" fix: add a `Chant-Record: work:<id>` trailer");
200
+ expect(text).toContain(`commit-wrongly-linked: ${sha.c4.slice(0, 8)} "four" names no work item that exists: Chant-Record: work:W-999 (the work kind has no record W-999)`);
201
+ });
202
+
203
+ test("--severity sets the level: fail fails the check, off reports no finding", async () => {
204
+ const fail = await check("fail");
205
+ expect(fail.failed).toBe(true);
206
+ expect(fail.doc.ok).toBe(false);
207
+ expect(fail.doc.links!.findings.every((f) => f.severity === "fail")).toBe(true);
208
+ expect(formatChanges(fail.doc)).toContain("error commit-unlinked");
209
+ const off = await check("off");
210
+ expect(off.failed).toBe(false);
211
+ expect(off.doc.links!.findings).toEqual([]);
212
+ expect(off.doc.links!.commits).toHaveLength(8);
213
+ });
214
+
215
+ test("a range whose commits all name a work item has no finding", async () => {
216
+ const { doc } = await checkChanges({ cwd: root, range: `${sha.c0}..${sha.c2}` });
217
+ changes.expectValid(doc);
218
+ expect((doc as Result).links!.findings).toEqual([]);
219
+ });
220
+ });
221
+
222
+ describe("ci list and ci tick (#4029)", () => {
223
+ test("ci list --json marks each commit with its link, and merges as merge", async () => {
224
+ const doc = (await ciList({ cwd: root, now: NOW })) as Listed;
225
+ list.expectValid(doc);
226
+ const byCommit = Object.fromEntries(doc.commits.map((c) => [c.sha, c.work]));
227
+ expect(byCommit[sha.c1]).toEqual({ state: "linked", ids: ["work:W-001"], problems: [] });
228
+ expect(byCommit[sha.c2]).toMatchObject({ state: "linked", ids: ["work:W-002"] });
229
+ expect(byCommit[sha.c3]).toEqual({ state: "unlinked", ids: [], problems: [] });
230
+ expect(byCommit[sha.c4]).toMatchObject({ state: "wrong" });
231
+ expect(byCommit[sha.m]).toEqual({ state: "merge", ids: [], problems: [] });
232
+ });
233
+
234
+ test("ci list text gives the reason a commit's work item is unknown", async () => {
235
+ const doc = (await ciList({ cwd: root, now: NOW })) as Listed;
236
+ const text = formatList(doc).split("\n");
237
+ const line = (s: string) => text.find((l) => l.includes(s.slice(0, 12)))!;
238
+ expect(line(sha.c3)).toContain("; why unknown: no Chant-Record or Chant-Lease trailer names a work item");
239
+ expect(line(sha.c4)).toContain("; why unknown: the work item named is not there: Chant-Record: work:W-999");
240
+ expect(line(sha.c1)).not.toContain("why unknown");
241
+ expect(line(sha.m)).not.toContain("why unknown");
242
+ });
243
+
244
+ test("the field is null where the workspace declares no work kind", async () => {
245
+ const plain = scratchDir("chant-links-plain-");
246
+ git(plain, "init", "-q");
247
+ git(plain, "symbolic-ref", "HEAD", "refs/heads/main");
248
+ writeFiles(plain, {
249
+ "chant.workspace.json": JSON.stringify({ name: "plain", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "x" }], ci: { green: { branch: "main", phases: { lint: ["lint"] }, require: ["lint"] } } }),
250
+ "app/a.txt": "a\n",
251
+ });
252
+ git(plain, "add", "-A");
253
+ git(plain, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one");
254
+ const doc = (await ciList({ cwd: plain })) as Listed;
255
+ list.expectValid(doc);
256
+ expect(doc.commits.map((c) => c.work)).toEqual([null]);
257
+ expect(formatList(doc)).not.toContain("why unknown");
258
+ });
259
+
260
+ test("ci tick shows an unlinked commit with why unknown", async () => {
261
+ origin = scratchDir("chant-links-origin-");
262
+ git(origin, "init", "-q", "--bare");
263
+ git(root, "remote", "add", "origin", origin);
264
+ git(root, "push", "-q", "origin", "main");
265
+ const forge: CiForge = { kind: "github", checkRuns: async () => [] };
266
+ const result = await ciTick({ cwd: root, forge, now: NOW, dryRun: true });
267
+ const text = formatTick(result, true);
268
+ const line = (s: string) => text.split("\n").find((l) => l.includes(s.slice(0, 12)))!;
269
+ expect(line(sha.c3)).toMatch(/undecided: lint; why unknown: no Chant-Record or Chant-Lease trailer names a work item/);
270
+ expect(line(sha.c1)).not.toContain("why unknown");
271
+ });
272
+
273
+ test("whyWorkUnknown names nothing for a linked commit, a merge or no field", () => {
274
+ expect(whyWorkUnknown(null)).toBeUndefined();
275
+ expect(whyWorkUnknown(undefined)).toBeUndefined();
276
+ expect(whyWorkUnknown({ state: "linked", ids: ["work:W-1"], problems: [] })).toBeUndefined();
277
+ expect(whyWorkUnknown({ state: "merge", ids: [], problems: [] })).toBeUndefined();
278
+ });
279
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Which work item a commit belongs to (#4029).
3
+ *
4
+ * A work item implements a decision, and a trunk commit says which work item
5
+ * it carries out in one of two ways:
6
+ *
7
+ * - a `Chant-Record: <kind>:<id>` trailer, where `<kind>` is a work kind the
8
+ * declaration names (conventionally `work`) and the kind has a record `<id>`;
9
+ * - a `Chant-Lease` trailer whose token a lease history on `chant/lifecycle`
10
+ * names a work item for, and that item is a record of a work kind.
11
+ *
12
+ * A window match (the intent graph's `worked` bucket) does not link a commit:
13
+ * the commit never said so itself. A commit is wrongly linked when it names a
14
+ * work record that does not exist, a record of a kind that is not a work
15
+ * kind, a lease that resolves to nothing, or a `Chant-Record` value that is
16
+ * not `<kind>:<id>`, and no trailer of it links to a work item.
17
+ *
18
+ * This is the one place that judgement is made. `workspace check --changes`
19
+ * reports it, `ci list` and `ci tick` show it, and later readers share
20
+ * {@link judgeCommitLink}. It reads trailers through `readTrailerJoins`,
21
+ * which `graph --intent` joins commits with, so both resolve a Chant-Record
22
+ * and a Chant-Lease the same way. Everything here reads local git.
23
+ */
24
+
25
+ import { execFileSync } from "node:child_process";
26
+ import { declaredRecordKinds, readDeclaration, WorkspaceReadError } from "./declaration";
27
+ import { declaredKindFile } from "./declared-kinds";
28
+ import { trailerValues } from "./intent-joins";
29
+ import { commitDetails } from "./intent";
30
+ import { loadRecordKind, RecordReadError } from "./records";
31
+ import { readRecordsFor } from "./records-cli";
32
+ import { readTrailerJoins } from "./trailer-joins";
33
+ import { parseRecordRef, RECORD_TRAILER, LEASE_TRAILER } from "./trailers";
34
+ import { locateWorkspace } from "./which-chant";
35
+
36
+ /** `linked`, `unlinked` (no trailer names a work item), `wrong` (trailers name one that is not there), or `merge` (a merge commit carries no work of its own). */
37
+ export type CommitLinkState = "linked" | "unlinked" | "wrong" | "merge";
38
+
39
+ /** One trailer that does not link the commit, with why. */
40
+ export interface CommitLinkProblem {
41
+ /** The trailer as written, such as `Chant-Record: decision:ws-075`. */
42
+ trailer: string;
43
+ message: string;
44
+ }
45
+
46
+ /** What {@link judgeCommitLink} says about one commit. */
47
+ export interface CommitLink {
48
+ state: CommitLinkState;
49
+ /** The work records the commit's trailers name, as `<kind>:<id>`. Empty unless `linked`; also empty for a lease that could not be checked. */
50
+ ids: string[];
51
+ /** The trailers that do not link. Empty unless `wrong`. */
52
+ problems: CommitLinkProblem[];
53
+ }
54
+
55
+ /** The work records a revision has, by work kind. */
56
+ export interface WorkIndex {
57
+ /** Each work kind's name, with the ids of its records. */
58
+ kinds: Map<string, Set<string>>;
59
+ /** Whether lease histories can be read: the local `chant/lifecycle` branch exists. Without it a lease is not judged. */
60
+ ledger: boolean;
61
+ }
62
+
63
+ /** The fix line for each state a check reports. */
64
+ export const COMMIT_UNLINKED_FIX = "add a `Chant-Record: work:<id>` trailer when the commit is made (git commit --trailer \"Chant-Record: work:<id>\"), naming the work item the commit carries out";
65
+
66
+ function workKindList(index: WorkIndex): string {
67
+ return [...index.kinds.keys()].join(", ") || "none";
68
+ }
69
+
70
+ /**
71
+ * Judge one commit from its trailers.
72
+ *
73
+ * `trailers` are the commit's parsed trailers, `joins` what
74
+ * `readTrailerJoins` made of them, and `index` the work records of the
75
+ * revision judged against. A commit is `linked` when any trailer names a
76
+ * work record; another trailer that names a decision or a typo beside it
77
+ * does not make it wrong.
78
+ */
79
+ export function judgeCommitLink(trailers: Record<string, string[]>, joins: { records: { kind: string; id: string }[]; lease: { token: string; item: string | null } | null }, index: WorkIndex): CommitLink {
80
+ const ids: string[] = [];
81
+ const problems: CommitLinkProblem[] = [];
82
+ let unchecked = false;
83
+ const add = (id: string) => {
84
+ if (!ids.includes(id)) ids.push(id);
85
+ };
86
+ for (const value of trailerValues(trailers, RECORD_TRAILER)) {
87
+ const trailer = `${RECORD_TRAILER}: ${value}`;
88
+ const ref = parseRecordRef(value);
89
+ if (!ref) {
90
+ problems.push({ trailer, message: `${value} is not <kind>:<id>` });
91
+ continue;
92
+ }
93
+ const known = index.kinds.get(ref.kind);
94
+ if (!known) problems.push({ trailer, message: `${ref.kind} is not a work kind (the work kinds are ${workKindList(index)})` });
95
+ else if (!known.has(ref.id)) problems.push({ trailer, message: `the ${ref.kind} kind has no record ${ref.id}` });
96
+ else add(`${ref.kind}:${ref.id}`);
97
+ }
98
+ if (joins.lease) {
99
+ const trailer = `${LEASE_TRAILER}: ${joins.lease.token}`;
100
+ const item = joins.lease.item;
101
+ if (item === null) {
102
+ if (index.ledger) problems.push({ trailer, message: "no lease history on chant/lifecycle names this token" });
103
+ else unchecked = true;
104
+ } else {
105
+ const hits = [...index.kinds].filter(([, found]) => found.has(item)).map(([kind]) => kind);
106
+ if (hits.length === 1) add(`${hits[0]}:${item}`);
107
+ else problems.push({ trailer, message: hits.length === 0 ? `the lease was taken on ${item}, which no work kind has a record for` : `the lease was taken on ${item}, which ${hits.join(" and ")} both have, so it names no one work item` });
108
+ }
109
+ }
110
+ if (ids.length > 0 || unchecked) return { state: "linked", ids, problems: [] };
111
+ return problems.length > 0 ? { state: "wrong", ids: [], problems } : { state: "unlinked", ids: [], problems: [] };
112
+ }
113
+
114
+ /**
115
+ * Judge each commit by its own trailers. A commit in `merges` is `merge`:
116
+ * its work is the commits it brings. Reads the lease histories on the local
117
+ * `chant/lifecycle` branch once, and never fetches.
118
+ */
119
+ export function judgeCommits(top: string, shas: string[], index: WorkIndex, merges: ReadonlySet<string> = new Set()): Map<string, CommitLink> {
120
+ const details = commitDetails(top, shas);
121
+ const joined = readTrailerJoins(
122
+ top,
123
+ shas.map((sha) => ({ sha, trailers: details.get(sha)?.trailers ?? {} })),
124
+ );
125
+ const out = new Map<string, CommitLink>();
126
+ for (const sha of shas) {
127
+ if (merges.has(sha)) {
128
+ out.set(sha, { state: "merge", ids: [], problems: [] });
129
+ continue;
130
+ }
131
+ out.set(sha, judgeCommitLink(details.get(sha)?.trailers ?? {}, joined.get(sha)!.joins, index));
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** Whether the lease histories can be read from this repository: the local `chant/lifecycle` branch exists. */
137
+ export function hasLeaseLedger(top: string): boolean {
138
+ try {
139
+ execFileSync("git", ["rev-parse", "--verify", "--quiet", "refs/heads/chant/lifecycle"], { cwd: top, stdio: "ignore" });
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /** Which of `shas` are merge commits. */
147
+ export function mergeCommits(top: string, shas: string[]): Set<string> {
148
+ const out = new Set<string>();
149
+ if (shas.length === 0) return out;
150
+ const text = execFileSync("git", ["log", "--no-walk=unsorted", "--stdin", "--format=%H %P"], { cwd: top, encoding: "utf-8", input: `${shas.join("\n")}\n`, maxBuffer: 256 * 1024 * 1024 });
151
+ for (const line of text.split("\n")) {
152
+ const [sha, ...parents] = line.trim().split(" ");
153
+ if (sha && parents.length > 1) out.add(sha);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * The work records the declaration's work kinds hold at `at` (a revision, or
160
+ * the working tree without one). Null when `cwd` is in no workspace, the
161
+ * declaration names no work kind, or a kind can't be read: the question
162
+ * "which work item" has no answer there.
163
+ */
164
+ export async function readWorkIndex(cwd: string, top: string, at?: string): Promise<WorkIndex | null> {
165
+ try {
166
+ const located = locateWorkspace(cwd, at);
167
+ const declaration = readDeclaration(located.tree);
168
+ const kinds = new Map<string, Set<string>>();
169
+ for (const d of declaredRecordKinds(declaration)) {
170
+ const file = declaredKindFile(d, located.rootOnDisk);
171
+ const loaded = await loadRecordKind(file, cwd);
172
+ if (!loaded.kind.work) continue;
173
+ const read = await readRecordsFor({ kind: file, cwd, ...(at !== undefined ? { at } : {}) });
174
+ const ids = (kinds.get(loaded.kind.name) ?? new Set<string>());
175
+ for (const r of read.result.records) if (r.id !== null) ids.add(r.id);
176
+ kinds.set(loaded.kind.name, ids);
177
+ }
178
+ return kinds.size === 0 ? null : { kinds, ledger: hasLeaseLedger(top) };
179
+ } catch (err) {
180
+ if (err instanceof WorkspaceReadError || err instanceof RecordReadError) return null;
181
+ throw err;
182
+ }
183
+ }
@@ -1450,6 +1450,18 @@
1450
1450
  "items": {
1451
1451
  "$ref": "#/$defs/ciPhaseName"
1452
1452
  }
1453
+ },
1454
+ "signedSince": {
1455
+ "type": "string",
1456
+ "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$",
1457
+ "description": "Added by #4031, read by chant 0.124.28 and newer. The full sha of the first commit on branch whose ci/green and ci/revoked tags must carry a DSSE statement in their annotation, signed by a key .chant/trust.json at base lists under runners for a principal holding the runner role. A tag on a strict first-parent ancestor of this commit counts unsigned, so existing tags keep working; a tag on any other commit, this one included, needs a statement. Position in history decides, never a date, which whoever makes a commit or a tag sets. A reader refuses a sha that names no commit in its clone with declaration-invalid. Absent, no tag needs a statement."
1458
+ },
1459
+ "source": {
1460
+ "enum": [
1461
+ "checks",
1462
+ "runs"
1463
+ ],
1464
+ "description": "Added by #4044, read by chant 0.124.28 and newer, so a declaration that sets it sets minReader to 0.124.28 or newer. Where chant ci tick reads each phase's results. checks (the default): the forge's check runs, whose names the phases' patterns match. runs: chant's own Op runs, read from the run ledger on chant/lifecycle, for a project whose pipelines run as chant Ops, such as one on GitLab or Forgejo. A phase's patterns then match Op names, a run counts for the commit its record names as gitSha, and the latest run of each Op decides. A run counts only when its record carries a statement signed by a key .chant/trust.json at base lists under runners for a principal holding the runner role; the tick ignores and reports any other. A tick from runs signs every tag it makes, so it needs a runner key."
1453
1465
  }
1454
1466
  },
1455
1467
  "patternProperties": {
@@ -205,9 +205,29 @@ export interface CiGreen {
205
205
  phases: CiPhase[];
206
206
  /** The phases a commit must pass, by name, in file order. */
207
207
  require: string[];
208
+ /**
209
+ * The full sha of the first commit on the branch whose ci tags need a
210
+ * statement signed by a runner key (#4031). A tag on a strict
211
+ * first-parent ancestor of it counts unsigned, and any other tag counts
212
+ * only with one. Readers refuse a sha that names no commit with
213
+ * `declaration-invalid`. Null when the declaration sets none, and then no
214
+ * tag needs a statement.
215
+ */
216
+ signedSince: string | null;
217
+ /**
218
+ * Where the tick reads each phase's results (#4044): `checks` (the
219
+ * default), the forge's check runs, whose names the phases' patterns
220
+ * match; or `runs`, chant's own Op runs on `chant/lifecycle`, whose Op
221
+ * names the patterns match, each counted only with a statement signed by
222
+ * a runner key.
223
+ */
224
+ source: CiSource;
208
225
  pointer: string;
209
226
  }
210
227
 
228
+ /** Where `ci tick` reads phase results (#4044): forge check runs, or chant Op runs. */
229
+ export type CiSource = "checks" | "runs";
230
+
211
231
  /** The declaration's `ci` block (#3573). */
212
232
  export interface CiDeclaration {
213
233
  green: CiGreen | null;
@@ -1029,7 +1049,7 @@ function identityOf(raw: unknown): IdentityPolicy | null {
1029
1049
  */
1030
1050
  function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): CiDeclaration | null {
1031
1051
  if (raw === undefined) return null;
1032
- const block = raw as { green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped }>; require: string[] } };
1052
+ const block = raw as { green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped }>; require: string[]; signedSince?: string; source?: CiSource } };
1033
1053
  if (block.green === undefined) return { green: null };
1034
1054
  const g = block.green;
1035
1055
  const phases: CiPhase[] = Object.entries(g.phases).map(([name, p]) => ({
@@ -1046,7 +1066,7 @@ function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocatio
1046
1066
  at(`/ci/green/require/${i}`),
1047
1067
  );
1048
1068
  }
1049
- return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], pointer: "/ci/green" } };
1069
+ return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], signedSince: g.signedSince ?? null, source: g.source ?? "checks", pointer: "/ci/green" } };
1050
1070
  }
1051
1071
 
1052
1072
  function agentsOf(raw: unknown, members: Member[], at: (pointer: string, key?: boolean) => ErrorLocation): AgentDeclaration[] {
@@ -14,7 +14,7 @@
14
14
  * else: no zod, no ajv, no package loading, so it bundles small.
15
15
  */
16
16
 
17
- import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, verifySshSignatureWith, type SealedGateApproval } from "./trust/gate-seal";
17
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshKeyFingerprint, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./trust/gate-seal";
18
18
  import type { Signer } from "./trust/trust-files";
19
19
 
20
20
  /** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
@@ -78,17 +78,101 @@ export function grantedClasses(classes: readonly ClassGrant[], roles: Readonly<R
78
78
  */
79
79
  export function admitApprover(rule: GateAdmissionRule, a: SealedGateApproval): string | null {
80
80
  const { gate, signers } = rule;
81
- const want = rule.class;
82
- if (want !== null && !rule.classes.some((c) => c.name === want)) {
83
- return `identity.gates.${gate} names the class ${want}, which ${rule.unknownClass ?? "no pinned package supplies"}, so no approval can show it is in that class (known classes: ${rule.classes.map((c) => c.name).join(", ")})`;
84
- }
81
+ const unknown = unknownClassRefusal(rule, "approval");
82
+ if (unknown !== null) return unknown;
85
83
  if (!signers.active) return `gate ${gate} needs a signed approval, and there is no signers file (${signers.signersPath}) at base to check one against`;
86
84
  const seal = sealRefusal(gate, signers, a);
87
85
  if (seal !== null) return seal;
86
+ return classRefusal(rule, a.resolvedBy);
87
+ }
88
+
89
+ /** Why no signature can show a principal is in the rule's class: the class is one the rule does not know. Null otherwise. */
90
+ function unknownClassRefusal(rule: GateAdmissionRule, what: string): string | null {
91
+ const want = rule.class;
92
+ if (want === null || rule.classes.some((c) => c.name === want)) return null;
93
+ return `identity.gates.${rule.gate} names the class ${want}, which ${rule.unknownClass ?? "no pinned package supplies"}, so no ${what} can show it is in that class (known classes: ${rule.classes.map((c) => c.name).join(", ")})`;
94
+ }
95
+
96
+ /** Why `principal` is not in the rule's class, or null when it is or the rule names none. */
97
+ function classRefusal(rule: GateAdmissionRule, principal: string): string | null {
98
+ const want = rule.class;
88
99
  if (want === null) return null;
89
- const granted = grantedClasses(rule.classes, rule.roles, a.resolvedBy);
100
+ const granted = grantedClasses(rule.classes, rule.roles, principal);
90
101
  const inClass = want === "human" ? granted.length === 0 : granted.includes(want);
91
- return inClass ? null : `${a.resolvedBy} is not in the class ${want} that gate ${gate} needs: no role grant at base puts them there`;
102
+ return inClass ? null : `${principal} is not in the class ${want} that gate ${rule.gate} needs: no role grant at base puts them there`;
103
+ }
104
+
105
+ // ── release cuts (#4031) ─────────────────────────────────────────────────
106
+
107
+ /** The gate whose `identity.gates` entry says who may cut a release (#4031). */
108
+ export const RELEASE_GATE = "release";
109
+
110
+ /** The ssh signature namespace of a release cut's signature (#4031), so no other signature can stand in for one. */
111
+ export const RELEASE_SEAL_NAMESPACE = "chant-release";
112
+
113
+ /** A cut line's fields its signature covers (#4031), and the signature. */
114
+ export interface SignedReleaseCut {
115
+ releaseDigest: string;
116
+ label: string | null;
117
+ actor: string;
118
+ timestamp: string;
119
+ /** The armored ssh signature, or null for an unsigned cut. */
120
+ signature: string | null;
121
+ }
122
+
123
+ /**
124
+ * The bytes a release cut's signature signs (#4031): the release digest,
125
+ * the label (empty for none), the actor who cut it and the cut's time,
126
+ * joined by LF with no final newline.
127
+ */
128
+ export function releaseSealPayload(cut: Omit<SignedReleaseCut, "signature">): Buffer {
129
+ return Buffer.from(`${cut.releaseDigest}\n${cut.label ?? ""}\n${cut.actor}\n${cut.timestamp}`, "utf-8");
130
+ }
131
+
132
+ /** Why the cut's signature does not verify for its actor against the signers at base, or null when it does. */
133
+ function releaseSignatureRefusal(signers: GateAdmissionRule["signers"], cut: SignedReleaseCut): string | null {
134
+ if (cut.signature === null) return `the cut by ${cut.actor} is not signed`;
135
+ if (!signers.active) return `there is no signers file (${signers.signersPath}) at base to check the cut's signature against`;
136
+ const where = `${signers.signersPath} at base`;
137
+ const listed = signers.signers.filter((s) => normalise(s.principal) === normalise(cut.actor));
138
+ if (listed.length === 0) return `${cut.actor} has no key in ${where}, so the cut's signature can't count`;
139
+ const keys = listed.filter((s) => namespaceAllowed(s.namespaces, RELEASE_SEAL_NAMESPACE)).map((s) => Buffer.from(s.key.split(" ")[1] ?? "", "base64"));
140
+ const r = verifySshSignatureWith(cut.signature, releaseSealPayload(cut), RELEASE_SEAL_NAMESPACE, keys);
141
+ return r.ok ? null : `the cut's signature does not verify for ${cut.actor} against ${where}: ${r.reason}`;
142
+ }
143
+
144
+ /**
145
+ * Why `cut` may not be made under the `release` gate's rule, or null when
146
+ * it may (#4031): its signature verifies for its actor against the signers
147
+ * at base and, when the gate names a class, the role grants at base put the
148
+ * actor in it. The same rule {@link admitApprover} applies to an approval,
149
+ * over the cut's own signed bytes.
150
+ */
151
+ export function admitReleaseCut(rule: GateAdmissionRule, cut: SignedReleaseCut): string | null {
152
+ const unknown = unknownClassRefusal(rule, "cut");
153
+ if (unknown !== null) return unknown;
154
+ const seal = releaseSignatureRefusal(rule.signers, cut);
155
+ if (seal !== null) return `${seal}; identity.gates.${rule.gate} needs a release cut signed with chant release cut --sign`;
156
+ return classRefusal(rule, cut.actor);
157
+ }
158
+
159
+ /** Whether a cut's signature verifies, as `release list` and `workspace status` report it (#4031). */
160
+ export interface ReleaseSignatureCheck {
161
+ /** `verified`: it verifies for the actor, who is in the release gate's class when it names one. `unsigned`: the cut has none. `unverified`: it does not, and `reason` says why. */
162
+ status: "verified" | "unsigned" | "unverified";
163
+ /** The fingerprint of the key in the signature, or null for an unsigned cut or one that can't be read. */
164
+ key: string | null;
165
+ reason: string;
166
+ }
167
+
168
+ /** Judge a stored cut's signature against `rule`, read at base. A rule with no class checks the signature alone. */
169
+ export function checkReleaseSignature(rule: GateAdmissionRule, cut: SignedReleaseCut): ReleaseSignatureCheck {
170
+ if (cut.signature === null) return { status: "unsigned", key: null, reason: "the cut carries no signature" };
171
+ const blob = sshSignatureKey(cut.signature);
172
+ const key = blob ? sshKeyFingerprint(blob) : null;
173
+ const why = unknownClassRefusal(rule, "cut") ?? releaseSignatureRefusal(rule.signers, cut) ?? classRefusal(rule, cut.actor);
174
+ if (why !== null) return { status: "unverified", key, reason: why };
175
+ return { status: "verified", key, reason: `signed by ${cut.actor}${rule.class !== null ? `, in the class ${rule.class}` : ""}` };
92
176
  }
93
177
 
94
178
  /** The seal step, worded as `checkGateSeal` (`./trust/seal.ts`) words it against an active signers file at base. */