@intentius/chant 0.90.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (402) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts +8 -0
  4. package/dist/cli/handlers/components.d.ts.map +1 -1
  5. package/dist/cli/handlers/graph.d.ts.map +1 -1
  6. package/dist/cli/handlers/operator.d.ts +20 -0
  7. package/dist/cli/handlers/operator.d.ts.map +1 -1
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/mcp/workspace-tools.d.ts +7 -4
  11. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  12. package/dist/cli/plugins.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +42 -1
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/config.d.ts +22 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/lexicon-module.d.ts +22 -0
  21. package/dist/lexicon-module.d.ts.map +1 -1
  22. package/dist/lifecycle/converge-ledger.d.ts +19 -0
  23. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/git.d.ts +40 -5
  25. package/dist/lifecycle/git.d.ts.map +1 -1
  26. package/dist/lifecycle/lease.d.ts +72 -16
  27. package/dist/lifecycle/lease.d.ts.map +1 -1
  28. package/dist/lifecycle/member-ledger.d.ts +3 -2
  29. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  30. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  31. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/symptoms.d.ts +51 -0
  35. package/dist/lifecycle/symptoms.d.ts.map +1 -1
  36. package/dist/lifecycle/work-lease.d.ts +140 -0
  37. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  38. package/dist/lint/rules/index.d.ts +4 -2
  39. package/dist/lint/rules/index.d.ts.map +1 -1
  40. package/dist/lint/rules/op/index.d.ts +14 -3
  41. package/dist/lint/rules/op/index.d.ts.map +1 -1
  42. package/dist/lint/rules/op/ops014-converge-rule-refusals.d.ts.map +1 -1
  43. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts +29 -0
  44. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts.map +1 -0
  45. package/dist/lint/rules/sys001-no-literal-key.d.ts +3 -0
  46. package/dist/lint/rules/sys001-no-literal-key.d.ts.map +1 -0
  47. package/dist/op/__fixtures__/decide-stub-backend.d.ts +53 -0
  48. package/dist/op/__fixtures__/decide-stub-backend.d.ts.map +1 -0
  49. package/dist/op/activities/activity-contracts.d.ts +203 -2
  50. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  51. package/dist/op/activities/apply.d.ts.map +1 -1
  52. package/dist/op/activities/change-coverage.d.ts +44 -0
  53. package/dist/op/activities/change-coverage.d.ts.map +1 -0
  54. package/dist/op/activities/converge.d.ts +24 -1
  55. package/dist/op/activities/converge.d.ts.map +1 -1
  56. package/dist/op/activities/decide.d.ts +107 -0
  57. package/dist/op/activities/decide.d.ts.map +1 -0
  58. package/dist/op/activities/index.d.ts +10 -0
  59. package/dist/op/activities/index.d.ts.map +1 -1
  60. package/dist/op/activities/shell.d.ts +32 -0
  61. package/dist/op/activities/shell.d.ts.map +1 -1
  62. package/dist/op/activities/source-release.d.ts +86 -0
  63. package/dist/op/activities/source-release.d.ts.map +1 -0
  64. package/dist/op/activities/source-rollback.d.ts +100 -0
  65. package/dist/op/activities/source-rollback.d.ts.map +1 -0
  66. package/dist/op/activities/work-evidence.d.ts +42 -0
  67. package/dist/op/activities/work-evidence.d.ts.map +1 -0
  68. package/dist/op/activity-contract.d.ts +1 -0
  69. package/dist/op/activity-contract.d.ts.map +1 -1
  70. package/dist/op/activity-profiles.d.ts +16 -0
  71. package/dist/op/activity-profiles.d.ts.map +1 -1
  72. package/dist/op/builders.d.ts +84 -2
  73. package/dist/op/builders.d.ts.map +1 -1
  74. package/dist/op/composites/converge-op.d.ts +31 -4
  75. package/dist/op/composites/converge-op.d.ts.map +1 -1
  76. package/dist/op/decide-backend.d.ts +116 -0
  77. package/dist/op/decide-backend.d.ts.map +1 -0
  78. package/dist/op/decide-config.d.ts +120 -0
  79. package/dist/op/decide-config.d.ts.map +1 -0
  80. package/dist/op/decide-read-inputs.d.ts +40 -0
  81. package/dist/op/decide-read-inputs.d.ts.map +1 -0
  82. package/dist/op/discover.d.ts +25 -0
  83. package/dist/op/discover.d.ts.map +1 -1
  84. package/dist/op/gate-wait.d.ts +43 -0
  85. package/dist/op/gate-wait.d.ts.map +1 -0
  86. package/dist/op/gate.d.ts +5 -2
  87. package/dist/op/gate.d.ts.map +1 -1
  88. package/dist/op/index.d.ts +18 -5
  89. package/dist/op/index.d.ts.map +1 -1
  90. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  91. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  92. package/dist/op/local-executor.d.ts +48 -4
  93. package/dist/op/local-executor.d.ts.map +1 -1
  94. package/dist/op/local-output.d.ts +2 -0
  95. package/dist/op/local-output.d.ts.map +1 -1
  96. package/dist/op/op-ir.d.ts +12 -1
  97. package/dist/op/op-ir.d.ts.map +1 -1
  98. package/dist/op/op-verb-class.d.ts.map +1 -1
  99. package/dist/op/operator.d.ts +126 -1
  100. package/dist/op/operator.d.ts.map +1 -1
  101. package/dist/op/runtime.d.ts +46 -5
  102. package/dist/op/runtime.d.ts.map +1 -1
  103. package/dist/op/runtimes/local.d.ts.map +1 -1
  104. package/dist/op/source-archive.d.ts +63 -0
  105. package/dist/op/source-archive.d.ts.map +1 -0
  106. package/dist/op/step-output-ref.d.ts +2 -2
  107. package/dist/op/step-output-ref.d.ts.map +1 -1
  108. package/dist/op/steward-points.d.ts +136 -0
  109. package/dist/op/steward-points.d.ts.map +1 -0
  110. package/dist/op/steward-turn.d.ts +53 -0
  111. package/dist/op/steward-turn.d.ts.map +1 -0
  112. package/dist/op/steward.d.ts +161 -0
  113. package/dist/op/steward.d.ts.map +1 -0
  114. package/dist/op/types.d.ts +58 -0
  115. package/dist/op/types.d.ts.map +1 -1
  116. package/dist/op/work-lease-decl.d.ts +18 -0
  117. package/dist/op/work-lease-decl.d.ts.map +1 -0
  118. package/dist/op/work-lease-run.d.ts +173 -0
  119. package/dist/op/work-lease-run.d.ts.map +1 -0
  120. package/dist/workspace/__fixtures__/decide-workspace.d.ts +100 -0
  121. package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -0
  122. package/dist/workspace/answers-ledger.d.ts +76 -0
  123. package/dist/workspace/answers-ledger.d.ts.map +1 -0
  124. package/dist/workspace/box-isolation.d.ts +99 -0
  125. package/dist/workspace/box-isolation.d.ts.map +1 -0
  126. package/dist/workspace/changes-cli.d.ts +16 -0
  127. package/dist/workspace/changes-cli.d.ts.map +1 -0
  128. package/dist/workspace/changes.d.ts +200 -0
  129. package/dist/workspace/changes.d.ts.map +1 -0
  130. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts +59 -0
  131. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts.map +1 -0
  132. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts +51 -0
  133. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts.map +1 -0
  134. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts +26 -0
  135. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts.map +1 -0
  136. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts +59 -0
  137. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts.map +1 -0
  138. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts +22 -0
  139. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts.map +1 -0
  140. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts +71 -0
  141. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -0
  142. package/dist/workspace/chant-migrations.d.ts +99 -0
  143. package/dist/workspace/chant-migrations.d.ts.map +1 -0
  144. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  145. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  146. package/dist/workspace/checks/boxes.d.ts +104 -0
  147. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  148. package/dist/workspace/checks/diagrams.d.ts +31 -0
  149. package/dist/workspace/checks/diagrams.d.ts.map +1 -0
  150. package/dist/workspace/checks/records.d.ts +8 -0
  151. package/dist/workspace/checks/records.d.ts.map +1 -1
  152. package/dist/workspace/checks.d.ts +13 -1
  153. package/dist/workspace/checks.d.ts.map +1 -1
  154. package/dist/workspace/compose-graph.d.ts +2 -0
  155. package/dist/workspace/compose-graph.d.ts.map +1 -1
  156. package/dist/workspace/conformance/index.d.ts.map +1 -1
  157. package/dist/workspace/decide.d.ts +229 -0
  158. package/dist/workspace/decide.d.ts.map +1 -0
  159. package/dist/workspace/decision-points.schema.json +137 -0
  160. package/dist/workspace/declaration.d.ts +103 -0
  161. package/dist/workspace/declaration.d.ts.map +1 -1
  162. package/dist/workspace/declaration.schema.json +285 -0
  163. package/dist/workspace/declared-kinds.d.ts +32 -0
  164. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  165. package/dist/workspace/generated-files.d.ts +7 -0
  166. package/dist/workspace/generated-files.d.ts.map +1 -1
  167. package/dist/workspace/intent.d.ts.map +1 -1
  168. package/dist/workspace/lineage-check.d.ts +3 -0
  169. package/dist/workspace/lineage-check.d.ts.map +1 -1
  170. package/dist/workspace/lineage-upgrade.d.ts +6 -1
  171. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  172. package/dist/workspace/ls.d.ts +39 -1
  173. package/dist/workspace/ls.d.ts.map +1 -1
  174. package/dist/workspace/point-answer.schema.json +95 -0
  175. package/dist/workspace/points-cli.d.ts +113 -0
  176. package/dist/workspace/points-cli.d.ts.map +1 -0
  177. package/dist/workspace/points.d.ts +320 -0
  178. package/dist/workspace/points.d.ts.map +1 -0
  179. package/dist/workspace/reason-codes.d.ts +38 -0
  180. package/dist/workspace/reason-codes.d.ts.map +1 -1
  181. package/dist/workspace/record-assets.d.ts.map +1 -1
  182. package/dist/workspace/records-cli.d.ts +23 -1
  183. package/dist/workspace/records-cli.d.ts.map +1 -1
  184. package/dist/workspace/records-close.d.ts +1 -1
  185. package/dist/workspace/records-close.d.ts.map +1 -1
  186. package/dist/workspace/records-write.d.ts +11 -5
  187. package/dist/workspace/records-write.d.ts.map +1 -1
  188. package/dist/workspace/records.d.ts +42 -6
  189. package/dist/workspace/records.d.ts.map +1 -1
  190. package/dist/workspace/status-gates.d.ts +7 -2
  191. package/dist/workspace/status-gates.d.ts.map +1 -1
  192. package/dist/workspace/status-stewards.d.ts +179 -0
  193. package/dist/workspace/status-stewards.d.ts.map +1 -0
  194. package/dist/workspace/status.d.ts +68 -1
  195. package/dist/workspace/status.d.ts.map +1 -1
  196. package/dist/workspace/work-cli.d.ts +189 -0
  197. package/dist/workspace/work-cli.d.ts.map +1 -0
  198. package/dist/workspace/work-evidence.d.ts +74 -0
  199. package/dist/workspace/work-evidence.d.ts.map +1 -0
  200. package/dist/workspace/work.d.ts +43 -1
  201. package/dist/workspace/work.d.ts.map +1 -1
  202. package/package.json +1 -1
  203. package/src/cli/commands/audit.test.ts +9 -1
  204. package/src/cli/commands/build.ts +3 -2
  205. package/src/cli/commands/lint.ts +7 -1
  206. package/src/cli/handlers/components.test.ts +93 -0
  207. package/src/cli/handlers/components.ts +44 -3
  208. package/src/cli/handlers/graph.test.ts +20 -0
  209. package/src/cli/handlers/graph.ts +16 -2
  210. package/src/cli/handlers/operator.ts +141 -4
  211. package/src/cli/handlers/run.test.ts +173 -2
  212. package/src/cli/handlers/run.ts +174 -3
  213. package/src/cli/main.test.ts +50 -0
  214. package/src/cli/main.ts +97 -2
  215. package/src/cli/mcp/workspace-tools.test.ts +48 -19
  216. package/src/cli/mcp/workspace-tools.ts +110 -8
  217. package/src/cli/plugins.ts +11 -3
  218. package/src/cli/registry.ts +42 -1
  219. package/src/codegen/pinned-upgrade.ts +2 -1
  220. package/src/codegen/release-wiring.test.ts +49 -2
  221. package/src/components/capability-plugin-loader.ts +3 -3
  222. package/src/components/cli-support.ts +2 -2
  223. package/src/components/verbs/vuln-scan.test.ts +124 -1
  224. package/src/components/verbs/vuln-scan.ts +142 -1
  225. package/src/config.ts +11 -0
  226. package/src/lexicon-module.ts +56 -1
  227. package/src/lexicon-package.test.ts +96 -0
  228. package/src/lifecycle/converge-ledger.ts +20 -0
  229. package/src/lifecycle/git.ts +65 -15
  230. package/src/lifecycle/lease.test.ts +22 -0
  231. package/src/lifecycle/lease.ts +133 -29
  232. package/src/lifecycle/member-ledger.ts +3 -2
  233. package/src/lifecycle/plan-ledger.test.ts +148 -0
  234. package/src/lifecycle/plan-ledger.ts +158 -0
  235. package/src/lifecycle/run-ledger.ts +6 -0
  236. package/src/lifecycle/symptoms.ts +95 -0
  237. package/src/lifecycle/work-lease.test.ts +236 -0
  238. package/src/lifecycle/work-lease.ts +426 -0
  239. package/src/lint/rules/index.ts +6 -2
  240. package/src/lint/rules/op/index.ts +17 -4
  241. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +23 -1
  242. package/src/lint/rules/op/ops014-converge-rule-refusals.ts +10 -6
  243. package/src/lint/rules/op/sys010-key-over-plain-http.test.ts +52 -0
  244. package/src/lint/rules/op/sys010-key-over-plain-http.ts +100 -0
  245. package/src/lint/rules/sys001-no-literal-key.test.ts +31 -0
  246. package/src/lint/rules/sys001-no-literal-key.ts +67 -0
  247. package/src/op/__fixtures__/decide-stub-backend.ts +143 -0
  248. package/src/op/activities/activity-contracts.ts +190 -1
  249. package/src/op/activities/apply.ts +7 -6
  250. package/src/op/activities/change-coverage.ts +60 -0
  251. package/src/op/activities/converge.test.ts +55 -1
  252. package/src/op/activities/converge.ts +208 -29
  253. package/src/op/activities/decide.live.test.ts +78 -0
  254. package/src/op/activities/decide.test.ts +300 -0
  255. package/src/op/activities/decide.ts +267 -0
  256. package/src/op/activities/index.ts +21 -0
  257. package/src/op/activities/shell.test.ts +91 -1
  258. package/src/op/activities/shell.ts +91 -2
  259. package/src/op/activities/source-release.test.ts +118 -0
  260. package/src/op/activities/source-release.ts +168 -0
  261. package/src/op/activities/source-rollback.test.ts +131 -0
  262. package/src/op/activities/source-rollback.ts +239 -0
  263. package/src/op/activities/work-evidence.ts +62 -0
  264. package/src/op/activity-contract-registry.test.ts +2 -0
  265. package/src/op/activity-contract-registry.ts +2 -2
  266. package/src/op/activity-contract.test.ts +12 -0
  267. package/src/op/activity-contract.ts +6 -1
  268. package/src/op/activity-profiles.ts +29 -0
  269. package/src/op/activity-registry.test.ts +1 -1
  270. package/src/op/activity-registry.ts +2 -2
  271. package/src/op/builders.ts +124 -5
  272. package/src/op/command-gate.test.ts +192 -0
  273. package/src/op/composites/converge-op.test.ts +27 -1
  274. package/src/op/composites/converge-op.ts +69 -15
  275. package/src/op/decide-backend.test.ts +44 -0
  276. package/src/op/decide-backend.ts +212 -0
  277. package/src/op/decide-config.test.ts +52 -0
  278. package/src/op/decide-config.ts +80 -0
  279. package/src/op/decide-read-inputs.ts +117 -0
  280. package/src/op/discover.ts +71 -0
  281. package/src/op/gate-approval.ts +2 -2
  282. package/src/op/gate-wait.ts +57 -0
  283. package/src/op/gate.ts +8 -3
  284. package/src/op/generate-pipeline.ts +2 -2
  285. package/src/op/index.ts +34 -6
  286. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  287. package/src/op/lifecycle-receipt-store.ts +61 -0
  288. package/src/op/local-executor.ts +367 -33
  289. package/src/op/local-output.test.ts +33 -0
  290. package/src/op/local-output.ts +33 -2
  291. package/src/op/op-ir.test.ts +10 -0
  292. package/src/op/op-ir.ts +18 -0
  293. package/src/op/op-verb-class.ts +1 -0
  294. package/src/op/operator.ts +360 -10
  295. package/src/op/runtime.ts +37 -8
  296. package/src/op/runtimes/local.ts +6 -1
  297. package/src/op/source-archive.ts +161 -0
  298. package/src/op/step-output-ref.ts +6 -2
  299. package/src/op/steward-points.test.ts +313 -0
  300. package/src/op/steward-points.ts +194 -0
  301. package/src/op/steward-turn.ts +79 -0
  302. package/src/op/steward.test.ts +296 -0
  303. package/src/op/steward.ts +277 -0
  304. package/src/op/types.ts +60 -0
  305. package/src/op/work-lease-decl.ts +80 -0
  306. package/src/op/work-lease-run.test.ts +345 -0
  307. package/src/op/work-lease-run.ts +395 -0
  308. package/src/workspace/__fixtures__/decide-workspace.ts +129 -0
  309. package/src/workspace/__fixtures__/studio-template-84c8b21.json +67 -0
  310. package/src/workspace/answers-ledger.ts +118 -0
  311. package/src/workspace/box-isolation.test.ts +261 -0
  312. package/src/workspace/box-isolation.ts +205 -0
  313. package/src/workspace/changes-cli.ts +56 -0
  314. package/src/workspace/changes.schema.json +472 -0
  315. package/src/workspace/changes.test.ts +315 -0
  316. package/src/workspace/changes.ts +512 -0
  317. package/src/workspace/chant-migrations/chud-lexicon-exit-fly-site.ts +234 -0
  318. package/src/workspace/chant-migrations/chud-lexicon-exit-live-names.ts +164 -0
  319. package/src/workspace/chant-migrations/chud-lexicon-exit-rollback.ts +223 -0
  320. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.ts +195 -0
  321. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-policy.ts +108 -0
  322. package/src/workspace/chant-migrations/chud-lexicon-exit.ts +882 -0
  323. package/src/workspace/chant-migrations.e2e.test.ts +1089 -0
  324. package/src/workspace/chant-migrations.ts +164 -0
  325. package/src/workspace/check-contract.test.ts +5 -1
  326. package/src/workspace/check.schema.json +25 -7
  327. package/src/workspace/checks/box-isolation.ts +68 -0
  328. package/src/workspace/checks/boxes.test.ts +276 -0
  329. package/src/workspace/checks/boxes.ts +442 -0
  330. package/src/workspace/checks/diagrams.test.ts +134 -0
  331. package/src/workspace/checks/diagrams.ts +108 -0
  332. package/src/workspace/checks/records.ts +72 -0
  333. package/src/workspace/checks.test.ts +7 -0
  334. package/src/workspace/checks.ts +27 -3
  335. package/src/workspace/compose-graph.ts +2 -0
  336. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +10 -0
  337. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +26 -0
  338. package/src/workspace/conformance/index.ts +5 -0
  339. package/src/workspace/decide.test.ts +224 -0
  340. package/src/workspace/decide.ts +709 -0
  341. package/src/workspace/decision-points.schema.json +137 -0
  342. package/src/workspace/declaration.schema.json +285 -0
  343. package/src/workspace/declaration.test.ts +58 -0
  344. package/src/workspace/declaration.ts +241 -0
  345. package/src/workspace/declared-kinds.test.ts +5 -5
  346. package/src/workspace/declared-kinds.ts +71 -3
  347. package/src/workspace/generated-files.ts +14 -0
  348. package/src/workspace/graph-cli.ts +1 -1
  349. package/src/workspace/graph.schema.json +9 -1
  350. package/src/workspace/intent.schema.json +12 -3
  351. package/src/workspace/intent.ts +3 -10
  352. package/src/workspace/lineage-check.ts +16 -1
  353. package/src/workspace/lineage-upgrade-cli.ts +1 -1
  354. package/src/workspace/lineage-upgrade.test.ts +70 -0
  355. package/src/workspace/lineage-upgrade.ts +81 -10
  356. package/src/workspace/ls-contract.test.ts +57 -0
  357. package/src/workspace/ls.schema.json +46 -1
  358. package/src/workspace/ls.ts +62 -3
  359. package/src/workspace/point-answer.schema.json +95 -0
  360. package/src/workspace/points-cli.ts +284 -0
  361. package/src/workspace/points-write.schema.json +499 -0
  362. package/src/workspace/points.schema.json +743 -0
  363. package/src/workspace/points.test.ts +264 -0
  364. package/src/workspace/points.ts +564 -0
  365. package/src/workspace/read-contract.test.ts +18 -1
  366. package/src/workspace/reason-codes.test.ts +24 -1
  367. package/src/workspace/reason-codes.ts +49 -0
  368. package/src/workspace/record-assets.test.ts +3 -2
  369. package/src/workspace/record-assets.ts +4 -1
  370. package/src/workspace/records-amend.schema.json +2 -0
  371. package/src/workspace/records-cli.ts +28 -5
  372. package/src/workspace/records-close.schema.json +2 -0
  373. package/src/workspace/records-contract.test.ts +3 -2
  374. package/src/workspace/records-new.schema.json +2 -0
  375. package/src/workspace/records-review.schema.json +2 -0
  376. package/src/workspace/records-sessions-write.e2e.test.ts +77 -0
  377. package/src/workspace/records-sessions-write.test.ts +2 -34
  378. package/src/workspace/records-write.e2e.test.ts +108 -0
  379. package/src/workspace/records-write.test.ts +32 -41
  380. package/src/workspace/records-write.ts +33 -15
  381. package/src/workspace/records.schema.json +53 -2
  382. package/src/workspace/records.test.ts +48 -3
  383. package/src/workspace/records.ts +142 -6
  384. package/src/workspace/status-contract.test.ts +189 -6
  385. package/src/workspace/status-gates.ts +9 -4
  386. package/src/workspace/status-stewards.ts +329 -0
  387. package/src/workspace/status.schema.json +335 -5
  388. package/src/workspace/status.ts +142 -6
  389. package/src/workspace/work-acceptance.test.ts +274 -0
  390. package/src/workspace/work-cli.e2e.test.ts +180 -0
  391. package/src/workspace/work-cli.ts +429 -0
  392. package/src/workspace/work-evidence.schema.json +212 -0
  393. package/src/workspace/work-evidence.ts +169 -0
  394. package/src/workspace/work-history.schema.json +349 -0
  395. package/src/workspace/work-history.test.ts +156 -0
  396. package/src/workspace/work-lease.schema.json +233 -0
  397. package/src/workspace/work-readiness-chud.test.ts +145 -0
  398. package/src/workspace/work.test.ts +3 -2
  399. package/src/workspace/work.ts +100 -1
  400. /package/src/cli/{discovery-skip.test.ts → discovery-skip.e2e.test.ts} +0 -0
  401. /package/src/workspace/conformance/{conformance.test.ts → conformance.e2e.test.ts} +0 -0
  402. /package/src/workspace/{which-chant.test.ts → which-chant.e2e.test.ts} +0 -0
@@ -0,0 +1,1089 @@
1
+ /**
2
+ * chant #2737 — `chud-lexicon-exit`, the migration `chant workspace upgrade`
3
+ * ships for repos made from chud's template, run over the studio kit's copy
4
+ * of it (arugula-salad/studio 84c8b21 `template/`, the fixture). It covers the
5
+ * two sources such repos are made from: a directory, as the kit's
6
+ * provisioning does, and a git repository at a ref, as `chant init --from
7
+ * jhgaylor/chud@<ref>#template` did.
8
+ *
9
+ * The last test builds, lints and runs the migrated delivery project with
10
+ * this checkout's chant: to the ship gate, then, once the plan is approved,
11
+ * through Ship to the fly lexicon's in-memory Machines API and Record (#2782);
12
+ * then a second release, and the rollback Op back to the first (#2800).
13
+ * That test runs the Machines API's running mode (#2831), so each release is
14
+ * also checked over HTTP: the shipped tree serves, the migration's table is
15
+ * there, and the rollback serves the first release's page again.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
19
+ import yaml from "js-yaml";
20
+ import { execFileSync, spawn } from "node:child_process";
21
+ import { createServer } from "node:http";
22
+ import type { AddressInfo } from "node:net";
23
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath, pathToFileURL } from "node:url";
27
+ import type { GateLedgerPort } from "../op/gate";
28
+ import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
29
+ import { WORKSPACE_UPGRADE_GATE_OP } from "../op/gate-name";
30
+ import { CHANT_MIGRATIONS } from "./chant-migrations";
31
+ import { CHUD_LEXICON_EXIT, chudLexiconExit, convertPoints } from "./chant-migrations/chud-lexicon-exit";
32
+ import { CHUD_LEXICON_EXIT_SHIP_INPUTS } from "./chant-migrations/chud-lexicon-exit-ship-inputs";
33
+ import { CHUD_LEXICON_EXIT_ROLLBACK } from "./chant-migrations/chud-lexicon-exit-rollback";
34
+ import { CHUD_LEXICON_EXIT_SHIP_POLICY } from "./chant-migrations/chud-lexicon-exit-ship-policy";
35
+ import { CHUD_LEXICON_EXIT_FLY_SITE, chudLexiconExitFlySite } from "./chant-migrations/chud-lexicon-exit-fly-site";
36
+ import { CHUD_LEXICON_EXIT_LIVE_NAMES } from "./chant-migrations/chud-lexicon-exit-live-names";
37
+ import { readerVersion } from "./declaration";
38
+ import { runChecks } from "./lineage-check";
39
+ import { initFromCommand } from "./lineage-init";
40
+ import { readLock } from "./lineage-lock";
41
+ import { stageUpgrade, type ChantRunner } from "./lineage-upgrade";
42
+ import { upgradeCommand } from "./lineage-upgrade-cli";
43
+ import { parsePoints } from "./points";
44
+ import { readReleaseLedger } from "../lifecycle/release-ledger";
45
+ import { readReleasePlan } from "../lifecycle/plan-ledger";
46
+
47
+ const ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
48
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
49
+ const FIXTURE = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "__fixtures__/studio-template-84c8b21.json"), "utf-8")) as {
50
+ files: Record<string, string>;
51
+ };
52
+ const CHUD_IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']@intentius\/(?:chant-lexicon-chud|chud-runtime)/;
53
+
54
+ let root: string;
55
+ let tpl: string;
56
+ let proj: string;
57
+ let source: "dir" | "git";
58
+
59
+ function git(cwd: string, args: string[]): string {
60
+ return execFileSync("git", args, { cwd, encoding: "utf-8", env: ENV, stdio: ["ignore", "pipe", "pipe"] }).trim();
61
+ }
62
+ function put(base: string, rel: string, content: string): void {
63
+ mkdirSync(dirname(join(base, rel)), { recursive: true });
64
+ writeFileSync(join(base, rel), content);
65
+ }
66
+ function read(base: string, rel: string): string {
67
+ return readFileSync(join(base, rel), "utf-8");
68
+ }
69
+ function commit(message: string): void {
70
+ git(proj, ["add", "-A"]);
71
+ git(proj, ["commit", "-q", "-m", message]);
72
+ }
73
+ /** Every source file under `dir`, skipping node_modules and .git. */
74
+ function sources(dir: string, rel = ""): string[] {
75
+ const out: string[] = [];
76
+ for (const name of readdirSync(join(dir, rel))) {
77
+ if (name === "node_modules" || name === ".git") continue;
78
+ const path = rel ? `${rel}/${name}` : name;
79
+ if (statSync(join(dir, path)).isDirectory()) out.push(...sources(dir, path));
80
+ else if (/\.(?:[cm]?[jt]s)$/.test(name)) out.push(path);
81
+ }
82
+ return out;
83
+ }
84
+ /** The `--to` an upgrade to the same template version takes. */
85
+ const target = (): string => (source === "dir" ? `${tpl}#template` : "main");
86
+
87
+ function ledger(): GateLedgerPort & { approve(digest: string): void } {
88
+ const pending: PendingGateRecord[] = [];
89
+ const resolutions: GateResolutionRecord[] = [];
90
+ return {
91
+ approve(digest) {
92
+ resolutions.push({ version: 1, op: WORKSPACE_UPGRADE_GATE_OP, gate: ".", resolvedBy: "alice", timestamp: "2026-09-25T00:00:00Z", planDigest: digest });
93
+ },
94
+ async read() {
95
+ return { resolutions: [...resolutions], pending: [...pending] };
96
+ },
97
+ async appendPending(input) {
98
+ const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
99
+ pending.push(record);
100
+ return { record, pushed: true };
101
+ },
102
+ };
103
+ }
104
+
105
+ const passing: ChantRunner = async () => ({ exitCode: 0, output: "" });
106
+
107
+ /** Stage, approve and apply the upgrade, and commit it. */
108
+ async function upgrade(): Promise<void> {
109
+ const l = ledger();
110
+ const first = await upgradeCommand({ root: proj, to: target(), runChant: passing, ledger: l, now: "2026-09-25T00:00:00Z" });
111
+ expect(first.outcome).toBe("gated");
112
+ l.approve(first.staged!.digest);
113
+ const second = await upgradeCommand({ root: proj, to: target(), runChant: passing, ledger: l, now: "2026-09-25T00:00:00Z" });
114
+ expect(second.outcome).toBe("applied");
115
+ commit("chud-lexicon-exit");
116
+ }
117
+
118
+ async function makeProject(from: "dir" | "git"): Promise<void> {
119
+ source = from;
120
+ root = realpathSync(mkdtempSync(join(tmpdir(), "chant-chud-exit-")));
121
+ tpl = join(root, "studio");
122
+ proj = join(root, "app-repo");
123
+ for (const [path, content] of Object.entries(FIXTURE.files)) put(tpl, `template/${path}`, content);
124
+ git(tpl, ["init", "-q", "-b", "main"]);
125
+ git(tpl, ["add", "-A"]);
126
+ git(tpl, ["commit", "-q", "-m", "template"]);
127
+ const made = await initFromCommand({ from: from === "dir" ? `${tpl}#template` : `${tpl}@main#template`, path: proj, params: { name: "Notes", issue: "acme/notes#1" } });
128
+ expect(made.error).toBeUndefined();
129
+ git(proj, ["init", "-q", "-b", "main"]);
130
+ commit("init from the studio kit's template");
131
+ }
132
+
133
+ /** `paths` as 0.92.0's `chud-lexicon-exit` writes them over the project as it is now. */
134
+ function exitWrote(paths: string[]): Record<string, string> {
135
+ const plan = chudLexiconExit.plan({ dir: proj, lineage: readLock(proj)?.scopes["."] ?? ({ migrations: [], files: {} } as never), chantVersion: readerVersion() })!;
136
+ return Object.fromEntries(paths.map((p) => [p, plan.changes.find((c) => c.path === p)!.data!.toString("utf-8")]));
137
+ }
138
+ /** Put files back as a release left them, with the lock naming only `migrations`, and commit. */
139
+ function asReleased(files: Record<string, string>, migrations: string[]): void {
140
+ for (const [p, text] of Object.entries(files)) put(proj, p, text);
141
+ const lock = JSON.parse(read(proj, ".chant/workspace.lock.json")) as { scopes: Record<string, { migrations: string[] }> };
142
+ lock.scopes["."].migrations = migrations;
143
+ put(proj, ".chant/workspace.lock.json", JSON.stringify(lock, null, 2) + "\n");
144
+ commit("as 0.92.0 migrated it");
145
+ }
146
+
147
+ afterEach(() => {
148
+ vi.restoreAllMocks();
149
+ rmSync(root, { recursive: true, force: true });
150
+ });
151
+
152
+ describe.each(["dir", "git"] as const)("chud-lexicon-exit, from a %s source", (from) => {
153
+ beforeEach(async () => {
154
+ await makeProject(from);
155
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
156
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
157
+ });
158
+
159
+ test("the dry run shows the plan and leaves the tree alone", async () => {
160
+ const lines: string[] = [];
161
+ vi.spyOn(console, "log").mockImplementation((s: string) => void lines.push(s));
162
+ const result = await upgradeCommand({ root: proj, to: target(), dryRun: true, runChant: passing });
163
+ expect(result.outcome).toBe("dry-run");
164
+ const out = lines.join("\n");
165
+ expect(out).toContain(`chant migration: ${CHUD_LEXICON_EXIT} (applied)`);
166
+ expect(out).toContain("delete: delivery/ops/chant-lexicon-chud/index.ts");
167
+ expect(out).toContain("write: delivery/ops/release.op.ts");
168
+ expect(out).toContain("write: decisions/points.json");
169
+ expect(out).toMatch(/not moved: the dispatch Op .* -> the studio kit \(arugula-salad\/studio, template\/\)/);
170
+ expect(out).toMatch(/not moved: signing the release archive.* -> INTENTIUS\/chant#2515/);
171
+ expect(out).toMatch(/not moved: chud's rollback Op \(ops\/rollback\.op\.ts\) -> chant's rollback Op, which the migration chud-lexicon-exit-rollback writes/);
172
+ // The rollback Op is planned after the exit, from the tree the exit left (#2800).
173
+ expect(out).toContain(`chant migration: ${CHUD_LEXICON_EXIT_ROLLBACK} (applied)`);
174
+ expect(out).toContain("write: delivery/ops/rollback.op.ts");
175
+ expect(out).not.toContain("not moved: the release's steps after the ship gate");
176
+ // #2805: the plan lists README.md, CLAUDE.md and design/CLAUDE.md as
177
+ // changed, not silently left describing what the migration just deleted.
178
+ expect(out).toContain("write: README.md");
179
+ expect(out).toContain("write: CLAUDE.md");
180
+ expect(out).toContain("write: design/CLAUDE.md");
181
+ expect(read(proj, "delivery/package.json")).toContain("@intentius/chud-runtime");
182
+ expect(git(proj, ["status", "--porcelain"])).toBe("");
183
+ });
184
+
185
+ test("takes the repo off the chud packages, and the lock records it", async () => {
186
+ await upgrade();
187
+
188
+ const imports = sources(proj).filter((f) => CHUD_IMPORT.test(read(proj, f)));
189
+ expect(imports).toEqual([]);
190
+ for (const gone of ["delivery/ops/chant-lexicon-chud", "delivery/ops/dispatch.op.ts", "delivery/ops/upgrade.op.ts", "delivery/deploy/site.ts", "delivery/deploy/fly-machine.ts", "delivery/.chant", "delivery/.npmrc", "delivery/decisions/points.yaml"]) {
191
+ expect(existsSync(join(proj, gone)), gone).toBe(false);
192
+ }
193
+
194
+ const pkg = JSON.parse(read(proj, "delivery/package.json")) as { dependencies: Record<string, string>; scripts: Record<string, string> };
195
+ expect(Object.keys(pkg.dependencies)).not.toContain("@intentius/chud-runtime");
196
+ expect(Object.keys(pkg.dependencies)).not.toContain("@intentius/chant-lexicon-chud");
197
+ // delivery/ takes the chant doing the upgrade, and nothing holds it below that.
198
+ expect(pkg.dependencies["@intentius/chant"]).toBe(`^${readerVersion()}`);
199
+ expect(pkg.dependencies["@intentius/chant-lexicon-fly"]).toBe(`^${readerVersion()}`);
200
+ // decide is chant's own (#2828): no package joins for it.
201
+ expect(Object.keys(pkg.dependencies).filter((d) => /lexicon-systemone/.test(d))).toEqual([]);
202
+ expect(pkg.scripts.release).toBe("chant run release");
203
+ expect(pkg.scripts["build:fly"]).toBe("chant build deploy --lexicon fly -o dist/fly.json");
204
+ const release = read(proj, "delivery/ops/release.op.ts");
205
+ for (const step of ["sourceArchive(", "releasePlan(", "plan: plan.out.digest", "flyRelease(", "releaseRecord("]) expect(release).toContain(step);
206
+ expect(release).not.toContain("#2800");
207
+ expect(release).toContain("ops/rollback.op.ts rolls the site back to the\n * previous release.");
208
+ expect(pkg.scripts.rollback).toBe("chant run rollback");
209
+ const rollback = read(proj, "delivery/ops/rollback.op.ts");
210
+ for (const step of ["releaseRollbackPlan(", 'gate("rollback"', "plan: plan.out.digest", "flyRollback(", "to: plan.out.to", "releaseRollbackRecord("]) expect(rollback).toContain(step);
211
+ expect(CHUD_IMPORT.test(rollback)).toBe(false);
212
+ expect(read(proj, "README.md")).toContain("npm run rollback # chant run rollback");
213
+ expect(read(proj, "README.md")).toContain("and the rollback Op,");
214
+ expect(read(proj, "CLAUDE.md")).toContain("`ops/` (the release and rollback Ops),");
215
+ expect(pkg.scripts.check).toBe("npm --prefix ../app test --silent");
216
+ expect(pkg.scripts).not.toHaveProperty("dispatch");
217
+ expect(Object.values(pkg.scripts).join("\n")).not.toMatch(/--on chud|\bchud (dev|design)\b/);
218
+
219
+ // The Fly site is the fly lexicon's FlySite composite, and the app component names it (#2809).
220
+ const fly = read(proj, "delivery/deploy/fly.ts");
221
+ expect(fly).toContain('import { Fly, FlySite } from "@intentius/chant-lexicon-fly";');
222
+ expect(fly).toContain('import { appSlug } from "../app-name.ts";');
223
+ expect(fly).toContain("export const flySite = FlySite({\n app: appSlug,\n org: Fly.OrgSlug,\n region: \"iad\",\n machine: \"web\",\n image: \"node:22-slim\",\n port: 8080,");
224
+ expect(fly).toContain('volume: { name: "data", sizeGb: 1, path: "/data" },');
225
+ expect(fly).toContain("secrets: { APP_SECRET: process.env.CHUD_FLY_APP_SECRET || undefined },");
226
+ expect(read(proj, "delivery/deploy/app.component.ts")).toContain('composites: ["FlySite"],');
227
+
228
+ const config = read(proj, "delivery/chant.config.ts");
229
+ expect(config).toContain('lexicons: ["fountain", "fly", "cedar", "github"]');
230
+ expect(release).toContain('import { Op, phase, gate, shell, build, sourceArchive, releasePlan, releaseRecord, decide } from "@intentius/chant/op";');
231
+ expect(sources(proj).filter((f) => read(proj, f).includes("chant-lexicon-systemone"))).toEqual([]);
232
+ expect(config).not.toMatch(/CHUD00[12]|write-scope\.ts|const sizing/);
233
+ // The template's parameters stay where they were.
234
+ expect(config).toContain('issue: "acme/notes#1"');
235
+
236
+ const points = parsePoints(read(proj, "decisions/points.json"), "decisions/points.json");
237
+ expect(Object.keys(points["ship-skip"].inputs)).toContain("release.work_changed");
238
+ expect(points["ship-skip"].question.type).toBe("noul");
239
+ expect(Object.keys(points["slice-tier"].inputs)).toContain("work-item.fits_small");
240
+ // #2805: the slice-tier point no longer sends its decider to the `lint`
241
+ // block this migration just removed from chant.config.ts.
242
+ expect(points["slice-tier"].question.instructions).not.toMatch(/lint\.rules|chant\.config\.ts/);
243
+ expect(points["slice-tier"].question.instructions).toContain("studio kit");
244
+ const decl = JSON.parse(read(proj, "chant.workspace.json")) as { records: Array<{ kind: string }> };
245
+ expect(decl.records.map((r) => r.kind)).toContain("answers/answer.kind.mjs");
246
+
247
+ // #2805: README.md, CLAUDE.md and design/CLAUDE.md no longer describe
248
+ // chud's runtime, `--on chud`, or the Ops and paths this migration deleted
249
+ // as though they were still live.
250
+ for (const doc of ["README.md", "CLAUDE.md", "design/CLAUDE.md"]) {
251
+ const text = read(proj, doc);
252
+ expect(text, doc).not.toMatch(/--on chud|is the machinery|`chud design`|rollback, upgrade and dispatch Ops|chud's runtime package/);
253
+ }
254
+ expect(read(proj, "README.md")).toContain("off chud");
255
+ expect(read(proj, "CLAUDE.md")).toContain("studio kit's now");
256
+ // CLAUDE.md drops the dead path outright; design/CLAUDE.md keeps it only
257
+ // to say the package (and the path) are gone.
258
+ expect(read(proj, "CLAUDE.md")).not.toContain("chud-runtime/schemas");
259
+ expect(read(proj, "design/CLAUDE.md")).toContain("hud's chant views now");
260
+ expect(read(proj, "design/CLAUDE.md")).toMatch(/chud-runtime\/design\/`\. That package is\ngone/);
261
+
262
+ const lineage = readLock(proj)!.scopes["."];
263
+ // A repo migrated from chud gets every follow-on in the same upgrade.
264
+ expect(lineage.migrations).toEqual(CHANT_MIGRATIONS.map((m) => m.id));
265
+ // The ship gate's policy lets a person pass (#2810).
266
+ expect(read(proj, "delivery/decisions/ship-skip.cedar.ts")).toContain("policies: [personApproves, agentSkipsOnTableYes]");
267
+ expect(lineage.manualSteps).toEqual([]);
268
+
269
+ // chant workspace check is clean: the lock, the declaration and the points file.
270
+ const check = await runChecks(proj);
271
+ expect("error" in check).toBe(false);
272
+ if (!("error" in check)) {
273
+ expect(check.findings).toEqual([]);
274
+ expect(check.declaration?.diagnostics.filter((d) => d.severity === "error")).toEqual([]);
275
+ expect(check.ok).toBe(true);
276
+ }
277
+ });
278
+
279
+ test("is idempotent: a second upgrade plans nothing and changes nothing", async () => {
280
+ await upgrade();
281
+ const again = await stageUpgrade({ root: proj, to: target(), runChant: passing });
282
+ try {
283
+ expect(again.chantMigrations).toEqual([]);
284
+ expect(again.changed).toBe(false);
285
+ } finally {
286
+ again.dispose();
287
+ }
288
+ // Without the lock's record, the plan finds nothing left to move either.
289
+ const lock = JSON.parse(read(proj, ".chant/workspace.lock.json")) as { scopes: Record<string, { migrations: string[] }> };
290
+ lock.scopes["."].migrations = [];
291
+ put(proj, ".chant/workspace.lock.json", JSON.stringify(lock, null, 2) + "\n");
292
+ commit("forget the migration");
293
+ const fresh = await stageUpgrade({ root: proj, to: target(), runChant: passing });
294
+ try {
295
+ expect(fresh.chantMigrations).toEqual([]);
296
+ } finally {
297
+ fresh.dispose();
298
+ }
299
+ });
300
+ });
301
+
302
+ describe("chud-lexicon-exit-rollback, over a repo chud-lexicon-exit migrated before it existed", () => {
303
+ beforeEach(async () => {
304
+ await makeProject("dir");
305
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
306
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
307
+ // What 0.92.0's upgrade left: the exit applied, no rollback Op, and the lock naming the exit alone.
308
+ await upgrade();
309
+ const released = read(proj, "delivery/ops/release.op.ts").replace(
310
+ "). ops/rollback.op.ts rolls the site back to the\n * previous release. ",
311
+ "), and rolling the site back to the previous\n * release (INTENTIUS/chant#2800). ",
312
+ );
313
+ put(proj, "delivery/ops/release.op.ts", released);
314
+ rmSync(join(proj, "delivery/ops/rollback.op.ts"));
315
+ put(proj, "README.md", read(proj, "README.md").replace(" and the rollback Op,", ",").replace(/npm run rollback {5}# chant run rollback[^\n]*\n/, ""));
316
+ put(proj, "CLAUDE.md", read(proj, "CLAUDE.md").replace("`ops/` (the release and rollback Ops),", "`ops/` (the release Op),"));
317
+ const pkg = JSON.parse(read(proj, "delivery/package.json")) as { scripts: Record<string, string> };
318
+ delete pkg.scripts.rollback;
319
+ put(proj, "delivery/package.json", JSON.stringify(pkg, null, 2) + "\n");
320
+ const lock = JSON.parse(read(proj, ".chant/workspace.lock.json")) as { scopes: Record<string, { migrations: string[] }> };
321
+ lock.scopes["."].migrations = [CHUD_LEXICON_EXIT];
322
+ put(proj, ".chant/workspace.lock.json", JSON.stringify(lock, null, 2) + "\n");
323
+ commit("as 0.92.0 migrated it");
324
+ });
325
+
326
+ test("the dry run lists it; the upgrade writes the rollback Op and the lock records it; a second plans nothing", async () => {
327
+ const lines: string[] = [];
328
+ vi.spyOn(console, "log").mockImplementation((s: string) => void lines.push(s));
329
+ const dry = await upgradeCommand({ root: proj, to: target(), dryRun: true, runChant: passing });
330
+ expect(dry.outcome).toBe("dry-run");
331
+ const out = lines.join("\n");
332
+ expect(out).toContain(`chant migration: ${CHUD_LEXICON_EXIT_ROLLBACK} (applied)`);
333
+ expect(out).not.toContain(`chant migration: ${CHUD_LEXICON_EXIT} `);
334
+ expect(out).toContain("write: delivery/ops/rollback.op.ts");
335
+ expect(out).toContain("write: delivery/ops/release.op.ts");
336
+ expect(out).toContain("write: delivery/package.json");
337
+ expect(out).toContain("write: README.md");
338
+ expect(out).toContain("write: CLAUDE.md");
339
+ expect(existsSync(join(proj, "delivery/ops/rollback.op.ts"))).toBe(false);
340
+
341
+ await upgrade();
342
+ expect(read(proj, "delivery/ops/rollback.op.ts")).toContain("flyRollback(");
343
+ expect(read(proj, "delivery/ops/release.op.ts")).not.toContain("#2800");
344
+ expect((JSON.parse(read(proj, "delivery/package.json")) as { scripts: Record<string, string> }).scripts.rollback).toBe("chant run rollback");
345
+ expect(readLock(proj)!.scopes["."].migrations).toEqual([CHUD_LEXICON_EXIT, CHUD_LEXICON_EXIT_ROLLBACK]);
346
+
347
+ const again = await stageUpgrade({ root: proj, to: target(), runChant: passing });
348
+ try {
349
+ expect(again.chantMigrations).toEqual([]);
350
+ expect(again.changed).toBe(false);
351
+ } finally {
352
+ again.dispose();
353
+ }
354
+ });
355
+
356
+ test("a rollback Op of the project's own is a conflict, and nothing is applied", async () => {
357
+ put(proj, "delivery/ops/rollback.op.ts", "export default {};\n");
358
+ commit("our own rollback");
359
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
360
+ try {
361
+ const [m] = staged.chantMigrations;
362
+ expect(m).toMatchObject({ id: CHUD_LEXICON_EXIT_ROLLBACK, applied: false, conflicts: [{ path: "delivery/ops/rollback.op.ts" }] });
363
+ } finally {
364
+ staged.dispose();
365
+ }
366
+ });
367
+ });
368
+
369
+ describe("chud-lexicon-exit-ship-inputs, over a repo chud-lexicon-exit migrated before it existed (#2811)", () => {
370
+ const RELEASE = "delivery/ops/release.op.ts";
371
+ beforeEach(async () => {
372
+ await makeProject("dir");
373
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
374
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
375
+ const released = exitWrote([RELEASE]);
376
+ await upgrade();
377
+ const lock = readLock(proj)!.scopes["."].migrations.filter((id) => id !== CHUD_LEXICON_EXIT_SHIP_INPUTS);
378
+ // As 0.92.0 wrote it: decide from the systemone lexicon (#2829 moved it into core since).
379
+ const at092 = released[RELEASE]
380
+ .replace("releasePlan, releaseRecord, decide } from \"@intentius/chant/op\";", "releasePlan, releaseRecord } from \"@intentius/chant/op\";")
381
+ .replace('import { flyRelease } from "@intentius/chant-lexicon-fly";', 'import { decide } from "@intentius/chant-lexicon-systemone";\nimport { flyRelease } from "@intentius/chant-lexicon-fly";')
382
+ .replace("through chant's `decide` activity, and", "through the systemone lexicon's `decide` activity, and");
383
+ expect(at092).toContain('import { decide } from "@intentius/chant-lexicon-systemone";');
384
+ asReleased({ [RELEASE]: at092 }, lock);
385
+ });
386
+
387
+ test("the dry run lists it; the upgrade passes the inputs and the lock records it; a second plans nothing", async () => {
388
+ expect(read(proj, RELEASE)).toContain('const shipSkip = decide("ship-skip", { id: "shipSkip" });');
389
+ const lines: string[] = [];
390
+ vi.spyOn(console, "log").mockImplementation((s: string) => void lines.push(s));
391
+ const dry = await upgradeCommand({ root: proj, to: target(), dryRun: true, runChant: passing });
392
+ expect(dry.outcome).toBe("dry-run");
393
+ expect(lines.join("\n")).toContain(`chant migration: ${CHUD_LEXICON_EXIT_SHIP_INPUTS} (applied)`);
394
+ expect(lines.join("\n")).toContain(`write: ${RELEASE}`);
395
+
396
+ await upgrade();
397
+ const release = read(proj, RELEASE);
398
+ expect(release).toContain('const shipSkip = decide("ship-skip", { id: "shipSkip", inputs });');
399
+ expect(release).toContain('import { readReleaseLedger } from "@intentius/chant/lifecycle/release-ledger";');
400
+ expect(release).toContain('const WORK: Array<{ dir: string; match: string }> = [{"dir":"work","match":"^W-[0-9]{3,}-.+\\\\.md$"}];');
401
+ expect(release).toContain('const APP = "app";');
402
+ expect(release).toContain("with\n * the inputs it declares");
403
+ expect(readLock(proj)!.scopes["."].migrations).toContain(CHUD_LEXICON_EXIT_SHIP_INPUTS);
404
+
405
+ const again = await stageUpgrade({ root: proj, to: target(), runChant: passing });
406
+ try {
407
+ expect(again.chantMigrations).toEqual([]);
408
+ expect(again.changed).toBe(false);
409
+ } finally {
410
+ again.dispose();
411
+ }
412
+ });
413
+
414
+ test("a release Op the project changed so the decide step is gone is left alone", async () => {
415
+ put(proj, RELEASE, read(proj, RELEASE).replace('const shipSkip = decide("ship-skip", { id: "shipSkip" });', 'const shipSkip = decide("ship-skip", { id: "shipSkip", inputs: { "release.units": 0 } });'));
416
+ commit("our own inputs");
417
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
418
+ try {
419
+ expect(staged.chantMigrations.find((m) => m.id === CHUD_LEXICON_EXIT_SHIP_INPUTS)).toBeUndefined();
420
+ } finally {
421
+ staged.dispose();
422
+ }
423
+ });
424
+ });
425
+
426
+ describe("chud-lexicon-exit-ship-policy, over a repo chud-lexicon-exit migrated before it existed (#2810)", () => {
427
+ const POLICY = "delivery/decisions/ship-skip.cedar.ts";
428
+ beforeEach(async () => {
429
+ await makeProject("dir");
430
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
431
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
432
+ const released = exitWrote([POLICY]);
433
+ await upgrade();
434
+ asReleased(released, readLock(proj)!.scopes["."].migrations.filter((id) => id !== CHUD_LEXICON_EXIT_SHIP_POLICY));
435
+ });
436
+
437
+ test("the dry run lists it; the upgrade adds the person's permit and the lock records it; a second plans nothing", async () => {
438
+ expect(read(proj, POLICY)).not.toContain("personApproves");
439
+ const lines: string[] = [];
440
+ vi.spyOn(console, "log").mockImplementation((s: string) => void lines.push(s));
441
+ const dry = await upgradeCommand({ root: proj, to: target(), dryRun: true, runChant: passing });
442
+ expect(dry.outcome).toBe("dry-run");
443
+ const out = lines.join("\n");
444
+ expect(out).toContain(`chant migration: ${CHUD_LEXICON_EXIT_SHIP_POLICY} (applied)`);
445
+ expect(out).toContain(`write: ${POLICY}`);
446
+
447
+ await upgrade();
448
+ const policy = read(proj, POLICY);
449
+ expect(policy).toContain('import { DenyByDefaultSet, Policy, gatePolicy, GATE_AGENT_TYPE, GATE_HUMAN_TYPE, PASS_GATE_ACTION } from "@intentius/chant-lexicon-cedar";');
450
+ expect(policy).toContain('export const personApproves = new Policy({\n effect: "permit",\n principal: { is: person },');
451
+ expect(policy).toContain("policies: [personApproves, agentSkipsOnTableYes]");
452
+ expect(policy).toContain('annotations: { id: "agents-never-skip-otherwise" }');
453
+ expect(readLock(proj)!.scopes["."].migrations).toContain(CHUD_LEXICON_EXIT_SHIP_POLICY);
454
+
455
+ const again = await stageUpgrade({ root: proj, to: target(), runChant: passing });
456
+ try {
457
+ expect(again.chantMigrations).toEqual([]);
458
+ expect(again.changed).toBe(false);
459
+ } finally {
460
+ again.dispose();
461
+ }
462
+ });
463
+
464
+ test("a policy the project rewrote without the anchors is a conflict, and nothing is applied", async () => {
465
+ put(proj, POLICY, read(proj, POLICY).replace(" policies: [agentSkipsOnTableYes],\n", " policies: [agentSkipsOnTableYes, ours],\n"));
466
+ commit("our own policy set");
467
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
468
+ try {
469
+ const m = staged.chantMigrations.find((x) => x.id === CHUD_LEXICON_EXIT_SHIP_POLICY);
470
+ expect(m).toMatchObject({ applied: false, conflicts: [{ path: POLICY }] });
471
+ } finally {
472
+ staged.dispose();
473
+ }
474
+ });
475
+ });
476
+
477
+ describe("chud-lexicon-exit-fly-site, over a repo chud-lexicon-exit migrated before it existed (#2809)", () => {
478
+ const FILES = ["delivery/deploy/fly.ts", "delivery/deploy/fly-machine.ts", "delivery/deploy/app.component.ts"];
479
+ const RELEASE = "delivery/ops/release.op.ts";
480
+ beforeEach(async () => {
481
+ await makeProject("dir");
482
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
483
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
484
+ const released = exitWrote(FILES);
485
+ await upgrade();
486
+ // The release Op as the other follow-ons left it, with 0.92.0's line on where the Fly requests come from.
487
+ released[RELEASE] = read(proj, RELEASE).replace("the FlySite composite in deploy/fly.ts, into dist/fly.json", "deploy/fly.ts and deploy/fly-machine.ts, into dist/fly.json");
488
+ asReleased(released, readLock(proj)!.scopes["."].migrations.filter((id) => id !== CHUD_LEXICON_EXIT_FLY_SITE));
489
+ });
490
+
491
+ test("the dry run lists it; the upgrade declares the FlySite and the component names it; a second plans nothing", async () => {
492
+ expect(read(proj, "delivery/deploy/fly.ts")).toContain("export const flyApp = new App(");
493
+ const lines: string[] = [];
494
+ vi.spyOn(console, "log").mockImplementation((s: string) => void lines.push(s));
495
+ const dry = await upgradeCommand({ root: proj, to: target(), dryRun: true, runChant: passing });
496
+ expect(dry.outcome).toBe("dry-run");
497
+ const out = lines.join("\n");
498
+ expect(out).toContain(`chant migration: ${CHUD_LEXICON_EXIT_FLY_SITE} (applied)`);
499
+ expect(out).toContain("write: delivery/deploy/fly.ts");
500
+ expect(out).toContain("delete: delivery/deploy/fly-machine.ts");
501
+
502
+ await upgrade();
503
+ expect(read(proj, "delivery/deploy/fly.ts")).toContain("export const flySite = FlySite({");
504
+ expect(existsSync(join(proj, "delivery/deploy/fly-machine.ts"))).toBe(false);
505
+ expect(read(proj, "delivery/deploy/app.component.ts")).toContain('composites: ["FlySite"],');
506
+ expect(read(proj, "delivery/ops/release.op.ts")).toContain("the FlySite composite in deploy/fly.ts, into dist/fly.json");
507
+ expect(readLock(proj)!.scopes["."].migrations).toContain(CHUD_LEXICON_EXIT_FLY_SITE);
508
+
509
+ const again = await stageUpgrade({ root: proj, to: target(), runChant: passing });
510
+ try {
511
+ expect(again.chantMigrations).toEqual([]);
512
+ expect(again.changed).toBe(false);
513
+ } finally {
514
+ again.dispose();
515
+ }
516
+ });
517
+ });
518
+
519
+ describe("chud-lexicon-exit-live-names, over a repo chud-lexicon-exit migrated before it existed (#2833)", () => {
520
+ const COMPONENT = "delivery/deploy/app.component.ts";
521
+ beforeEach(async () => {
522
+ await makeProject("dir");
523
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
524
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
525
+ const released = exitWrote([COMPONENT]);
526
+ await upgrade();
527
+ asReleased(released, readLock(proj)!.scopes["."].migrations.filter((id) => id !== CHUD_LEXICON_EXIT_LIVE_NAMES));
528
+ });
529
+
530
+ test("the dry run lists it; the upgrade adds liveNames and the lock records it; a second plans nothing", async () => {
531
+ expect(read(proj, COMPONENT)).not.toContain("liveNames");
532
+ const lines: string[] = [];
533
+ vi.spyOn(console, "log").mockImplementation((s: string) => void lines.push(s));
534
+ const dry = await upgradeCommand({ root: proj, to: target(), dryRun: true, runChant: passing });
535
+ expect(dry.outcome).toBe("dry-run");
536
+ const out = lines.join("\n");
537
+ expect(out).toContain(`chant migration: ${CHUD_LEXICON_EXIT_LIVE_NAMES} (applied)`);
538
+ expect(out).toContain(`write: ${COMPONENT}`);
539
+
540
+ await upgrade();
541
+ const component = read(proj, COMPONENT);
542
+ // deploy/fly.ts is already the FlySite composite (chant#2809 plans before
543
+ // this one), so the derived name is the Machine it expands, not the old
544
+ // hardcoded "server" (#2839).
545
+ expect(component).toContain('dependsOn: [],\n liveNames: ["flySiteMachine"],\n');
546
+ expect(component).toContain("Its live name is the Machine the release Op\n * ships to, the entity flySiteMachine (chant#2833)");
547
+ expect(readLock(proj)!.scopes["."].migrations).toContain(CHUD_LEXICON_EXIT_LIVE_NAMES);
548
+
549
+ const again = await stageUpgrade({ root: proj, to: target(), runChant: passing });
550
+ try {
551
+ expect(again.chantMigrations).toEqual([]);
552
+ expect(again.changed).toBe(false);
553
+ } finally {
554
+ again.dispose();
555
+ }
556
+ });
557
+
558
+ test("an app component the project rewrote without the anchor is a conflict, and nothing is applied", async () => {
559
+ put(proj, COMPONENT, read(proj, COMPONENT).replace(" dependsOn: [],\n", ""));
560
+ commit("our own component shape");
561
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
562
+ try {
563
+ const m = staged.chantMigrations.find((x) => x.id === CHUD_LEXICON_EXIT_LIVE_NAMES);
564
+ expect(m).toMatchObject({ applied: false, conflicts: [{ path: COMPONENT }] });
565
+ } finally {
566
+ staged.dispose();
567
+ }
568
+ });
569
+
570
+ test("a component that already declares liveNames is left alone", async () => {
571
+ put(proj, COMPONENT, read(proj, COMPONENT).replace(" dependsOn: [],\n", ' dependsOn: [],\n liveNames: ["web"],\n'));
572
+ commit("our own liveNames");
573
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
574
+ try {
575
+ expect(staged.chantMigrations.find((m) => m.id === CHUD_LEXICON_EXIT_LIVE_NAMES)).toBeUndefined();
576
+ } finally {
577
+ staged.dispose();
578
+ }
579
+ });
580
+
581
+ test("a component already holding the right liveNames (hand-corrected, arugula-salad/studio cfd4522) plans nothing", async () => {
582
+ put(proj, COMPONENT, read(proj, COMPONENT).replace(" dependsOn: [],\n", ' dependsOn: [],\n liveNames: ["flySiteMachine"],\n'));
583
+ commit("hand-corrected liveNames");
584
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
585
+ try {
586
+ expect(staged.chantMigrations.find((m) => m.id === CHUD_LEXICON_EXIT_LIVE_NAMES)).toBeUndefined();
587
+ } finally {
588
+ staged.dispose();
589
+ }
590
+ });
591
+
592
+ test("a component holding this migration's own bad default (#2839) is corrected once FlySite names the Machine", async () => {
593
+ put(proj, COMPONENT, read(proj, COMPONENT).replace(" dependsOn: [],\n", ' dependsOn: [],\n liveNames: ["server"],\n'));
594
+ commit("the bad default, as the unfixed migration wrote it");
595
+ await upgrade();
596
+ const component = read(proj, COMPONENT);
597
+ expect(component).toContain('dependsOn: [],\n liveNames: ["flySiteMachine"],\n');
598
+ expect(component).not.toContain('liveNames: ["server"]');
599
+
600
+ const again = await stageUpgrade({ root: proj, to: target(), runChant: passing });
601
+ try {
602
+ expect(again.chantMigrations).toEqual([]);
603
+ expect(again.changed).toBe(false);
604
+ } finally {
605
+ again.dispose();
606
+ }
607
+ });
608
+
609
+ test("neither deploy/fly.ts nor deploy/fly-machine.ts says the Machine's live name: a conflict, not a guess", async () => {
610
+ put(proj, "delivery/deploy/fly.ts", read(proj, "delivery/deploy/fly.ts").replace("export const flySite = FlySite(", "const flySite = FlySite("));
611
+ commit("the site under a name this migration cannot see");
612
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
613
+ try {
614
+ const m = staged.chantMigrations.find((x) => x.id === CHUD_LEXICON_EXIT_LIVE_NAMES);
615
+ expect(m).toMatchObject({ applied: false, conflicts: [{ path: COMPONENT }] });
616
+ expect(m!.conflicts[0].reason).toContain("cannot tell the Machine's live name");
617
+ } finally {
618
+ staged.dispose();
619
+ }
620
+ });
621
+ });
622
+
623
+ describe("chud-lexicon-exit, with the project's own edits", () => {
624
+ beforeEach(async () => {
625
+ await makeProject("dir");
626
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
627
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
628
+ });
629
+
630
+ test("keeps the box's port edits in chant.config.ts, and marks a replaced Op that was edited", async () => {
631
+ put(proj, "delivery/chant.config.ts", read(proj, "delivery/chant.config.ts").replace("default: 3000, env: \"CHUD_DEV_PORT\"", "default: 4312, env: \"CHUD_DEV_PORT\""));
632
+ put(proj, "delivery/ops/release.op.ts", read(proj, "delivery/ops/release.op.ts") + "// ours\n");
633
+ commit("the box's ports, and an edit");
634
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
635
+ try {
636
+ const [m] = staged.chantMigrations;
637
+ expect(m.applied).toBe(true);
638
+ expect(m.changes.find((c) => c.path === "delivery/ops/release.op.ts")?.edited).toBe(true);
639
+ expect(read(staged.worktreeProject, "delivery/chant.config.ts")).toContain('default: 4312, env: "CHUD_DEV_PORT"');
640
+ expect(read(staged.worktreeProject, "delivery/ops/release.op.ts")).not.toContain("// ours");
641
+ } finally {
642
+ staged.dispose();
643
+ }
644
+ });
645
+
646
+ test("a Fly site the project changed keeps its resources, and the plan says it is not a composite instance (#2809)", async () => {
647
+ put(proj, "delivery/deploy/fly-machine.ts", read(proj, "delivery/deploy/fly-machine.ts").replace('region: "iad",\n config', 'region: "ord",\n config'));
648
+ commit("the Machine in another region");
649
+ await upgrade();
650
+ expect(read(proj, "delivery/deploy/fly.ts")).toContain("export const flyApp = new App(");
651
+ expect(read(proj, "delivery/deploy/fly.ts")).not.toContain("FlySite");
652
+ expect(read(proj, "delivery/deploy/fly-machine.ts")).toContain('region: "ord",');
653
+ const component = read(proj, "delivery/deploy/app.component.ts");
654
+ expect(component).toContain('composites: ["FlySite"],');
655
+ expect(readLock(proj)!.scopes["."].migrations).toContain(CHUD_LEXICON_EXIT_FLY_SITE);
656
+ // What its plan said: only the component is written, and the composite is not moved.
657
+ put(proj, "delivery/deploy/app.component.ts", component.replace(' composites: ["FlySite"],\n', ""));
658
+ const plan = chudLexiconExitFlySite.plan({ dir: proj, lineage: readLock(proj)!.scopes["."], chantVersion: readerVersion() })!;
659
+ expect(plan.changes.map((c) => c.path)).toEqual(["delivery/deploy/app.component.ts"]);
660
+ expect(plan.notMoved.map((n) => n.what)).toContain("the Fly site as a composite instance (delivery/deploy/fly.ts is not the template's, so its resources are kept as they are)");
661
+ });
662
+
663
+ test("a file it does not know that imports chud is a conflict: nothing is applied, and the checks fail", async () => {
664
+ put(proj, "delivery/deploy/extra.ts", 'import { config } from "@intentius/chud-runtime/release";\nexport const port = config.ports.prod;\n');
665
+ commit("our own chud import");
666
+ const lines: string[] = [];
667
+ vi.spyOn(console, "log").mockImplementation((s: string) => void lines.push(s));
668
+ const result = await upgradeCommand({ root: proj, to: target(), dryRun: true, runChant: passing });
669
+ expect(result.outcome).toBe("checks-failed");
670
+ expect(lines.join("\n")).toMatch(/conflict: delivery\/deploy\/extra\.ts: imports the chud packages/);
671
+ expect(result.staged!.chantMigrations[0].applied).toBe(false);
672
+ expect(result.staged!.migrations).not.toContain(CHUD_LEXICON_EXIT);
673
+ expect(git(proj, ["status", "--porcelain"])).toBe("");
674
+ });
675
+
676
+ test("a point of the project's own, whose inputs have no read-contract output, is a conflict", async () => {
677
+ const yaml = read(proj, "delivery/decisions/points.yaml");
678
+ put(proj, "delivery/decisions/points.yaml", `${yaml}\n our-point:\n title: Ours\n question: { type: boolean, instructions: Ours?, criteria: { "true": yes, "false": no } }\n inputs: { size: how big }\n deciders:\n - kind: quorum\n count: 1\n`);
679
+ commit("our point");
680
+ const staged = await stageUpgrade({ root: proj, to: target(), runChant: passing });
681
+ try {
682
+ expect(staged.checksOk).toBe(false);
683
+ expect(staged.chantMigrations[0].conflicts[0]).toMatchObject({ path: "delivery/decisions/points.yaml" });
684
+ expect(staged.chantMigrations[0].conflicts[0].reason).toContain("our-point");
685
+ expect(existsSync(join(staged.worktreeProject, "delivery/ops/chant-lexicon-chud"))).toBe(true);
686
+ } finally {
687
+ staged.dispose();
688
+ }
689
+ });
690
+ });
691
+
692
+ describe("convertPoints", () => {
693
+ test("keeps an input already named for its output, and refuses one with none", () => {
694
+ const ok = convertPoints("points:\n ours:\n title: t\n question: { type: boolean, instructions: i, criteria: { \"true\": a, \"false\": b } }\n inputs: { work-item.size: s }\n deciders:\n - kind: table\n rows: [{ when: { work-item.size: 1 }, answer: true }]\n - kind: quorum\n count: 1\n", "p.json");
695
+ expect("json" in ok).toBe(true);
696
+ const bad = convertPoints("points:\n ours:\n title: t\n question: { type: boolean, instructions: i, criteria: { \"true\": a, \"false\": b } }\n inputs: { size: s }\n deciders:\n - kind: quorum\n count: 1\n", "p.json");
697
+ expect("error" in bad && bad.error).toContain("ours");
698
+ });
699
+ });
700
+
701
+ describe("the migrated delivery project, with this checkout's chant", () => {
702
+ /** Run this checkout's chant without blocking the event loop, so the in-process flaps below can answer it. */
703
+ function chant(cwd: string, env: Record<string, string>, ...args: string[]): Promise<{ status: number | null; out: string }> {
704
+ return new Promise((done) => {
705
+ const child = spawn(
706
+ process.execPath,
707
+ ["--import", pathToFileURL(join(repoRoot, "node_modules/tsx/dist/loader.mjs")).href, join(repoRoot, "packages/core/src/cli/main.ts"), ...args],
708
+ { cwd, env: { ...process.env, NO_COLOR: "1", ...env } },
709
+ );
710
+ let out = "";
711
+ child.stdout.on("data", (d) => (out += d));
712
+ child.stderr.on("data", (d) => (out += d));
713
+ const timer = setTimeout(() => child.kill(), 240_000);
714
+ child.on("close", (status) => {
715
+ clearTimeout(timer);
716
+ done({ status, out });
717
+ });
718
+ });
719
+ }
720
+
721
+ type FakeFlaps = {
722
+ execs: Array<{ command: string[] }>;
723
+ machines: Map<string, Array<{ name: string; config: Record<string, unknown> & { metadata?: Record<string, string> } }>>;
724
+ endpoint(app: string, name: string): string | undefined;
725
+ processes(): Array<{ pid: number | undefined }>;
726
+ };
727
+ /**
728
+ * The fly lexicon's in-memory Machines API, served on a local port. With `run`, its running mode (#2831): each
729
+ * started Machine's files are written under `run`, its start command runs, and its service answers over HTTP.
730
+ */
731
+ async function flaps(run?: string): Promise<{ endpoint: string; fake: FakeFlaps; close(): Promise<void> }> {
732
+ if (run) {
733
+ const { serveLocalMachines } = (await import(pathToFileURL(join(repoRoot, "lexicons/fly/src/op/activities/machines-local.ts")).href)) as {
734
+ serveLocalMachines(o: { port: number; root: string; log: string }): Promise<{ url: string; machines: FakeFlaps; close(): Promise<void> }>;
735
+ };
736
+ const served = await serveLocalMachines({ port: 0, root: run, log: join(run, "machines.log") });
737
+ return { endpoint: served.url, fake: served.machines, close: served.close };
738
+ }
739
+ const { createMachinesFake } = (await import(pathToFileURL(join(repoRoot, "lexicons/fly/src/op/activities/machines-fake.ts")).href)) as {
740
+ createMachinesFake(): { http(method: string, url: string, body?: unknown): Promise<{ status: number; text: string }> } & Record<string, never>;
741
+ };
742
+ const fake = createMachinesFake();
743
+ const server = createServer((req, res) => {
744
+ const chunks: Buffer[] = [];
745
+ req.on("data", (c: Buffer) => chunks.push(c));
746
+ req.on("end", () => {
747
+ const text = Buffer.concat(chunks).toString("utf-8");
748
+ void fake.http(req.method ?? "GET", `http://flaps${req.url}`, text ? JSON.parse(text) : undefined).then((r) => {
749
+ res.writeHead(r.status, { "content-type": "application/json" });
750
+ res.end(r.text);
751
+ });
752
+ });
753
+ });
754
+ await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
755
+ const { port } = server.address() as AddressInfo;
756
+ return { endpoint: `http://127.0.0.1:${port}`, fake: fake as never, close: () => new Promise((ok) => server.close(() => ok())) };
757
+ }
758
+
759
+ beforeEach(async () => {
760
+ await makeProject("dir");
761
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
762
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
763
+ });
764
+
765
+ test("builds, lints, rebuilds its CI, stops at the ship gate, ships each approved plan to Fly and records it once, and rolls back to the previous release", { timeout: 1_800_000 }, async () => {
766
+ await upgrade();
767
+ const delivery = join(proj, "delivery");
768
+ symlinkSync(join(repoRoot, "node_modules"), join(delivery, "node_modules"));
769
+ writeFileSync(join(proj, ".git/info/exclude"), "node_modules\n");
770
+ const fly = await flaps(join(root, "fly"));
771
+ const env = { FLY_FLAPS_BASE_URL: fly.endpoint, FLY_API_TOKEN: "test", CHUD_FLY_APP_SECRET: "s3cret", GITHUB_ACTOR: "releaser" };
772
+ /** The Machine's app over HTTP, on the host port its service is published on. */
773
+ const site = async (path: string) => {
774
+ const [app] = [...fly.fake.machines.keys()];
775
+ const url = fly.fake.endpoint(app, "web");
776
+ expect(url, read(root, "fly/machines.log")).toBeDefined();
777
+ const res = await fetch(`${url}${path}`);
778
+ return { status: res.status, text: await res.text() };
779
+ };
780
+ try {
781
+ const build = await chant(delivery, env, "build", "-o", join(root, "build.out"));
782
+ expect(build.status, build.out).toBe(0);
783
+ const lint = await chant(delivery, env, "lint");
784
+ expect(lint.status, lint.out).toBe(0);
785
+ // `npm run ci:check`: the committed workflow is what ci/ci.ts builds.
786
+ const ci = await chant(delivery, env, "build", "ci", "--lexicon", "github", "-o", join(root, "ci.yml"));
787
+ expect(ci.status, ci.out).toBe(0);
788
+ expect(read(root, "ci.yml")).toBe(read(proj, ".github/workflows/ci.yml"));
789
+
790
+ // The Fly site is a composite instance, deployed by the app component (#2809): what the studio smoke's checks 27, 28 and 55 read.
791
+ const graph = await chant(proj, env, "workspace", "graph", "--composites", "--json");
792
+ expect(graph.status, graph.out).toBe(0);
793
+ const doc = JSON.parse(graph.out.slice(graph.out.indexOf("{"))) as { composites: Array<{ id: string; member: string; kinds: string[]; lexicons: string[]; components: Array<{ component: string; by: string; via: string }> }>; components: Array<{ id: string; archetype: string }>; reasons: unknown[] };
794
+ expect(doc.reasons).toEqual([]);
795
+ expect(doc.composites).toHaveLength(1);
796
+ expect(doc.composites[0]).toMatchObject({ id: "delivery/flySite", member: "delivery", kinds: ["FlySite"], lexicons: ["fly"], components: [{ component: "delivery/app", by: "composites", via: "member" }] });
797
+ expect(doc.components.find((c) => c.id === "delivery/app")?.archetype).toBe("service");
798
+ // The plan a release ships with has the one Machine, with the resources fly.ts and fly-machine.ts declared.
799
+ const flyPlan = await chant(delivery, env, "build", "deploy", "--lexicon", "fly", "-o", join(root, "fly.json"));
800
+ expect(flyPlan.status, flyPlan.out).toBe(0);
801
+ const requests = Object.values(JSON.parse(read(root, "fly.json")) as Record<string, { endpoint: string; body: Record<string, unknown> }>);
802
+ const machines = requests.filter((r) => /\/machines$/.test(r.endpoint));
803
+ expect(machines).toHaveLength(1);
804
+ expect(machines[0].body).toMatchObject({
805
+ name: "web",
806
+ region: "iad",
807
+ config: {
808
+ image: "node:22-slim",
809
+ guest: { cpu_kind: "shared", cpus: 1, memory_mb: 256 },
810
+ mounts: [{ volume: "data", path: "/data" }],
811
+ services: [{ protocol: "tcp", internal_port: 8080, ports: [{ port: 443, handlers: ["tls", "http"] }, { port: 80, handlers: ["http"] }] }],
812
+ env: { PORT: "8080", APP_DATA: "/data" },
813
+ },
814
+ });
815
+ expect(requests.map((r) => r.endpoint).sort()).toEqual(["/v1/apps", "/v1/apps/notes/ip_assignments", "/v1/apps/notes/machines", "/v1/apps/notes/secrets/APP_SECRET", "/v1/apps/notes/volumes"].sort());
816
+
817
+ // Check (the app's tests), Build, Plan with the ship-skip point through decide, then the gate on the plan.
818
+ const gated = await chant(delivery, env, "run", "release");
819
+ expect(gated.status, gated.out).toBe(3);
820
+ expect(gated.out).toContain(`gated on "ship"`);
821
+ const answers = readdirSync(join(proj, "answers")).filter((f) => f.startsWith("ship-skip-"));
822
+ expect(answers).toHaveLength(1);
823
+ expect(read(proj, `answers/${answers[0]}`)).toMatch(/state: "answered"\nanswer: false/);
824
+ const plans = readdirSync(join(delivery, "dist/plans"));
825
+ expect(plans).toHaveLength(1);
826
+ const plan = JSON.parse(read(delivery, `dist/plans/${plans[0]}`)) as { digest: string; gitSha: string; artifact: { digest: string } };
827
+ expect(plan).toMatchObject({ component: "app", env: "fly", gitSha: git(proj, ["rev-parse", "HEAD"]), shipSkip: { answer: false, decider: "table" } });
828
+ expect(fly.fake.machines.size).toBe(0);
829
+
830
+ const approve = await chant(delivery, env, "approve", "release", "ship", "--plan", plan.digest, "--approver", "alice");
831
+ expect(approve.status, approve.out).toBe(0);
832
+
833
+ // Ship: the approved tree on the Machine, the migration run once inside it, then Record.
834
+ const shipped = await chant(delivery, env, "run", "release");
835
+ expect(shipped.status, shipped.out).toBe(0);
836
+ const [machine] = [...fly.fake.machines.values()].flat();
837
+ expect(machine.name).toBe("web");
838
+ expect(machine.config.metadata?.["chant-release-digest"]).toBe(plan.digest);
839
+ expect(machine.config.metadata?.["chant-release-git-sha"]).toBe(plan.gitSha);
840
+ const files = (machine.config.files as Array<{ guest_path: string; raw_value: string }>).map((f) => f.guest_path);
841
+ expect(files).toEqual(expect.arrayContaining(["/srv/app/server.js", "/srv/app/migrate.js", "/srv/app/migrations/0001_init.sql"]));
842
+ expect(Buffer.from((machine.config.files as Array<{ guest_path: string; raw_value: string }>).find((f) => f.guest_path === "/srv/app/server.js")!.raw_value, "base64").toString()).toBe(read(proj, "app/server.js"));
843
+ expect((machine.config.init as { cmd: string[] }).cmd).toEqual(["sh", "-c", "cd /srv/app && exec node --disable-warning=ExperimentalWarning server.js"]);
844
+ expect(fly.fake.execs.map((e) => e.command.join(" "))).toEqual(["sh -c cd /srv/app && node --disable-warning=ExperimentalWarning migrate.js 0001_init.sql"]);
845
+ // The shipped tree runs (#2831): the app answers on its port, its page is the committed one, and /health reads
846
+ // the table the migration created in the data Volume.
847
+ const health = await site("/health");
848
+ expect(health.status, health.text).toBe(200);
849
+ expect(JSON.parse(health.text)).toMatchObject({ status: "healthy", services: { database: "healthy" } });
850
+ expect((await site("/")).text).toBe(read(proj, "app/public/index.html"));
851
+ const note = await site("/api/notes");
852
+ expect(JSON.parse(note.text)).toEqual([]);
853
+ const [{ pid: pidA }] = fly.fake.processes();
854
+ const ledger = await readReleaseLedger("fly", { cwd: delivery });
855
+ expect(ledger.records).toHaveLength(1);
856
+ expect(ledger.records[0]).toMatchObject({ component: "app", env: "fly", digest: plan.digest, gitSha: plan.gitSha, actor: "releaser", approver: "alice" });
857
+ expect(await readReleasePlan(plan.digest, { cwd: delivery })).toMatchObject({ digest: plan.digest, artifact: { digest: plan.artifact.digest } });
858
+
859
+ // The app component's liveNames (chud-lexicon-exit-live-names, #2833)
860
+ // join it to the Machine the FlySite composite gives it, so `components
861
+ // status --live` reads the release reconciled instead of stale (#2839).
862
+ const status = await chant(delivery, env, "components", "status", "fly", "--live", "--json");
863
+ expect(status.status, status.out).toBe(0);
864
+ const statusLines = status.out.split("\n");
865
+ const statusJsonAt = statusLines.findIndex((l) => l === "[");
866
+ const statusRows = JSON.parse(statusLines.slice(statusJsonAt).join("\n")) as Array<{ component: string; reconciliation: string; live?: boolean }>;
867
+ expect(statusRows.find((r) => r.component === "app")).toMatchObject({ reconciliation: "reconciled", live: true });
868
+
869
+ // A retry: the same plan, still approved; the Machine is left as it is, the migration's receipt matches, and nothing more is recorded.
870
+ const again = await chant(delivery, env, "run", "release");
871
+ expect(again.status, again.out).toBe(0);
872
+ expect(fly.fake.execs).toHaveLength(1);
873
+ expect((await readReleaseLedger("fly", { cwd: delivery })).records).toHaveLength(1);
874
+
875
+ // A new commit is a new tree, so a new plan digest: the approval of the old one does not pass it (#2808).
876
+ const pageA = read(proj, "app/public/index.html");
877
+ put(proj, "app/server.js", `${read(proj, "app/server.js")}// a change\n`);
878
+ put(proj, "app/public/index.html", `${pageA}<!-- release B -->\n`);
879
+ commit("change the app");
880
+ const changed = await chant(delivery, env, "run", "release");
881
+ expect(changed.status, changed.out).toBe(3);
882
+ expect(changed.out).toContain(`gated on "ship"`);
883
+ const next = readdirSync(join(delivery, "dist/plans")).filter((f) => f !== plans[0]);
884
+ expect(next).toHaveLength(1);
885
+ const nextPlan = JSON.parse(read(delivery, `dist/plans/${next[0]}`)) as { digest: string };
886
+ expect(nextPlan.digest).not.toBe(plan.digest);
887
+ expect(changed.out).toContain(nextPlan.digest);
888
+ expect(machine.config.metadata?.["chant-release-digest"]).toBe(plan.digest);
889
+ expect(fly.fake.execs).toHaveLength(1);
890
+ expect((await readReleaseLedger("fly", { cwd: delivery })).records).toHaveLength(1);
891
+
892
+ // Release B: that plan approved and shipped.
893
+ const served = () => [...fly.fake.machines.values()].flat()[0];
894
+ const servedFile = (path: string) =>
895
+ Buffer.from((served().config.files as Array<{ guest_path: string; raw_value: string }>).find((f) => f.guest_path === path)!.raw_value, "base64").toString();
896
+ const serverA = read(proj, "app/server.js").replace("// a change\n", "");
897
+ const approveB = await chant(delivery, env, "approve", "release", "ship", "--plan", nextPlan.digest, "--approver", "alice");
898
+ expect(approveB.status, approveB.out).toBe(0);
899
+ const shippedB = await chant(delivery, env, "run", "release");
900
+ expect(shippedB.status, shippedB.out).toBe(0);
901
+ expect(served().config.metadata?.["chant-release-digest"]).toBe(nextPlan.digest);
902
+ expect(servedFile("/srv/app/server.js")).toBe(read(proj, "app/server.js"));
903
+ expect((await readReleaseLedger("fly", { cwd: delivery })).records).toHaveLength(2);
904
+ // B runs in a new process on the same port, over the same data.
905
+ expect((await site("/")).text).toBe(read(proj, "app/public/index.html"));
906
+ expect((await site("/")).text).not.toBe(pageA);
907
+ expect((await site("/health")).status).toBe(200);
908
+ const [{ pid: pidB }] = fly.fake.processes();
909
+ expect(pidB).not.toBe(pidA);
910
+
911
+ // Roll back (#2800): the rollback Op plans release A again, its tree archived again from its commit, and stops at its gate.
912
+ const plansBefore = new Set(readdirSync(join(delivery, "dist/plans")));
913
+ const gatedBack = await chant(delivery, env, "run", "rollback");
914
+ expect(gatedBack.status, gatedBack.out).toBe(3);
915
+ expect(gatedBack.out).toContain(`gated on "rollback"`);
916
+ const rollbackPlans = readdirSync(join(delivery, "dist/plans")).filter((f) => !plansBefore.has(f));
917
+ expect(rollbackPlans).toHaveLength(1);
918
+ const rollbackPlan = JSON.parse(read(delivery, `dist/plans/${rollbackPlans[0]}`)) as { digest: string; gitSha: string; rollback: { to: string; from: string }; artifact: { digest: string } };
919
+ expect(rollbackPlan).toMatchObject({ gitSha: plan.gitSha, rollback: { to: plan.digest, from: nextPlan.digest }, artifact: { digest: plan.artifact.digest } });
920
+ expect(served().config.metadata?.["chant-release-digest"]).toBe(nextPlan.digest);
921
+
922
+ const approveBack = await chant(delivery, env, "approve", "rollback", "rollback", "--plan", rollbackPlan.digest, "--approver", "carol");
923
+ expect(approveBack.status, approveBack.out).toBe(0);
924
+ const back = await chant(delivery, env, "run", "rollback");
925
+ expect(back.status, back.out).toBe(0);
926
+ // The Machine serves A: its digest, its commit and its files. Migrations are not run again.
927
+ expect(served().config.metadata?.["chant-release-digest"]).toBe(plan.digest);
928
+ expect(served().config.metadata?.["chant-release-git-sha"]).toBe(plan.gitSha);
929
+ expect(servedFile("/srv/app/server.js")).toBe(serverA);
930
+ expect(fly.fake.execs).toHaveLength(1);
931
+ // Over HTTP: A's page again, from a new process.
932
+ expect((await site("/")).text).toBe(pageA);
933
+ expect((await site("/health")).status).toBe(200);
934
+ expect(fly.fake.processes()[0].pid).not.toBe(pidB);
935
+ const afterBack = (await readReleaseLedger("fly", { cwd: delivery })).records;
936
+ expect(afterBack).toHaveLength(3);
937
+ expect(afterBack[2]).toMatchObject({
938
+ component: "app",
939
+ env: "fly",
940
+ digest: plan.digest,
941
+ gitSha: plan.gitSha,
942
+ actor: "releaser",
943
+ approver: "carol",
944
+ restores: { env: "fly", runId: afterBack[0].runId, timestamp: afterBack[0].timestamp },
945
+ });
946
+
947
+ // Again: the same rollback plan, still approved; the Machine is left as it is and nothing more is recorded.
948
+ const backAgain = await chant(delivery, env, "run", "rollback");
949
+ expect(backAgain.status, backAgain.out).toBe(0);
950
+ expect(served().config.metadata?.["chant-release-digest"]).toBe(plan.digest);
951
+ expect((await readReleaseLedger("fly", { cwd: delivery })).records).toHaveLength(3);
952
+ } finally {
953
+ await fly.close();
954
+ }
955
+ });
956
+
957
+ test("asks ship-skip with the inputs the point declares, from the diff since the release the site serves (#2811)", { timeout: 1_200_000 }, async () => {
958
+ await upgrade();
959
+ // A row above the default: a release that is not the first and changes no work item may skip the gate.
960
+ const pointsDoc = JSON.parse(read(proj, "decisions/points.json")) as { points: Record<string, { deciders: Array<{ kind: string; rows?: unknown[] }> }> };
961
+ pointsDoc.points["ship-skip"].deciders[0].rows!.unshift({ when: { "release.first_release": false, "release.work_changed": false }, answer: true, note: "the app alone changed" });
962
+ put(proj, "decisions/points.json", JSON.stringify(pointsDoc, null, 2) + "\n");
963
+ commit("a ship-skip row for the test");
964
+ const delivery = join(proj, "delivery");
965
+ symlinkSync(join(repoRoot, "node_modules"), join(delivery, "node_modules"));
966
+ writeFileSync(join(proj, ".git/info/exclude"), "node_modules\n");
967
+
968
+ const seen = new Set<string>();
969
+ /** The ship-skip answer record the last run wrote: its inputs, answer and table row. */
970
+ const answered = (): { inputs: Record<string, unknown>; answer: unknown; decider: { kind: string; row: number } } => {
971
+ const fresh = readdirSync(join(proj, "answers")).filter((f) => f.startsWith("ship-skip-") && !seen.has(f));
972
+ expect(fresh).toHaveLength(1);
973
+ seen.add(fresh[0]);
974
+ return yaml.load(read(proj, `answers/${fresh[0]}`).split("---\n")[1]) as never;
975
+ };
976
+ const count = (text: string) => text.split("\n").filter(Boolean).length;
977
+ const fly = await flaps();
978
+ const env = { FLY_FLAPS_BASE_URL: fly.endpoint, FLY_API_TOKEN: "test", CHUD_FLY_APP_SECRET: "s3cret", GITHUB_ACTOR: "releaser" };
979
+ try {
980
+ const first = await chant(delivery, env, "run", "release");
981
+ expect(first.status, first.out).toBe(3);
982
+ // The first release: every file on HEAD counts.
983
+ expect(answered()).toMatchObject({
984
+ inputs: {
985
+ "release.first_release": true,
986
+ "release.new_migrations": 1,
987
+ "release.files_changed": count(git(proj, ["ls-tree", "-r", "--name-only", "HEAD"])),
988
+ "release.app_changed": true,
989
+ "release.work_changed": true,
990
+ "release.units": 0,
991
+ },
992
+ answer: false,
993
+ decider: { kind: "table", row: 1 },
994
+ });
995
+ const approve = await chant(delivery, env, "approve", "release", "ship", "--approver", "alice");
996
+ expect(approve.status, approve.out).toBe(0);
997
+ const shipped = await chant(delivery, env, "run", "release");
998
+ expect(shipped.status, shipped.out).toBe(0);
999
+ const serving = git(proj, ["rev-parse", "HEAD"]);
1000
+
1001
+ put(proj, "app/server.js", `${read(proj, "app/server.js")}// a change\n`);
1002
+ commit("change the app");
1003
+ const second = await chant(delivery, env, "run", "release");
1004
+ expect(second.status, second.out).toBe(3);
1005
+ // The app alone changed since the serving release: the row above the default answers yes.
1006
+ expect(answered()).toMatchObject({
1007
+ inputs: {
1008
+ "release.first_release": false,
1009
+ "release.new_migrations": 0,
1010
+ "release.files_changed": count(git(proj, ["diff", "--name-only", serving, "HEAD"])),
1011
+ "release.app_changed": true,
1012
+ "release.work_changed": false,
1013
+ "release.units": 0,
1014
+ },
1015
+ answer: true,
1016
+ decider: { kind: "table", row: 0 },
1017
+ });
1018
+
1019
+ put(proj, "work/W-001-notes-search.md", "---\nid: W-001\n---\nSearch notes.\n");
1020
+ put(proj, "app/migrations/0002_search.sql", "-- search\n");
1021
+ commit("a work item and its migration");
1022
+ const third = await chant(delivery, env, "run", "release");
1023
+ expect(third.status, third.out).toBe(3);
1024
+ // A work item changed too, with a migration that would fire: the default row answers no.
1025
+ expect(answered()).toMatchObject({
1026
+ inputs: {
1027
+ "release.first_release": false,
1028
+ "release.new_migrations": 1,
1029
+ "release.files_changed": count(git(proj, ["diff", "--name-only", serving, "HEAD"])),
1030
+ "release.app_changed": true,
1031
+ "release.work_changed": true,
1032
+ "release.units": 1,
1033
+ },
1034
+ answer: false,
1035
+ decider: { kind: "table", row: 1 },
1036
+ });
1037
+ } finally {
1038
+ await fly.close();
1039
+ }
1040
+ });
1041
+
1042
+ test("the ship gate's Cedar policy at enforce: a person's approval is allowed and passes the gate, an agent's is denied and does not (#2810)", { timeout: 1_200_000 }, async () => {
1043
+ await upgrade();
1044
+ const delivery = join(proj, "delivery");
1045
+ symlinkSync(join(repoRoot, "node_modules"), join(delivery, "node_modules"));
1046
+ writeFileSync(join(proj, ".git/info/exclude"), "node_modules\n");
1047
+ const policy = read(proj, "delivery/decisions/ship-skip.cedar.ts");
1048
+ expect(policy).toContain("principal: { is: person }");
1049
+ expect(policy).toContain("policies: [personApproves, agentSkipsOnTableYes]");
1050
+ const op = read(proj, "delivery/ops/release.op.ts");
1051
+ expect(op).toContain('mode: "log-only"');
1052
+ put(proj, "delivery/ops/release.op.ts", op.replace('mode: "log-only"', 'mode: "enforce"'));
1053
+ commit("the ship gate's policy at enforce");
1054
+
1055
+ const fly = await flaps();
1056
+ const env = { FLY_FLAPS_BASE_URL: fly.endpoint, FLY_API_TOKEN: "test", CHUD_FLY_APP_SECRET: "s3cret", GITHUB_ACTOR: "releaser" };
1057
+ const decisions = () =>
1058
+ git(proj, ["show", "chant/lifecycle:_members/delivery/_gates/release.jsonl"])
1059
+ .split("\n")
1060
+ .filter(Boolean)
1061
+ .map((l) => JSON.parse(l) as { kind?: string; resolvedBy?: string; approver?: { kind: string }; policyDecision?: { decision: string; determining: string[] } })
1062
+ .filter((r) => r.kind !== "pending");
1063
+ try {
1064
+ const gated = await chant(delivery, env, "run", "release");
1065
+ expect(gated.status, gated.out).toBe(3);
1066
+
1067
+ // The agent's approval is recorded with the policy's deny, and does not count.
1068
+ const agent = await chant(delivery, env, "approve", "release", "ship", "--agent", "--approver", "release-bot");
1069
+ expect(agent.status, agent.out).toBe(0);
1070
+ expect(agent.out).toMatch(/Policy "ship-skip" \S+: deny \(agents-never-skip-otherwise\)/);
1071
+ expect(agent.out).toContain("An agent's approval does not count toward the quorum.");
1072
+ expect(decisions().at(-1)).toMatchObject({ resolvedBy: "release-bot", approver: { kind: "agent" }, policyDecision: { decision: "deny", determining: ["agents-never-skip-otherwise"] } });
1073
+ const stillGated = await chant(delivery, env, "run", "release");
1074
+ expect(stillGated.status, stillGated.out).toBe(3);
1075
+ expect(fly.fake.machines.size).toBe(0);
1076
+
1077
+ // The person's is recorded with the policy's allow, and the next run passes the gate and ships.
1078
+ const person = await chant(delivery, env, "approve", "release", "ship", "--approver", "alice");
1079
+ expect(person.status, person.out).toBe(0);
1080
+ expect(person.out).toMatch(/Policy "ship-skip" \S+: allow \(a-person-approves\)/);
1081
+ expect(decisions().at(-1)).toMatchObject({ resolvedBy: "alice", approver: { kind: "human" }, policyDecision: { decision: "allow", determining: ["a-person-approves"] } });
1082
+ const shipped = await chant(delivery, env, "run", "release");
1083
+ expect(shipped.status, shipped.out).toBe(0);
1084
+ expect([...fly.fake.machines.values()].flat().map((m) => m.name)).toEqual(["web"]);
1085
+ } finally {
1086
+ await fly.close();
1087
+ }
1088
+ });
1089
+ });