@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
@@ -27,14 +27,46 @@
27
27
  *
28
28
  * The forge is asked only for check runs (`./ci-green-forge.ts`). The tags
29
29
  * are fetched from and pushed to the remote with git.
30
+ *
31
+ * Signed tags (#4031): when `ci.green.signedSince` names a commit, a tag on
32
+ * any commit that is not a strict first-parent ancestor of it counts only
33
+ * when its annotation carries a statement signed by a runner key (`./trust/ci-tag-statement.ts`). Every
34
+ * reader here, and the release cut, ignores a tag without a valid one and
35
+ * reports it. A tick given a runner key signs the tags it makes. Without
36
+ * `signedSince` every tag counts, as before.
37
+ *
38
+ * Chant runs as the source (#4044): with `ci.green.source: "runs"` (or
39
+ * `--source runs`) the tick reads chant's own Op runs from the run ledger on
40
+ * `chant/lifecycle` in place of the forge's check runs, so a project whose
41
+ * pipelines run as chant Ops, such as one on GitLab or Forgejo, gets green
42
+ * and revoked tags with no forge check runs. A phase's patterns then match
43
+ * Op names, a run counts for the commit its record names as `gitSha`, and
44
+ * the latest run of each Op decides, by its end time. A run counts only when
45
+ * its record carries a statement signed by a runner key whose principal
46
+ * holds the `runner` role at base (`./trust/op-run-statement.ts`); the tick
47
+ * ignores and reports any other, so it neither greens nor revokes a commit.
48
+ * A tick from runs signs every tag it makes, and each run in the tag's
49
+ * record names the Op run's id and carries its statement.
30
50
  */
31
51
 
52
+ import { judgeCommits, mergeCommits, readWorkIndex, type CommitLink } from "./commit-links";
32
53
  import { execFileSync } from "node:child_process";
33
- import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type CiGreen, type CiPhase } from "./declaration";
34
- import type { CheckRun, CiForge } from "./ci-green-forge";
54
+ import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type CiGreen, type CiPhase, type CiSource } from "./declaration";
55
+ import type { CheckRun, CiForge, OpRunRef } from "./ci-green-forge";
56
+ import type { OpRunRecord } from "../op/runtime";
57
+ import { readRunLedger } from "../lifecycle/run-ledger";
58
+ import { fetchLifecycleStatus, listFilesInDir, listLedgerTrees } from "../lifecycle/git";
59
+ import { signOpRunRecord, verifyOpRunStatement } from "./trust/op-run-statement";
35
60
  import type { ReasonCode } from "./reason-codes";
36
61
  import { gitTop } from "./tree";
37
62
  import { locateWorkspace } from "./which-chant";
63
+ import { gitRevisionSource } from "./record-source";
64
+ import { emptyPolicy, readTrustPolicy } from "./trust/policy";
65
+ import { loadRunnerKey } from "./trust/dsse";
66
+ import { signCiTagRecord, verifyCiTagStatement, CI_TAG_SIGNER_ROLE, type CiTagFailure, type CiTagRecord, type CiTagSigner } from "./trust/ci-tag-statement";
67
+
68
+ /** The environment variable a tick (#4031) and `chant run` (#4044) read a runner key from, as PEM, when `--key` is not given. */
69
+ export const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
38
70
 
39
71
  export const CI_GREEN_TAG_PREFIX = "ci/green/";
40
72
  export const CI_REVOKED_TAG_PREFIX = "ci/revoked/";
@@ -83,6 +115,8 @@ export interface RunVerdict {
83
115
  conclusion: string | null;
84
116
  url: string | null;
85
117
  verdict: Verdict;
118
+ /** The Op run this is, when the source is chant runs (#4044). */
119
+ opRun?: OpRunRef;
86
120
  }
87
121
 
88
122
  export interface PhaseVerdict {
@@ -110,7 +144,7 @@ export function evaluatePhase(phase: CiPhase, latest: readonly CheckRun[]): Phas
110
144
  if (matched.length === 0) unmatched.push(pattern);
111
145
  for (const r of matched) {
112
146
  if (runs.some((x) => x.name === r.name)) continue;
113
- runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r) });
147
+ runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r), ...(r.opRun ? { opRun: r.opRun } : {}) });
114
148
  }
115
149
  }
116
150
  const verdict: Verdict = runs.some((r) => r.verdict === "fail")
@@ -152,6 +186,24 @@ export interface TickCommit {
152
186
  verdict?: CommitVerdict;
153
187
  /** The tag the tick made, if any. */
154
188
  made: { tag: string; message: string } | null;
189
+ /** Whether the commit names its work item (#4029), or undefined when the workspace declares no work kind. */
190
+ work?: CommitLink;
191
+ /** The commit's ci tags that were ignored, each without a valid statement (#4031). Absent when none were. */
192
+ ignored?: IgnoredCiTag[];
193
+ /** The tag the tick would have made, when an ignored tag already holds its name (#4031). Nothing is made until a person deletes that tag. */
194
+ blocked?: string;
195
+ /** The Op runs on this commit that were ignored, each without a valid statement (#4044). Absent when none were. */
196
+ ignoredRuns?: IgnoredOpRun[];
197
+ }
198
+
199
+ /** An Op run on a commit that does not count (#4044), and why. */
200
+ export interface IgnoredOpRun {
201
+ run: string;
202
+ op: string;
203
+ env: string;
204
+ /** `unsigned`, `invalid`, `untrusted` or `mismatch`, as for a ci tag. */
205
+ status: CiTagFailure;
206
+ reason: string;
155
207
  }
156
208
 
157
209
  export interface TickPlanInput {
@@ -159,8 +211,12 @@ export interface TickPlanInput {
159
211
  commits: readonly { sha: string; committed: string }[];
160
212
  greenTags: ReadonlySet<string>;
161
213
  revokedTags: ReadonlySet<string>;
214
+ /** Tags that exist but were ignored (#4031), by commit. The tick does not make a tag whose name one of them holds. */
215
+ ignored?: ReadonlyMap<string, readonly IgnoredCiTag[]>;
162
216
  checkRuns(sha: string): Promise<CheckRun[]>;
163
217
  now: Date;
218
+ /** Signs each tag's record (#4031). Without it the tags carry no statement. */
219
+ signer?: CiTagSigner;
164
220
  }
165
221
 
166
222
  /** A time as an annotation and a tick report write it: UTC, to the second. */
@@ -173,25 +229,41 @@ export function windowMs(window: string): number {
173
229
  return Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "m" | "h" | "d"];
174
230
  }
175
231
 
176
- const runRecord = (r: RunVerdict) => ({ name: r.name, id: r.id, conclusion: r.conclusion, url: r.url });
232
+ /**
233
+ * A run as a tag's record holds it. An Op run (#4044) has no forge id; it
234
+ * names the Op run's id, env and signer, and carries the run's statement.
235
+ */
236
+ const runRecord = (r: RunVerdict) =>
237
+ r.opRun
238
+ ? { name: r.name, id: null, conclusion: r.conclusion, url: r.url, opRun: { id: r.opRun.id, env: r.opRun.env, signer: r.opRun.signer, statement: r.opRun.statement } }
239
+ : { name: r.name, id: r.id, conclusion: r.conclusion, url: r.url };
240
+
241
+ /** `source: "runs"` on a record made from chant runs (#4044); nothing for check runs, so their records read as before. */
242
+ const sourceField = (green: CiGreen) => (green.source === "runs" ? { source: "runs" as const } : {});
243
+
244
+ /** The record as JSON, with the statement that signs it when a signer is given (#4031). */
245
+ function recordJson(record: CiTagRecord, signer: CiTagSigner | undefined): string {
246
+ return JSON.stringify(signer ? { ...record, statement: signCiTagRecord(record, signer) } : record, null, 2);
247
+ }
177
248
 
178
- /** The annotation of a green tag: a subject line, then the record as JSON. */
179
- export function greenAnnotation(green: CiGreen, sha: string, verdict: CommitVerdict, at: Date): string {
180
- const record = {
249
+ /** The annotation of a green tag: a subject line, then the record as JSON, signed when `signer` is given (#4031). */
250
+ export function greenAnnotation(green: CiGreen, sha: string, verdict: CommitVerdict, at: Date, signer?: CiTagSigner): string {
251
+ const record: CiTagRecord = {
181
252
  schema: 1,
182
253
  tag: "green",
183
254
  commit: sha,
184
255
  branch: green.branch,
185
256
  at: iso(at),
257
+ ...sourceField(green),
186
258
  phases: Object.fromEntries(verdict.phases.map((p) => [p.phase, p.runs.map(runRecord)])),
187
259
  };
188
- return `ci: every required phase passed (${green.require.join(", ")})\n\n${JSON.stringify(record, null, 2)}\n`;
260
+ return `ci: every required phase passed (${green.require.join(", ")})\n\n${recordJson(record, signer)}\n`;
189
261
  }
190
262
 
191
- /** The annotation of a revoked tag: the phase and the run that failed, and the time. */
192
- export function revokedAnnotation(green: CiGreen, sha: string, failure: NonNullable<CommitVerdict["failure"]>, at: Date): string {
193
- const record = { schema: 1, tag: "revoked", commit: sha, branch: green.branch, at: iso(at), phase: failure.phase, run: runRecord(failure.run) };
194
- return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status})\n\n${JSON.stringify(record, null, 2)}\n`;
263
+ /** The annotation of a revoked tag: the phase and the run that failed, and the time, signed when `signer` is given (#4031). */
264
+ export function revokedAnnotation(green: CiGreen, sha: string, failure: NonNullable<CommitVerdict["failure"]>, at: Date, signer?: CiTagSigner): string {
265
+ const record: CiTagRecord = { schema: 1, tag: "revoked", commit: sha, branch: green.branch, at: iso(at), ...sourceField(green), phase: failure.phase, run: runRecord(failure.run) };
266
+ return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status})\n\n${recordJson(record, signer)}\n`;
195
267
  }
196
268
 
197
269
  /**
@@ -205,17 +277,21 @@ export async function planTick(input: TickPlanInput): Promise<TickCommit[]> {
205
277
  for (const c of input.commits) {
206
278
  const before: TagState = input.revokedTags.has(c.sha) ? "revoked" : input.greenTags.has(c.sha) ? "green" : "none";
207
279
  if (before === "revoked") {
208
- out.push({ ...c, before, made: null });
280
+ const ignored = input.ignored?.get(c.sha);
281
+ out.push({ ...c, before, made: null, ...(ignored && ignored.length > 0 ? { ignored: [...ignored] } : {}) });
209
282
  continue;
210
283
  }
211
284
  const verdict = evaluateCommit(input.green, await input.checkRuns(c.sha));
212
285
  let made: TickCommit["made"] = null;
213
286
  if (before === "none" && verdict.verdict === "pass") {
214
- made = { tag: `${CI_GREEN_TAG_PREFIX}${c.sha}`, message: greenAnnotation(input.green, c.sha, verdict, input.now) };
287
+ made = { tag: `${CI_GREEN_TAG_PREFIX}${c.sha}`, message: greenAnnotation(input.green, c.sha, verdict, input.now, input.signer) };
215
288
  } else if (before === "green" && verdict.verdict === "fail") {
216
- made = { tag: `${CI_REVOKED_TAG_PREFIX}${c.sha}`, message: revokedAnnotation(input.green, c.sha, verdict.failure!, input.now) };
289
+ made = { tag: `${CI_REVOKED_TAG_PREFIX}${c.sha}`, message: revokedAnnotation(input.green, c.sha, verdict.failure!, input.now, input.signer) };
217
290
  }
218
- out.push({ ...c, before, verdict, made });
291
+ const ignored = input.ignored?.get(c.sha);
292
+ // A tag is never moved or deleted by chant, so an ignored tag holding the name stops the tick making it.
293
+ const blocked = made !== null && ignored?.some((t) => t.tag === made!.tag) ? made.tag : undefined;
294
+ out.push({ ...c, before, verdict, made: blocked ? null : made, ...(ignored && ignored.length > 0 ? { ignored: [...ignored] } : {}), ...(blocked ? { blocked } : {}) });
219
295
  }
220
296
  return out;
221
297
  }
@@ -238,17 +314,130 @@ function tryGit(top: string, args: string[]): string | null {
238
314
  }
239
315
  }
240
316
 
241
- /** The ci tags in the repository: the commits with a green tag, and those with a revoked one. */
242
- export function readCiTags(top: string): { green: Map<string, string>; revoked: Set<string> } {
243
- const green = new Map<string, string>();
244
- const revoked = new Set<string>();
245
- const lines = git(top, ["for-each-ref", "--format=%(refname:lstrip=2)%09%(taggerdate:iso-strict)", `refs/tags/${CI_GREEN_TAG_PREFIX}`, `refs/tags/${CI_REVOKED_TAG_PREFIX}`]);
246
- for (const line of lines.split("\n").filter(Boolean)) {
247
- const [tag, date] = line.split("\t");
248
- if (tag.startsWith(CI_GREEN_TAG_PREFIX)) green.set(tag.slice(CI_GREEN_TAG_PREFIX.length), date ?? "");
249
- else revoked.add(tag.slice(CI_REVOKED_TAG_PREFIX.length));
317
+ /** A ci tag as git holds it, before it is judged. */
318
+ interface CiTagRef {
319
+ kind: "green" | "revoked";
320
+ /** The commit the tag's name gives. */
321
+ sha: string;
322
+ tag: string;
323
+ /** The tagger's date, or null for a lightweight tag. */
324
+ tagged: string | null;
325
+ /** The annotation's JSON record, when the tag is annotated and it parses. */
326
+ record: Record<string, unknown> | null;
327
+ /** The commit the tag points at. */
328
+ target: string;
329
+ }
330
+
331
+ /** Every ci tag in the repository, read from local refs. */
332
+ function readCiTagRefs(top: string): CiTagRef[] {
333
+ const out: CiTagRef[] = [];
334
+ const format = "%(refname:lstrip=2)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(taggerdate:iso-strict)%00%(contents)%01";
335
+ const raw = git(top, ["for-each-ref", `--format=${format}`, `refs/tags/${CI_GREEN_TAG_PREFIX}`, `refs/tags/${CI_REVOKED_TAG_PREFIX}`]);
336
+ for (const entry of raw.split("\u0001")) {
337
+ const [tag, type, object, peeled, date, contents] = entry.replace(/^\n/, "").split("\u0000");
338
+ if (!tag) continue;
339
+ const annotated = type === "tag";
340
+ let record: CiTagRef["record"] = null;
341
+ // A lightweight tag has no tagger date, and its "contents" are the commit's message.
342
+ if (annotated && date && contents && contents.includes("{")) {
343
+ try {
344
+ const parsed = JSON.parse(contents.slice(contents.indexOf("{")));
345
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) record = parsed as Record<string, unknown>;
346
+ } catch {
347
+ record = null;
348
+ }
349
+ }
350
+ const green = tag.startsWith(CI_GREEN_TAG_PREFIX);
351
+ out.push({
352
+ kind: green ? "green" : "revoked",
353
+ sha: tag.slice((green ? CI_GREEN_TAG_PREFIX : CI_REVOKED_TAG_PREFIX).length),
354
+ tag,
355
+ tagged: date || null,
356
+ record,
357
+ target: (annotated ? peeled : object) ?? "",
358
+ });
359
+ }
360
+ return out;
361
+ }
362
+
363
+ /** A ci tag that exists and does not count (#4031), and why. */
364
+ export interface IgnoredCiTag {
365
+ tag: string;
366
+ /** `unsigned`, `invalid`, `untrusted` or `mismatch`, as a run statement's attestation says it. */
367
+ status: CiTagFailure;
368
+ reason: string;
369
+ }
370
+
371
+ /** A tag that counts, with what its annotation recorded. */
372
+ export interface CountedCiTag {
373
+ tagged: string | null;
374
+ /** The annotation's JSON record, when the tag is annotated and it parses. */
375
+ record: Record<string, unknown> | null;
376
+ /** The runner whose statement it carries, or null when it counts unsigned (#4031). */
377
+ signer: string | null;
378
+ }
379
+
380
+ /** The ci tags as readers count them (#4031). */
381
+ export interface CiTags {
382
+ green: Map<string, CountedCiTag>;
383
+ revoked: Map<string, CountedCiTag>;
384
+ /** Tags that exist and do not count, by the commit their name gives. */
385
+ ignored: Map<string, IgnoredCiTag[]>;
386
+ }
387
+
388
+ /**
389
+ * The commits whose tags count unsigned under `signedSince` (#4031): its
390
+ * strict first-parent ancestors, the history the green branch had before the
391
+ * first commit whose tags must be signed. Position in history is something a
392
+ * committer can't set, unlike a commit's or a tag's date (ws-069 d). Throws
393
+ * `declaration-invalid` when `signedSince` names no commit here.
394
+ */
395
+ export function unsignedHistory(top: string, signedSince: string): Set<string> {
396
+ if (tryGit(top, ["rev-parse", "--verify", "--quiet", `${signedSince}^{commit}`]) === null) {
397
+ throw new WorkspaceReadError(
398
+ "declaration-invalid",
399
+ `ci.green.signedSince names ${signedSince}, which is not a commit in this repository; it must be the full sha of the first commit on the green branch whose tags must be signed (fetch the branch if it is newer than this clone)`,
400
+ );
250
401
  }
251
- return { green, revoked };
402
+ return new Set(git(top, ["rev-list", "--first-parent", `${signedSince}^@`]).split("\n").filter(Boolean));
403
+ }
404
+
405
+ /**
406
+ * Read the ci tags and judge each one (#4031). Without `signedSince` in
407
+ * `green` every tag counts, as before. With it, a tag on a strict
408
+ * first-parent ancestor of `signedSince` counts unsigned, and any other tag
409
+ * counts only when it points at the commit its name gives and its annotation
410
+ * carries a statement a runner key at `base` signed for that commit, tag and
411
+ * branch. The runner keys and roles are read from `.chant/trust.json` at
412
+ * `base`, the commit the caller reads the branch at. Throws
413
+ * `declaration-invalid` when `signedSince` names no commit here.
414
+ */
415
+ export function readCiTags(top: string, green: CiGreen | null = null, base: string | null = null): CiTags {
416
+ const out: CiTags = { green: new Map(), revoked: new Map(), ignored: new Map() };
417
+ const before = green?.signedSince ? unsignedHistory(top, green.signedSince) : null;
418
+ let policy: ReturnType<typeof readTrustPolicy> | undefined;
419
+ const policyAtBase = () => (policy ??= base ? readTrustPolicy(gitRevisionSource(top, base), base) : emptyPolicy(null));
420
+ for (const t of readCiTagRefs(top)) {
421
+ const counted = (signer: string | null) => (t.kind === "green" ? out.green : out.revoked).set(t.sha, { tagged: t.tagged, record: t.record, signer });
422
+ if (before === null) {
423
+ counted(null);
424
+ continue;
425
+ }
426
+ const ignore = (status: CiTagFailure, reason: string) => out.ignored.set(t.sha, [...(out.ignored.get(t.sha) ?? []), { tag: t.tag, status, reason }]);
427
+ if (t.target !== t.sha) {
428
+ ignore("mismatch", `${t.tag} points at ${t.target.slice(0, 12) || "nothing"}, not at the commit its name gives`);
429
+ continue;
430
+ }
431
+ if (before.has(t.sha)) {
432
+ counted(null);
433
+ continue;
434
+ }
435
+ const p = policyAtBase();
436
+ const v = verifyCiTagStatement(t.record, { sha: t.sha, kind: t.kind, branch: green!.branch }, p.runners, p.roles);
437
+ if (v.status === "signed") counted(v.signer);
438
+ else ignore(v.status, `${v.reason}; ci.green.signedSince is ${green!.signedSince}, so a tag on a commit that is not a strict first-parent ancestor of it needs one`);
439
+ }
440
+ return out;
252
441
  }
253
442
 
254
443
  /** The declaration's `ci.green`, read where `cwd` is, and the git top. */
@@ -264,12 +453,26 @@ function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { nam
264
453
 
265
454
  export interface TickRequest {
266
455
  cwd: string;
267
- forge: CiForge;
456
+ /**
457
+ * Where check runs come from. A function is called only when the source
458
+ * is `checks`, so a tick from runs needs no forge token. Required for
459
+ * `checks`.
460
+ */
461
+ forge?: CiForge | (() => CiForge);
462
+ /** Where phase results come from (#4044): overrides `ci.green.source`. */
463
+ source?: CiSource;
268
464
  /** The git remote the branch and the tags are fetched from and pushed to. Default `origin`. */
269
465
  remote?: string;
270
466
  /** Decide and report, and make and push no tag. */
271
467
  dryRun?: boolean;
272
468
  now?: Date;
469
+ /**
470
+ * A runner's Ed25519 private key in PEM (#4031). The tick signs each tag
471
+ * it makes with it, and refuses a key `.chant/trust.json` at the branch tip
472
+ * does not list as a runner whose principal holds the `runner` role. When
473
+ * `ci.green.signedSince` is set the tick needs one.
474
+ */
475
+ runnerKey?: string;
273
476
  }
274
477
 
275
478
  export interface TickResult {
@@ -280,6 +483,34 @@ export interface TickResult {
280
483
  commits: TickCommit[];
281
484
  /** The tags made and pushed, or that would be under `dryRun`. */
282
485
  made: string[];
486
+ /** The runner the tags were signed as (#4031), or null when they carry no statement. */
487
+ signer: string | null;
488
+ /** Where the phase results came from (#4044). */
489
+ source: CiSource;
490
+ }
491
+
492
+ /**
493
+ * The signer a runner key makes (#4031): the principal `.chant/trust.json`
494
+ * at `base` lists the key for, who must hold the `runner` role. Throws when
495
+ * the key is not one, so a tick never makes a tag its readers would ignore.
496
+ */
497
+ export function ciTagSigner(top: string, base: string, pem: string, what = "the tags it signed"): CiTagSigner {
498
+ let loaded: ReturnType<typeof loadRunnerKey>;
499
+ try {
500
+ loaded = loadRunnerKey(pem);
501
+ } catch (err) {
502
+ throw new Error(`the runner key is not an Ed25519 private key in PEM: ${err instanceof Error ? err.message : String(err)}`);
503
+ }
504
+ const policy = readTrustPolicy(gitRevisionSource(top, base), base);
505
+ const at = `.chant/trust.json at ${base.slice(0, 12)}`;
506
+ if (policy.signers.some((s) => s.key === loaded.publicKey)) {
507
+ throw new Error(`the runner key is a signer's key in ${policy.signersPath}; ci tags and runs are signed by a CI identity's key, never by a person's`);
508
+ }
509
+ const listed = policy.runners.find((r) => r.key === loaded.publicKey);
510
+ if (!listed) throw new Error(`${at} lists no runner with this key, so ${what} would be ignored. Add it under "runners" first:\n ${loaded.publicKey}`);
511
+ const holds = (policy.roles[CI_TAG_SIGNER_ROLE] ?? []).some((p) => p.normalize("NFKC").trim().toLowerCase() === listed.principal.normalize("NFKC").trim().toLowerCase());
512
+ if (!holds) throw new Error(`${listed.principal} holds no ${CI_TAG_SIGNER_ROLE} role in ${at}, so ${what} would be ignored. Grant it: "roles": { "${CI_TAG_SIGNER_ROLE}": ["${listed.principal}"] }`);
513
+ return { principal: listed.principal, key: loaded.key, publicKey: loaded.publicKey };
283
514
  }
284
515
 
285
516
  /**
@@ -288,7 +519,11 @@ export interface TickResult {
288
519
  * window, make the tags that are due and push them, never forced.
289
520
  */
290
521
  export async function ciTick(req: TickRequest): Promise<TickResult> {
291
- const { green, top } = readGreen(req.cwd);
522
+ const read = readGreen(req.cwd);
523
+ const top = read.top;
524
+ const green: CiGreen = { ...read.green, source: req.source ?? read.green.source };
525
+ const forge = green.source === "checks" ? (typeof req.forge === "function" ? req.forge() : req.forge) : undefined;
526
+ if (green.source === "checks" && !forge) throw new Error("ci.green.source is checks, so the tick reads check runs and needs a forge");
292
527
  const remote = req.remote ?? "origin";
293
528
  const now = req.now ?? new Date();
294
529
  const tracking = `refs/remotes/${remote}/${green.branch}`;
@@ -309,15 +544,44 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
309
544
  .filter(([, ct]) => Number(ct) >= cutoff)
310
545
  .map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
311
546
 
312
- const tags = readCiTags(top);
547
+ const base = git(top, ["rev-parse", `${tracking}^{commit}`]);
548
+ if (green.signedSince && req.runnerKey === undefined && !req.dryRun) {
549
+ throw new Error(
550
+ `ci.green.signedSince is ${green.signedSince}, so every tag the tick makes needs a statement signed by a runner key, and none was given: pass --key <runner key.pem> or set CHANT_RUNNER_KEY`,
551
+ );
552
+ }
553
+ if (green.source === "runs" && req.runnerKey === undefined && !req.dryRun) {
554
+ throw new Error("the tick reads chant runs (source runs), and every tag it makes from them is signed by a runner key, and none was given: pass --key <runner key.pem> or set CHANT_RUNNER_KEY");
555
+ }
556
+ const signer = req.runnerKey !== undefined ? ciTagSigner(top, base, req.runnerKey) : undefined;
557
+ const links = await commitLinks(req.cwd, top, tracking, commits.map((c) => c.sha));
558
+ const tags = readCiTags(top, green, base);
559
+ let runs: OpRunSource | undefined;
560
+ if (green.source === "runs") {
561
+ // The runs live on chant/lifecycle, which a CI checkout may not have yet.
562
+ const fetched = await fetchLifecycleStatus({ cwd: top });
563
+ if (fetched.status === "diverged" || fetched.status === "unreachable") {
564
+ throw new Error(`could not fetch chant/lifecycle, where the Op runs are recorded, from ${fetched.remote}: ${fetched.status === "diverged" ? "the local branch and the remote's have diverged" : fetched.stderr}`);
565
+ }
566
+ runs = await readOpRunSource(req.cwd, top, base);
567
+ }
313
568
  const planned = await planTick({
314
569
  green,
315
570
  commits,
316
571
  greenTags: new Set(tags.green.keys()),
317
- revokedTags: tags.revoked,
318
- checkRuns: (sha) => req.forge.checkRuns(sha),
572
+ revokedTags: new Set(tags.revoked.keys()),
573
+ ignored: tags.ignored,
574
+ checkRuns: async (sha) => (runs ? runs.checkRuns(sha) : forge!.checkRuns(sha)),
319
575
  now,
576
+ signer,
320
577
  });
578
+ if (links) for (const c of planned) c.work = links.get(c.sha);
579
+ if (runs) {
580
+ for (const c of planned) {
581
+ const ignored = runs.ignored.get(c.sha);
582
+ if (ignored && ignored.length > 0) c.ignoredRuns = ignored;
583
+ }
584
+ }
321
585
  const due = planned.filter((c) => c.made !== null);
322
586
  if (!req.dryRun && due.length > 0) {
323
587
  const ident = taggerConfig(top);
@@ -331,7 +595,80 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
331
595
  throw new Error(`could not push ${due.map((c) => c.made!.tag).join(", ")} to ${remote}: ${cause ? `${cause}\n` : ""}${stderr}`);
332
596
  }
333
597
  }
334
- return { branch: green.branch, window: green.window, ref: tracking, commits: planned, made: due.map((c) => c.made!.tag) };
598
+ return { branch: green.branch, window: green.window, ref: tracking, commits: planned, made: due.map((c) => c.made!.tag), signer: signer?.principal ?? null, source: green.source };
599
+ }
600
+
601
+ // ── Chant runs as the source (#4044) ─────────────────────────────────────────
602
+
603
+ /** The Op runs on the ledger, judged, as check runs by commit. */
604
+ export interface OpRunSource {
605
+ /** The signed runs on `sha`, each as a check run named for its Op. */
606
+ checkRuns(sha: string): CheckRun[];
607
+ /** The runs on each commit that do not count, by commit. */
608
+ ignored: Map<string, IgnoredOpRun[]>;
609
+ }
610
+
611
+ const RUN_LEDGER_FILE = /^runs__(.+)\.jsonl$/;
612
+
613
+ /**
614
+ * An Op run as a check run (#4044): `ok` passes and `fail` fails once the
615
+ * run has settled; a run stopped at a gate or a decision point is still
616
+ * going. The id is the run's end time in milliseconds, so the latest run of
617
+ * an Op is its latest attempt.
618
+ */
619
+ export function opRunCheckRun(record: OpRunRecord, signer: string): CheckRun {
620
+ const settled = record.status === "ok" || record.status === "fail";
621
+ const ended = Date.parse(record.ended);
622
+ return {
623
+ id: Number.isFinite(ended) ? ended : 0,
624
+ name: record.op,
625
+ status: settled ? "completed" : record.status,
626
+ conclusion: settled ? (record.status === "ok" ? "success" : "failure") : null,
627
+ completedAt: settled ? record.ended : null,
628
+ url: record.runOrigin?.url ?? null,
629
+ opRun: { id: record.id, env: record.env, signer, statement: record.statement! },
630
+ };
631
+ }
632
+
633
+ /**
634
+ * Judge every Op run record that names a commit against `.chant/trust.json`
635
+ * at `base` (#4044), from the run ledger the project at `cwd` writes to. A
636
+ * record with a valid statement becomes a check run on its `gitSha`; any
637
+ * other is kept as an ignored run. Reads local refs only.
638
+ */
639
+ export async function readOpRunSource(cwd: string, top: string, base: string): Promise<OpRunSource> {
640
+ const policy = readTrustPolicy(gitRevisionSource(top, base), base);
641
+ const byCommit = new Map<string, CheckRun[]>();
642
+ const ignored = new Map<string, IgnoredOpRun[]>();
643
+ for (const env of await listLedgerTrees({ cwd })) {
644
+ for (const file of await listFilesInDir(env, { cwd })) {
645
+ const op = RUN_LEDGER_FILE.exec(file)?.[1];
646
+ if (!op) continue;
647
+ const { records } = await readRunLedger(env, op, { cwd });
648
+ for (const record of records) {
649
+ if (!record.gitSha) continue;
650
+ const v = verifyOpRunStatement(record, policy.runners, policy.roles);
651
+ if (v.status === "signed") byCommit.set(record.gitSha, [...(byCommit.get(record.gitSha) ?? []), opRunCheckRun(record, v.signer)]);
652
+ else ignored.set(record.gitSha, [...(ignored.get(record.gitSha) ?? []), { run: record.id, op: record.op, env: record.env, status: v.status, reason: v.reason }]);
653
+ }
654
+ }
655
+ }
656
+ return { checkRuns: (sha) => byCommit.get(sha) ?? [], ignored };
657
+ }
658
+
659
+ /**
660
+ * What `chant run <op> --key` signs a run's record with (#4044): the runner
661
+ * key `.chant/trust.json` at the checkout's `HEAD` lists for a principal
662
+ * holding the `runner` role. Throws when the key is not one, or `cwd` is in
663
+ * no git checkout.
664
+ */
665
+ export function opRunSigner(cwd: string, pem: string): (record: OpRunRecord) => NonNullable<OpRunRecord["statement"]> {
666
+ const top = gitTop(cwd);
667
+ if (!top) throw new Error("a run is signed against .chant/trust.json at HEAD, and this directory is not in a git repository");
668
+ const head = tryGit(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
669
+ if (head === null) throw new Error("a run is signed against .chant/trust.json at HEAD, and this repository has no commit yet");
670
+ const signer = ciTagSigner(top, head, pem, "the runs it signed");
671
+ return (record) => signOpRunRecord(record, signer);
335
672
  }
336
673
 
337
674
  /**
@@ -361,11 +698,37 @@ function stderrOf(err: unknown): string {
361
698
  return String(e.stderr ?? e.message ?? err).trim();
362
699
  }
363
700
 
701
+ /**
702
+ * Whether each commit names its work item (#4029), judged against the work
703
+ * records at `ref`. Undefined when the workspace declares no work kind or
704
+ * they can't be read: then no commit has a link state to report.
705
+ */
706
+ async function commitLinks(cwd: string, top: string, ref: string, shas: string[]): Promise<Map<string, CommitLink> | undefined> {
707
+ if (shas.length === 0) return undefined;
708
+ try {
709
+ const index = await readWorkIndex(cwd, top, ref);
710
+ if (!index) return undefined;
711
+ return judgeCommits(top, shas, index, mergeCommits(top, shas));
712
+ } catch {
713
+ // A tag and a green list stand without the link, which only explains a commit.
714
+ return undefined;
715
+ }
716
+ }
717
+
718
+ /** Why a commit's work item is unknown, for the text of `ci tick` and `ci list`; undefined for a commit that names one. */
719
+ export function whyWorkUnknown(work: CommitLink | null | undefined): string | undefined {
720
+ if (!work) return undefined;
721
+ if (work.state === "unlinked") return "no Chant-Record or Chant-Lease trailer names a work item";
722
+ if (work.state === "wrong") return `the work item named is not there: ${work.problems.map((p) => `${p.trailer} (${p.message})`).join("; ")}`;
723
+ return undefined;
724
+ }
725
+
364
726
  /** The text `chant ci tick` prints. */
365
727
  export function formatTick(r: TickResult, dryRun = false): string {
366
- const lines = [`${r.branch}, commits within ${r.window}: ${r.commits.length}`];
728
+ const lines = [`${r.branch}, commits within ${r.window}${r.source === "runs" ? ", judged from chant runs" : ""}: ${r.commits.length}`];
367
729
  for (const c of r.commits) {
368
730
  const short = c.sha.slice(0, 12);
731
+ const before = lines.length;
369
732
  if (c.made) {
370
733
  lines.push(` ${short} ${dryRun ? "would make" : "made"} ${c.made.tag.slice(0, c.made.tag.lastIndexOf("/"))}${c.verdict?.failure ? `: phase ${c.verdict.failure.phase}, ${c.verdict.failure.run.name} ${c.verdict.failure.run.conclusion}` : ""}`);
371
734
  } else if (c.before === "revoked") {
@@ -376,12 +739,26 @@ export function formatTick(r: TickResult, dryRun = false): string {
376
739
  const what = c.before === "green" ? "green" : v.verdict === "fail" ? `failed: phase ${v.failure!.phase}, ${v.failure!.run.name} ${v.failure!.run.conclusion}` : `undecided: ${waiting.join(", ")}`;
377
740
  lines.push(` ${short} ${what}`);
378
741
  }
742
+ const why = whyWorkUnknown(c.work);
743
+ if (why) lines[before] += `; why unknown: ${why}`;
744
+ for (const t of c.ignored ?? []) lines.push(` ignored ${t.tag} (${t.status}): ${t.reason}`);
745
+ for (const r of c.ignoredRuns ?? []) lines.push(` ignored run ${r.run} of ${r.op} in ${r.env} (${r.status}): ${r.reason}`);
746
+ if (c.blocked) lines.push(` did not make ${c.blocked}: an ignored tag holds the name. Delete it to let the tick tag the commit: git push origin :refs/tags/${c.blocked}`);
379
747
  }
748
+ if (r.signer && r.made.length > 0) lines.push(`signed as ${r.signer}`);
380
749
  return lines.join("\n");
381
750
  }
382
751
 
383
752
  // ── last-green ───────────────────────────────────────────────────────────────
384
753
 
754
+ /** The ref a reader walks: the remote-tracking branch when there is one, else the local branch. */
755
+ function branchRefOf(top: string, green: CiGreen, remote = "origin"): string {
756
+ const candidates = [`refs/remotes/${remote}/${green.branch}`, `refs/heads/${green.branch}`];
757
+ const ref = candidates.find((r) => tryGit(top, ["rev-parse", "--verify", "--quiet", `${r}^{commit}`]) !== null);
758
+ if (!ref) throw new CiGreenError("ci-branch-unknown", `this repository has neither ${candidates.join(" nor ")}`);
759
+ return ref;
760
+ }
761
+
385
762
  export type LastGreenDocument =
386
763
  | {
387
764
  $schema: string;
@@ -389,9 +766,11 @@ export type LastGreenDocument =
389
766
  chant: string;
390
767
  workspace: { name: string; root: string; file: string };
391
768
  branch: string;
392
- /** The ref walked: the remote-tracking branch when there is one, else the local branch. */
769
+ /** The ref walked: the remote-tracking branch when there is one, else the local branch, or the revision `from` named. */
393
770
  ref: string;
394
771
  commit: { sha: string; tag: string; tagged: string | null; committed: string } | null;
772
+ /** Tags on the commits walked, down to the one found, that were ignored for want of a valid statement (#4031). Newest first. */
773
+ ignored: IgnoredCiTag[];
395
774
  }
396
775
  | { $schema: string; contract: number; chant: string; error: { code: CiGreenErrorCode; message: string } };
397
776
 
@@ -400,25 +779,228 @@ export type LastGreenDocument =
400
779
  * green tag and no revoked tag. It reads local refs and tags and never
401
780
  * fetches, so a reader fetches first (`git fetch origin --tags`).
402
781
  */
403
- export function lastGreen(req: { cwd: string; remote?: string }): LastGreenDocument {
782
+ export function lastGreen(req: { cwd: string; remote?: string; from?: string }): LastGreenDocument {
404
783
  const head = { $schema: CI_LAST_GREEN_SCHEMA_ID, contract: CI_LAST_GREEN_CONTRACT_VERSION, chant: readerVersion() };
405
784
  try {
406
785
  const { green, top, workspace } = readGreen(req.cwd);
407
- const candidates = [`refs/remotes/${req.remote ?? "origin"}/${green.branch}`, `refs/heads/${green.branch}`];
408
- const ref = candidates.find((r) => tryGit(top, ["rev-parse", "--verify", "--quiet", `${r}^{commit}`]) !== null);
409
- if (!ref) throw new CiGreenError("ci-branch-unknown", `this repository has neither ${candidates.join(" nor ")}`);
410
- const tags = readCiTags(top);
786
+ const branchRef = branchRefOf(top, green, req.remote);
787
+ let ref = branchRef;
788
+ if (req.from !== undefined) {
789
+ if (req.from.startsWith("-") || tryGit(top, ["rev-parse", "--verify", "--quiet", `${req.from}^{commit}`]) === null) {
790
+ throw new WorkspaceReadError("revision-unknown", `--from ${req.from} names no commit in this repository`);
791
+ }
792
+ ref = req.from;
793
+ }
794
+ // The runner keys are read where the branch is, whichever commit the walk starts from.
795
+ const tags = readCiTags(top, green, git(top, ["rev-parse", `${branchRef}^{commit}`]));
411
796
  let commit: Extract<LastGreenDocument, { branch: string }>["commit"] = null;
412
- if (tags.green.size > 0) {
797
+ const ignored: IgnoredCiTag[] = [];
798
+ if (tags.green.size > 0 || tags.ignored.size > 0) {
413
799
  for (const sha of git(top, ["rev-list", "--first-parent", ref]).split("\n")) {
800
+ ignored.push(...(tags.ignored.get(sha) ?? []));
414
801
  if (!tags.green.has(sha) || tags.revoked.has(sha)) continue;
415
- commit = { sha, tag: `${CI_GREEN_TAG_PREFIX}${sha}`, tagged: tags.green.get(sha) || null, committed: git(top, ["show", "-s", "--format=%cI", sha]) };
802
+ commit = { sha, tag: `${CI_GREEN_TAG_PREFIX}${sha}`, tagged: tags.green.get(sha)!.tagged, committed: git(top, ["show", "-s", "--format=%cI", sha]) };
416
803
  break;
417
804
  }
418
805
  }
419
- return { ...head, workspace, branch: green.branch, ref, commit };
806
+ return { ...head, workspace, branch: green.branch, ref, commit, ignored };
807
+ } catch (err) {
808
+ if (err instanceof CiGreenError || err instanceof WorkspaceReadError) return { ...head, error: { code: err.code, message: err instanceof WorkspaceReadError ? err.describe() : err.message } };
809
+ throw err;
810
+ }
811
+ }
812
+
813
+ // ── list ─────────────────────────────────────────────────────────────────────
814
+
815
+ export const CI_LIST_CONTRACT_VERSION = 1;
816
+ export const CI_LIST_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/ci-list/v1/ci-list.schema.json";
817
+
818
+ /** A commit's state from its tags: `green`, `revoked` (a revoked tag wins), or `undecided` (no ci tag). */
819
+ export type CiCommitState = "green" | "revoked" | "undecided";
820
+
821
+ /**
822
+ * One required phase's result on a commit. `pass` and `fail` are what a tag
823
+ * recorded or a live read found, `pending` is a live read with runs still
824
+ * going or not yet reported, and `unrecorded` means no tag holds the result
825
+ * and the forge was not asked.
826
+ */
827
+ export type CiPhaseResult = "pass" | "fail" | "pending" | "unrecorded";
828
+
829
+ export interface CiListRun {
830
+ name: string;
831
+ id: number | null;
832
+ conclusion: string | null;
833
+ url: string | null;
834
+ /** The Op run, when the run is a chant run (#4044): its id, env and the runner whose statement it carries. Absent for a check run. */
835
+ opRun?: { id: string; env: string; signer: string };
836
+ }
837
+
838
+ export interface CiListPhase {
839
+ phase: string;
840
+ result: CiPhaseResult;
841
+ runs: CiListRun[];
842
+ }
843
+
844
+ export interface CiListCommit {
845
+ sha: string;
846
+ /** Committer time, ISO 8601. */
847
+ committed: string;
848
+ state: CiCommitState;
849
+ /**
850
+ * The green tag's name and time, or null when the commit has none that
851
+ * counts. `tagged` is null for a lightweight tag. `signer` is the runner
852
+ * whose statement it carries, or null when it counts unsigned (#4031).
853
+ */
854
+ green: { tag: string; tagged: string | null; signer: string | null } | null;
855
+ /** The revoked tag's name, time, the phase and run its annotation names, and its signer as for `green`. */
856
+ revoked: { tag: string; tagged: string | null; phase: string | null; run: CiListRun | null; signer: string | null } | null;
857
+ /** The commit's ci tags that do not count, each for want of a valid statement (#4031). Empty unless ci.green.signedSince is set. */
858
+ ignored: IgnoredCiTag[];
859
+ /** Each required phase in `require` order. */
860
+ phases: CiListPhase[];
861
+ /**
862
+ * Whether the commit names its work item (#4029), or null when the checkout
863
+ * is in no workspace that declares a work kind.
864
+ */
865
+ work: CommitLink | null;
866
+ /** The Op runs on the commit that do not count, each for want of a valid statement (#4044). Present only when the source is chant runs. */
867
+ ignoredRuns?: IgnoredOpRun[];
868
+ }
869
+
870
+ export type CiListDocument =
871
+ | {
872
+ $schema: string;
873
+ contract: number;
874
+ chant: string;
875
+ workspace: { name: string; root: string; file: string };
876
+ branch: string;
877
+ ref: string;
878
+ /** ci.green.window, or null when `since` replaced it. */
879
+ window: string | null;
880
+ /** The revision the walk stopped at (exclusive), when `since` was given. */
881
+ since: string | null;
882
+ /** ci.green.require. */
883
+ require: string[];
884
+ /** Newest first. */
885
+ commits: CiListCommit[];
886
+ }
887
+ | { $schema: string; contract: number; chant: string; error: { code: CiGreenErrorCode; message: string } };
888
+
889
+ const asRun = (r: unknown): CiListRun | null => {
890
+ if (!r || typeof r !== "object") return null;
891
+ const o = r as Record<string, unknown>;
892
+ if (typeof o.name !== "string") return null;
893
+ const op = o.opRun as Record<string, unknown> | undefined;
894
+ const opRun = op && typeof op === "object" && typeof op.id === "string" && typeof op.env === "string" && typeof op.signer === "string" ? { opRun: { id: op.id, env: op.env, signer: op.signer } } : {};
895
+ return { name: o.name, id: typeof o.id === "number" ? o.id : null, conclusion: typeof o.conclusion === "string" ? o.conclusion : null, url: typeof o.url === "string" ? o.url : null, ...opRun };
896
+ };
897
+
898
+ /** A judged run as `ci list` shows it. */
899
+ const listRun = (r: RunVerdict): CiListRun => ({ name: r.name, id: r.opRun ? null : r.id, conclusion: r.conclusion, url: r.url, ...(r.opRun ? { opRun: { id: r.opRun.id, env: r.opRun.env, signer: r.opRun.signer } } : {}) });
900
+
901
+ /** What the green tag's annotation recorded for a phase: its runs, all of which passed when the tag was made. */
902
+ function recordedPhase(green: CountedCiTag | undefined, phase: string): CiListPhase {
903
+ const runs = (green?.record?.phases as Record<string, unknown> | undefined)?.[phase];
904
+ if (!Array.isArray(runs)) return { phase, result: "unrecorded", runs: [] };
905
+ return { phase, result: "pass", runs: runs.map(asRun).filter((r): r is CiListRun => r !== null) };
906
+ }
907
+
908
+ export interface ListRequest {
909
+ cwd: string;
910
+ remote?: string;
911
+ /** Keep only the newest n commits. */
912
+ limit?: number;
913
+ /** Walk back to this revision, exclusive, instead of the declared window. */
914
+ since?: string;
915
+ /** Judge undecided commits from the forge's check runs. Without it they list as `unrecorded`. */
916
+ forge?: CiForge;
917
+ /**
918
+ * Where phase results come from (#4044): overrides `ci.green.source`. With
919
+ * `runs`, undecided commits are judged from the signed Op runs on the
920
+ * local `chant/lifecycle`, and `forge` is refused.
921
+ */
922
+ source?: CiSource;
923
+ now?: Date;
924
+ }
925
+
926
+ /**
927
+ * `chant ci list`: the branch's first-parent commits within the window, each
928
+ * with its state from the ci tags and each required phase's result. Tags
929
+ * give the green and revoked results offline; only undecided commits need
930
+ * the forge, and only when a `forge` is given. It never fetches.
931
+ */
932
+ export async function ciList(req: ListRequest): Promise<CiListDocument> {
933
+ const head = { $schema: CI_LIST_SCHEMA_ID, contract: CI_LIST_CONTRACT_VERSION, chant: readerVersion() };
934
+ try {
935
+ const { green, top, workspace } = readGreen(req.cwd);
936
+ const ref = branchRefOf(top, green, req.remote);
937
+
938
+ const logArgs = ["log", "--first-parent", "--format=%H %ct"];
939
+ let cutoff = 0;
940
+ if (req.since !== undefined) {
941
+ if (tryGit(top, ["rev-parse", "--verify", "--quiet", `${req.since}^{commit}`]) === null) throw new WorkspaceReadError("revision-unknown", `--since ${req.since} names no commit in this repository`);
942
+ logArgs.push(`${req.since}..${ref}`);
943
+ } else {
944
+ cutoff = Math.floor(((req.now ?? new Date()).getTime() - windowMs(green.window)) / 1000);
945
+ logArgs.push(`--max-age=${cutoff}`, ref);
946
+ }
947
+ let commits = git(top, logArgs)
948
+ .split("\n")
949
+ .filter(Boolean)
950
+ .map((line) => line.split(" "))
951
+ .filter(([, ct]) => Number(ct) >= cutoff)
952
+ .map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
953
+ if (req.limit !== undefined) commits = commits.slice(0, req.limit);
954
+
955
+ const links = await commitLinks(req.cwd, top, ref, commits.map((c) => c.sha));
956
+ const base = git(top, ["rev-parse", `${ref}^{commit}`]);
957
+ const tags = readCiTags(top, green, base);
958
+ const source = req.source ?? green.source;
959
+ if (source === "runs" && req.forge) throw new Error("--forge reads check runs, and the source is chant runs (ci.green.source or --source runs); drop one");
960
+ const runs = source === "runs" ? await readOpRunSource(req.cwd, top, base) : undefined;
961
+ const out: CiListCommit[] = [];
962
+ for (const c of commits) {
963
+ const g = tags.green.get(c.sha);
964
+ const r = tags.revoked.get(c.sha);
965
+ const state: CiCommitState = r ? "revoked" : g ? "green" : "undecided";
966
+ let phases = green.require.map((name) => recordedPhase(g, name));
967
+ let revoked: CiListCommit["revoked"] = null;
968
+ if (r) {
969
+ const phase = typeof r.record?.phase === "string" ? r.record.phase : null;
970
+ const run = asRun(r.record?.run);
971
+ revoked = { tag: `${CI_REVOKED_TAG_PREFIX}${c.sha}`, tagged: r.tagged, phase, run, signer: r.signer };
972
+ phases = phases.map((p) => (p.phase === phase ? { phase: p.phase, result: "fail", runs: run ? [run] : [] } : p));
973
+ } else if (!g && (runs || req.forge)) {
974
+ const verdict = evaluateCommit(green, runs ? runs.checkRuns(c.sha) : await req.forge!.checkRuns(c.sha));
975
+ phases = verdict.phases.map((p) => ({ phase: p.phase, result: p.verdict, runs: p.runs.map(listRun) }));
976
+ }
977
+ out.push({
978
+ ...c,
979
+ state,
980
+ green: g ? { tag: `${CI_GREEN_TAG_PREFIX}${c.sha}`, tagged: g.tagged, signer: g.signer } : null,
981
+ revoked,
982
+ phases,
983
+ work: links?.get(c.sha) ?? null,
984
+ ignored: tags.ignored.get(c.sha) ?? [],
985
+ ...(runs ? { ignoredRuns: runs.ignored.get(c.sha) ?? [] } : {}),
986
+ });
987
+ }
988
+ return { ...head, workspace, branch: green.branch, ref, window: req.since === undefined ? green.window : null, since: req.since ?? null, require: [...green.require], commits: out };
420
989
  } catch (err) {
421
990
  if (err instanceof CiGreenError || err instanceof WorkspaceReadError) return { ...head, error: { code: err.code, message: err instanceof WorkspaceReadError ? err.describe() : err.message } };
422
991
  throw err;
423
992
  }
424
993
  }
994
+
995
+ /** The text `chant ci list` prints: one line per commit. */
996
+ export function formatList(doc: Extract<CiListDocument, { branch: string }>): string {
997
+ const lines = [`${doc.branch}, ${doc.since ? `commits after ${doc.since}` : `commits within ${doc.window}`}: ${doc.commits.length}`];
998
+ for (const c of doc.commits) {
999
+ const phases = c.phases.map((p) => `${p.phase}=${p.result}`).join(" ");
1000
+ const why = whyWorkUnknown(c.work);
1001
+ lines.push(` ${c.sha.slice(0, 12)} ${c.state.padEnd(9)} ${phases}${why ? `; why unknown: ${why}` : ""}`);
1002
+ for (const t of c.ignored) lines.push(` ignored ${t.tag} (${t.status}): ${t.reason}`);
1003
+ for (const r of c.ignoredRuns ?? []) lines.push(` ignored run ${r.run} of ${r.op} in ${r.env} (${r.status}): ${r.reason}`);
1004
+ }
1005
+ return lines.join("\n");
1006
+ }