@intentius/chant 0.124.40 → 0.124.42

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 (346) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +18 -1
  15. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  16. package/dist/cli/handlers/fan-out.js +42 -5
  17. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  18. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  19. package/dist/cli/handlers/gate-resume.js +15 -2
  20. package/dist/cli/handlers/lock.d.ts +8 -0
  21. package/dist/cli/handlers/lock.d.ts.map +1 -0
  22. package/dist/cli/handlers/lock.js +197 -0
  23. package/dist/cli/handlers/pr-env.d.ts +34 -0
  24. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  25. package/dist/cli/handlers/pr-env.js +436 -0
  26. package/dist/cli/handlers/pr.d.ts +76 -0
  27. package/dist/cli/handlers/pr.d.ts.map +1 -1
  28. package/dist/cli/handlers/pr.js +615 -55
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/promote.js +9 -0
  31. package/dist/cli/handlers/query.d.ts +6 -0
  32. package/dist/cli/handlers/query.d.ts.map +1 -0
  33. package/dist/cli/handlers/query.js +80 -0
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/run.js +11 -0
  36. package/dist/cli/main.d.ts.map +1 -1
  37. package/dist/cli/main.js +49 -4
  38. package/dist/cli/parse-args.d.ts.map +1 -1
  39. package/dist/cli/parse-args.js +46 -2
  40. package/dist/cli/registry.d.ts +31 -2
  41. package/dist/cli/registry.d.ts.map +1 -1
  42. package/dist/components/capability.d.ts +47 -0
  43. package/dist/components/capability.d.ts.map +1 -1
  44. package/dist/components/capability.js +44 -0
  45. package/dist/components/cli-support.d.ts +6 -0
  46. package/dist/components/cli-support.d.ts.map +1 -1
  47. package/dist/components/cli-support.js +8 -1
  48. package/dist/components/driver.d.ts +10 -0
  49. package/dist/components/driver.d.ts.map +1 -1
  50. package/dist/components/driver.js +52 -2
  51. package/dist/components/fan-out-run.d.ts +37 -0
  52. package/dist/components/fan-out-run.d.ts.map +1 -1
  53. package/dist/components/fan-out-run.js +44 -4
  54. package/dist/components/gate-plan.d.ts +2 -0
  55. package/dist/components/gate-plan.d.ts.map +1 -1
  56. package/dist/components/gate-plan.js +1 -0
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +92 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +150 -21
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts +6 -0
  70. package/dist/components/pr-run.d.ts.map +1 -1
  71. package/dist/components/pr-run.js +8 -3
  72. package/dist/components/wave-plan.d.ts +14 -1
  73. package/dist/components/wave-plan.d.ts.map +1 -1
  74. package/dist/components/wave-plan.js +34 -2
  75. package/dist/gate-decision.d.ts +15 -2
  76. package/dist/gate-decision.d.ts.map +1 -1
  77. package/dist/gate-decision.js +17 -2
  78. package/dist/gate-policy.d.ts +140 -0
  79. package/dist/gate-policy.d.ts.map +1 -0
  80. package/dist/gate-policy.js +242 -0
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/query.d.ts +68 -0
  86. package/dist/lifecycle/query.d.ts.map +1 -0
  87. package/dist/lifecycle/query.js +246 -0
  88. package/dist/lifecycle/run-ledger.d.ts +13 -0
  89. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  90. package/dist/lifecycle/run-ledger.js +16 -0
  91. package/dist/lifecycle/target-lock.d.ts +97 -0
  92. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  93. package/dist/lifecycle/target-lock.js +286 -0
  94. package/dist/op/activities/activity-contracts.d.ts +30 -0
  95. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  96. package/dist/op/activities/activity-contracts.js +26 -0
  97. package/dist/op/activities/index.d.ts +2 -0
  98. package/dist/op/activities/index.d.ts.map +1 -1
  99. package/dist/op/activities/index.js +4 -0
  100. package/dist/op/activities/pr-refs.d.ts +10 -0
  101. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  102. package/dist/op/activities/pr-refs.js +26 -0
  103. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  104. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  105. package/dist/op/activities/predict-behaviour.js +2 -21
  106. package/dist/op/activities/review-agent.d.ts +137 -0
  107. package/dist/op/activities/review-agent.d.ts.map +1 -0
  108. package/dist/op/activities/review-agent.js +266 -0
  109. package/dist/op/builders.d.ts +9 -0
  110. package/dist/op/builders.d.ts.map +1 -1
  111. package/dist/op/builders.js +11 -0
  112. package/dist/op/cron.d.ts +8 -0
  113. package/dist/op/cron.d.ts.map +1 -1
  114. package/dist/op/cron.js +51 -0
  115. package/dist/op/gate-approval.d.ts +20 -13
  116. package/dist/op/gate-approval.d.ts.map +1 -1
  117. package/dist/op/gate-approval.js +13 -18
  118. package/dist/op/gate-quorum.d.ts +131 -0
  119. package/dist/op/gate-quorum.d.ts.map +1 -0
  120. package/dist/op/gate-quorum.js +183 -0
  121. package/dist/op/gate-resume.d.ts +4 -2
  122. package/dist/op/gate-resume.d.ts.map +1 -1
  123. package/dist/op/gate-resume.js +5 -4
  124. package/dist/op/gate-review.js +1 -1
  125. package/dist/op/gate.d.ts +32 -4
  126. package/dist/op/gate.d.ts.map +1 -1
  127. package/dist/op/gate.js +40 -24
  128. package/dist/op/index.d.ts +6 -4
  129. package/dist/op/index.d.ts.map +1 -1
  130. package/dist/op/index.js +4 -3
  131. package/dist/op/local-executor.d.ts.map +1 -1
  132. package/dist/op/local-executor.js +9 -3
  133. package/dist/op/op-waves-run.d.ts +28 -43
  134. package/dist/op/op-waves-run.d.ts.map +1 -1
  135. package/dist/op/op-waves-run.js +170 -120
  136. package/dist/op/op-waves.d.ts +55 -3
  137. package/dist/op/op-waves.d.ts.map +1 -1
  138. package/dist/op/op-waves.js +63 -8
  139. package/dist/op/schedule-overdue.d.ts +57 -0
  140. package/dist/op/schedule-overdue.d.ts.map +1 -0
  141. package/dist/op/schedule-overdue.js +138 -0
  142. package/dist/op/stage-hooks.d.ts +132 -0
  143. package/dist/op/stage-hooks.d.ts.map +1 -0
  144. package/dist/op/stage-hooks.js +142 -0
  145. package/dist/override-gate.d.ts +2 -2
  146. package/dist/override-gate.d.ts.map +1 -1
  147. package/dist/override-gate.js +1 -1
  148. package/dist/pr-forge.d.ts +64 -0
  149. package/dist/pr-forge.d.ts.map +1 -1
  150. package/dist/pr-forge.js +331 -44
  151. package/dist/pr-loop.d.ts +107 -1
  152. package/dist/pr-loop.d.ts.map +1 -1
  153. package/dist/pr-loop.js +84 -3
  154. package/dist/pr-post.d.ts +72 -0
  155. package/dist/pr-post.d.ts.map +1 -0
  156. package/dist/pr-post.js +134 -0
  157. package/dist/pr-requires.d.ts +38 -0
  158. package/dist/pr-requires.d.ts.map +1 -0
  159. package/dist/pr-requires.js +48 -0
  160. package/dist/wave-blocked.d.ts +79 -0
  161. package/dist/wave-blocked.d.ts.map +1 -0
  162. package/dist/wave-blocked.js +245 -0
  163. package/dist/wave-engine.d.ts +6 -0
  164. package/dist/wave-engine.d.ts.map +1 -1
  165. package/dist/wave-engine.js +2 -0
  166. package/dist/wave-runtime.mjs +243 -243
  167. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  168. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  169. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  170. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  171. package/dist/workspace/checks/ci-workflow.js +19 -4
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-init.d.ts.map +1 -1
  179. package/dist/workspace/ci-init.js +5 -4
  180. package/dist/workspace/ci-locks.d.ts +12 -0
  181. package/dist/workspace/ci-locks.d.ts.map +1 -0
  182. package/dist/workspace/ci-locks.js +6 -0
  183. package/dist/workspace/ci-run.d.ts +17 -1
  184. package/dist/workspace/ci-run.d.ts.map +1 -1
  185. package/dist/workspace/ci-run.js +66 -4
  186. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  187. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  188. package/dist/workspace/ci-workflow-at.js +17 -0
  189. package/dist/workspace/ci-workflow.d.ts +11 -3
  190. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  191. package/dist/workspace/ci-workflow.js +10 -2
  192. package/dist/workspace/declaration.d.ts +69 -3
  193. package/dist/workspace/declaration.d.ts.map +1 -1
  194. package/dist/workspace/declaration.js +72 -1
  195. package/dist/workspace/declaration.schema.json +149 -2
  196. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  197. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  198. package/dist/workspace/identity-gate-rule.js +1 -1
  199. package/dist/workspace/identity.d.ts +1 -1
  200. package/dist/workspace/identity.d.ts.map +1 -1
  201. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  202. package/dist/workspace/lineage-upgrade.js +8 -5
  203. package/dist/workspace/member-pipeline.d.ts +6 -0
  204. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  205. package/dist/workspace/member-pipeline.js +23 -0
  206. package/dist/workspace/pr-report.schema.json +94 -3
  207. package/dist/workspace/reason-codes.d.ts +1 -1
  208. package/dist/workspace/reason-codes.d.ts.map +1 -1
  209. package/dist/workspace/reason-codes.js +1 -1
  210. package/dist/workspace/security.d.ts.map +1 -1
  211. package/dist/workspace/security.js +4 -0
  212. package/dist/workspace/status-gates.d.ts +3 -2
  213. package/dist/workspace/status-gates.d.ts.map +1 -1
  214. package/dist/workspace/status-gates.js +8 -7
  215. package/dist/workspace/status.d.ts.map +1 -1
  216. package/dist/workspace/status.js +4 -1
  217. package/dist/workspace/trust/seal.d.ts +2 -2
  218. package/dist/workspace/trust/seal.d.ts.map +1 -1
  219. package/dist/workspace/trust/seal.js +2 -2
  220. package/package.json +1 -1
  221. package/src/__fixtures__/oidc-emulator.ts +118 -0
  222. package/src/ci-handover.test.ts +209 -0
  223. package/src/ci-handover.ts +237 -0
  224. package/src/ci-identity.test.ts +201 -0
  225. package/src/ci-identity.ts +310 -0
  226. package/src/cli/handlers/approve.ts +18 -5
  227. package/src/cli/handlers/build.ts +18 -1
  228. package/src/cli/handlers/fan-out.ts +43 -6
  229. package/src/cli/handlers/gate-resume.ts +16 -2
  230. package/src/cli/handlers/lock.test.ts +98 -0
  231. package/src/cli/handlers/lock.ts +202 -0
  232. package/src/cli/handlers/operator.test.ts +3 -1
  233. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  234. package/src/cli/handlers/pr-env.ts +462 -0
  235. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  236. package/src/cli/handlers/pr.test.ts +184 -0
  237. package/src/cli/handlers/pr.ts +605 -61
  238. package/src/cli/handlers/promote.ts +9 -0
  239. package/src/cli/handlers/query.ts +78 -0
  240. package/src/cli/handlers/run.ts +10 -0
  241. package/src/cli/main.ts +49 -4
  242. package/src/cli/parse-args.ts +34 -2
  243. package/src/cli/registry.ts +31 -2
  244. package/src/components/capability-targets.test.ts +162 -0
  245. package/src/components/capability.ts +71 -0
  246. package/src/components/cli-support.ts +14 -1
  247. package/src/components/driver.ts +54 -3
  248. package/src/components/fan-out-run.ts +58 -3
  249. package/src/components/fan-out-waves.test.ts +45 -1
  250. package/src/components/gate-plan.ts +3 -0
  251. package/src/components/pr-environment.test.ts +249 -0
  252. package/src/components/pr-environment.ts +538 -0
  253. package/src/components/pr-hooks.test.ts +118 -0
  254. package/src/components/pr-hooks.ts +157 -0
  255. package/src/components/pr-pipeline.ts +194 -21
  256. package/src/components/pr-policy.test.ts +145 -0
  257. package/src/components/pr-policy.ts +211 -0
  258. package/src/components/pr-run.ts +14 -2
  259. package/src/components/wave-plan.ts +38 -3
  260. package/src/gate-decision-bundle.test.ts +4 -3
  261. package/src/gate-decision.ts +39 -2
  262. package/src/gate-policy-bundle.test.ts +28 -0
  263. package/src/gate-policy.test.ts +162 -0
  264. package/src/gate-policy.ts +298 -0
  265. package/src/lifecycle/gate-checkpoint.ts +1 -1
  266. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  267. package/src/lifecycle/query.test.ts +91 -0
  268. package/src/lifecycle/query.ts +315 -0
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/target-lock.test.ts +195 -0
  271. package/src/lifecycle/target-lock.ts +347 -0
  272. package/src/op/activities/activity-contracts.ts +31 -0
  273. package/src/op/activities/index.ts +6 -0
  274. package/src/op/activities/pr-refs.ts +28 -0
  275. package/src/op/activities/predict-behaviour.ts +3 -23
  276. package/src/op/activities/review-agent.test.ts +240 -0
  277. package/src/op/activities/review-agent.ts +355 -0
  278. package/src/op/builders.ts +13 -0
  279. package/src/op/cron.ts +44 -0
  280. package/src/op/gate-alias.test.ts +2 -2
  281. package/src/op/gate-approval.test.ts +30 -9
  282. package/src/op/gate-approval.ts +23 -26
  283. package/src/op/gate-quorum.test.ts +139 -0
  284. package/src/op/gate-quorum.ts +248 -0
  285. package/src/op/gate-resume.ts +6 -3
  286. package/src/op/gate-review.ts +1 -1
  287. package/src/op/gate.ts +64 -23
  288. package/src/op/index.ts +13 -1
  289. package/src/op/local-executor.ts +9 -2
  290. package/src/op/op-waves-hook-targets.test.ts +126 -0
  291. package/src/op/op-waves-policy-hold.test.ts +66 -0
  292. package/src/op/op-waves-quorum.test.ts +101 -0
  293. package/src/op/op-waves-run.ts +197 -183
  294. package/src/op/op-waves.ts +83 -10
  295. package/src/op/schedule-overdue.test.ts +220 -0
  296. package/src/op/schedule-overdue.ts +161 -0
  297. package/src/op/stage-hooks.ts +251 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +347 -35
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +170 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/pr-requires.test.ts +184 -0
  309. package/src/pr-requires.ts +79 -0
  310. package/src/review-agent-bundle.test.ts +35 -0
  311. package/src/stage-hooks-bundle.test.ts +38 -0
  312. package/src/wave-blocked-bundle.test.ts +25 -0
  313. package/src/wave-blocked.test.ts +146 -0
  314. package/src/wave-blocked.ts +271 -0
  315. package/src/wave-engine.ts +8 -0
  316. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  317. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  318. package/src/workspace/checks/ci-workflow.ts +18 -4
  319. package/src/workspace/ci-branches.test.ts +62 -0
  320. package/src/workspace/ci-branches.ts +79 -0
  321. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  322. package/src/workspace/ci-cloud-identity.ts +23 -0
  323. package/src/workspace/ci-init.ts +5 -4
  324. package/src/workspace/ci-locks.ts +16 -0
  325. package/src/workspace/ci-run-index.test.ts +2 -1
  326. package/src/workspace/ci-run.test.ts +107 -8
  327. package/src/workspace/ci-run.ts +74 -5
  328. package/src/workspace/ci-workflow-at.ts +23 -0
  329. package/src/workspace/ci-workflow.ts +13 -2
  330. package/src/workspace/declaration.schema.json +149 -2
  331. package/src/workspace/declaration.test.ts +76 -0
  332. package/src/workspace/declaration.ts +140 -5
  333. package/src/workspace/identity-gate-rule.ts +1 -1
  334. package/src/workspace/identity.ts +1 -1
  335. package/src/workspace/lineage-upgrade.ts +11 -8
  336. package/src/workspace/member-pipeline.test.ts +29 -0
  337. package/src/workspace/member-pipeline.ts +30 -0
  338. package/src/workspace/pr-report.schema.json +94 -3
  339. package/src/workspace/reason-codes.ts +1 -1
  340. package/src/workspace/security.ts +4 -0
  341. package/src/workspace/status-gates.ts +9 -6
  342. package/src/workspace/status.ts +4 -1
  343. package/src/workspace/trust/gate-seal.test.ts +1 -1
  344. package/src/workspace/trust/seal.ts +2 -2
  345. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  346. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -68,11 +68,16 @@ import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger"
68
68
  import type { GatePointAsker } from "../point-decision";
69
69
  import type { WaitingPoint } from "./steward-points";
70
70
  import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
71
+ import { samePlanDigest } from "../lifecycle/plan-digest";
71
72
  import { isLifecycleLocalNote } from "../lifecycle/local-note";
72
73
  import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
73
74
  import type { TrustPolicy } from "../workspace/trust/policy";
74
- import type { GateSigners } from "../workspace/trust/gate-seal";
75
+ import type { GateSigners } from "../lifecycle/gate-seal";
75
76
  import { ciRunIdFromEnv } from "./gate-run-locator";
77
+ import {
78
+ quorumProblems, quorumRuleLine, quorumRulesFor, quorumWaiting, ruleNeedsProof, samePrincipal, trustQuorumJudge,
79
+ type GateQuorumRule, type GateQuorumRuleProgress,
80
+ } from "./gate-quorum";
76
81
  import {
77
82
  OP_WAVE_DEFAULT_APPLY,
78
83
  OP_WAVE_GATE_POLICIES,
@@ -83,6 +88,7 @@ import {
83
88
  opWaveApplyHooksRunBy,
84
89
  opWaveGateName,
85
90
  opWaveHooksAt,
91
+ opWaveHookRuns,
86
92
  opWavePointAt,
87
93
  opWavePointProblems,
88
94
  opWaveRecordPath,
@@ -99,6 +105,26 @@ import {
99
105
  type OpWaveRun,
100
106
  type OpWavesSpec,
101
107
  } from "./op-waves";
108
+ import {
109
+ OP_WAVE_PENDING_RESERVED,
110
+ opWaveArgv,
111
+ pendingFields,
112
+ readHoldRequest,
113
+ runHookList,
114
+ runStageHooks,
115
+ spawnExec,
116
+ type HookPlace,
117
+ type OpWaveExec,
118
+ type OpWaveHoldRequest,
119
+ type OpWaveHookOutcome,
120
+ type OpWaveHookRun,
121
+ type OtherGateHold,
122
+ type RunStageHooksOptions,
123
+ type StageHooksRun,
124
+ } from "./stage-hooks";
125
+
126
+ export { OP_WAVE_PENDING_RESERVED, opWaveArgv, runStageHooks };
127
+ export type { OpWaveExec, OpWaveHoldRequest, OpWaveHookOutcome, OpWaveHookRun, RunStageHooksOptions, StageHooksRun };
102
128
 
103
129
  /** What a run's plan command writes to `{plan}`. */
104
130
  export interface OpWavePlanFile {
@@ -113,6 +139,13 @@ export interface OpWavePlanFile {
113
139
  * plan digest and these rules counts (`@intentius/chant/override-gate`).
114
140
  */
115
141
  denied?: { rules: string[]; reason?: string };
142
+ /**
143
+ * The product's policy holds this plan for a person (#4294): the ids of
144
+ * the hold rules that matched. The wave waits for an approval of its set
145
+ * digest whatever its gate policy says, as a hook with `onFailure:
146
+ * approve` holds it. `@intentius/chant/gate-policy` gives the rules.
147
+ */
148
+ held?: { rules: string[]; reason?: string };
116
149
  }
117
150
 
118
151
  /** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
@@ -141,12 +174,6 @@ export interface OpWavePlansFile {
141
174
  waves?: number;
142
175
  }
143
176
 
144
- /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
145
- export const OP_WAVE_PENDING_RESERVED = [
146
- "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
147
- "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer", "wave", "heldBy",
148
- ] as const;
149
-
150
177
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
151
178
  export interface OpWaveRunsFile {
152
179
  /** The wave's name. */
@@ -170,6 +197,8 @@ export interface OpWaveMember extends WaveMember {
170
197
  denied?: { rules: string[]; reason?: string };
171
198
  /** The override that lets the denied plan through (#3821). */
172
199
  override?: { by: string; at: string; digest: string; reason: string };
200
+ /** The product's policy holds the plan for a person (#4294). */
201
+ held?: { rules: string[]; reason?: string };
173
202
  }
174
203
 
175
204
  /** Where a wave's policy came from. */
@@ -207,6 +236,10 @@ export interface OpWaveDecision {
207
236
  point?: OpWavePoint & { question?: WaitingPoint; answer?: GateAnswerRef };
208
237
  /** The review that approved the wave, or why the review did not, under `pr-review`. */
209
238
  review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
239
+ /** The approval rules at base the wave's targets take (#4290). Absent when it takes none. */
240
+ quorum?: GateQuorumRule[];
241
+ /** Where each of those rules stands (#4290), while the wave waits. */
242
+ quorumProgress?: GateQuorumRuleProgress[];
210
243
  /** The command that approves this digest, when waiting. */
211
244
  approve?: string;
212
245
  /** The hooks read at base (#3762), which a wide wave's share jobs run too. Absent when there are none. */
@@ -286,18 +319,6 @@ export interface OpWaveApplyOutcome {
286
319
 
287
320
  export const OP_WAVE_APPLY_OUTCOMES = ["stood-down", "held", "replan"] as const;
288
321
 
289
- /** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
290
- export interface OpWaveHoldRequest {
291
- /** The op the gate is recorded under. Default: the spec's name. */
292
- op?: string;
293
- gate: string;
294
- /** The digest an approval binds. */
295
- digest: string;
296
- description?: string;
297
- /** Fields for the pending fact, as a plans file's `pending`. */
298
- pending?: Record<string, unknown>;
299
- }
300
-
301
322
  /** A hold on another gate, as the decision records it (#3821). */
302
323
  export interface OpWaveHeldOn {
303
324
  op: string;
@@ -328,26 +349,6 @@ export interface OpWaveHeldOnApproval {
328
349
  approvedAt: string;
329
350
  }
330
351
 
331
- /** One hook's run (#3762). */
332
- export interface OpWaveHookOutcome {
333
- moment: OpWaveHookMoment;
334
- name: string;
335
- /** The run, for a `perRun` hook. */
336
- target?: string;
337
- exit: number;
338
- outcome: "passed" | "failed" | "held" | "skipped";
339
- /** Why the moment was skipped, for `skipped` (#3811). */
340
- reason?: string;
341
- }
342
-
343
- /** One run as a hook sees it (#3804). */
344
- export interface OpWaveHookRun {
345
- target: string;
346
- op: string;
347
- /** The run's plan file, once planned. */
348
- plan?: string;
349
- }
350
-
351
352
  /**
352
353
  * What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
353
354
  * {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
@@ -423,15 +424,6 @@ export interface OpWaveApplyHooksRun {
423
424
  proceed: boolean;
424
425
  }
425
426
 
426
- /** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
427
- export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
428
-
429
- const spawnExec: OpWaveExec = (argv, cwd, env) => {
430
- const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
431
- if (result.error) throw result.error;
432
- return result.status ?? 1;
433
- };
434
-
435
427
  /** Reads one file at one commit, or null when the commit has no such file. Injected for tests. */
436
428
  export type OpWaveShowAtBase = (base: string, path: string, cwd: string) => { sha: string; text: string | null };
437
429
 
@@ -489,6 +481,10 @@ export interface OpWaveBaseRules {
489
481
  point?: OpWavePoint;
490
482
  /** Set when the point at base cannot be asked: the wave fails rather than fall back to the ledger (#3836). */
491
483
  pointRefused?: string;
484
+ /** The approval rules at base (#4290), `approvals.quorum`. */
485
+ quorum?: GateQuorumRule[];
486
+ /** Set when the rules at base cannot be read: the wave fails rather than wait on fewer approvers than they ask. */
487
+ quorumRefused?: string;
492
488
  }
493
489
 
494
490
  const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
@@ -521,7 +517,7 @@ export function readOpWavePolicy(
521
517
  const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
522
518
  const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
523
519
  // #3836: the gate's decision point, the spec's for a wave the base does not have yet.
524
- const asks = pointAtBase(spec, name, `${specFile} at ${sha.slice(0, 12)}`);
520
+ const asks = { ...pointAtBase(spec, name, `${specFile} at ${sha.slice(0, 12)}`), ...quorumAtBase(spec, `${specFile} at ${sha.slice(0, 12)}`) };
525
521
  const wave = spec.waves.find((w) => w.name === name);
526
522
  if (!wave) return { ...absent, spend, neverOverMcp, ...asks };
527
523
  const policy = wave.gate ?? "always";
@@ -540,6 +536,15 @@ export function readOpWavePolicy(
540
536
  return rules;
541
537
  }
542
538
 
539
+ /** The approval rules at base (#4290), or why they cannot be read. A spec at base with none has none. */
540
+ function quorumAtBase(spec: OpWavesSpec, where: string): Pick<OpWaveBaseRules, "quorum" | "quorumRefused"> {
541
+ const raw = (spec as { approvals?: { quorum?: unknown } }).approvals?.quorum;
542
+ if (raw === undefined) return {};
543
+ const problems = Array.isArray(raw) ? quorumProblems(raw, "approvals.quorum", { notAuthor: true }) : ["`approvals.quorum` must be a list of rules"];
544
+ if (problems.length > 0) return { quorumRefused: `the approval rules in ${where} cannot be read (${problems.join("; ")})` };
545
+ return (raw as GateQuorumRule[]).length > 0 ? { quorum: raw as GateQuorumRule[] } : {};
546
+ }
547
+
543
548
  /** The point wave `name` asks at base (#3836), or why it cannot be asked. */
544
549
  function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWaveBaseRules, "point" | "pointRefused"> {
545
550
  const raw = spec.waves.find((w) => w.name === name)?.point ?? spec.point;
@@ -549,22 +554,6 @@ function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWav
549
554
  return { point: opWavePointAt(spec, name)! };
550
555
  }
551
556
 
552
- /** Fill one argv template. A placeholder whose value is not given is left as it is. */
553
- export function opWaveArgv(
554
- template: readonly string[],
555
- values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
556
- ): string[] {
557
- const pairs: Array<[string, string | undefined]> = [
558
- ["{op}", values.op],
559
- ["{target}", values.target],
560
- ["{wave}", values.wave],
561
- ["{plans}", values.plans],
562
- ["{plan}", values.plan],
563
- ["{runs}", values.runs],
564
- ];
565
- return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
566
- }
567
-
568
557
  /** Where a run's plan file goes. */
569
558
  export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, target: string): string {
570
559
  return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
@@ -674,11 +663,12 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
674
663
  }
675
664
 
676
665
  function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
677
- const denied = file.denied as unknown;
678
- if (denied !== undefined) {
679
- const d = denied as { rules?: unknown; reason?: unknown } | null;
666
+ for (const key of ["denied", "held"] as const) {
667
+ const value = file[key] as unknown;
668
+ if (value === undefined) continue;
669
+ const d = value as { rules?: unknown; reason?: unknown } | null;
680
670
  if (!d || typeof d !== "object" || !Array.isArray(d.rules) || d.rules.length === 0 || !d.rules.every((r) => typeof r === "string" && r.trim() !== "") || (d.reason !== undefined && typeof d.reason !== "string")) {
681
- throw new Error(`the plan of ${target} has denied ${JSON.stringify(denied)}; a denial is { "rules": ["<rule id>", ...], "reason"?: "<text>" }`);
671
+ throw new Error(`the plan of ${target} has ${key} ${JSON.stringify(value)}; ${key === "denied" ? "a denial" : "a hold"} is { "rules": ["<rule id>", ...], "reason"?: "<text>" }`);
682
672
  }
683
673
  }
684
674
  return {
@@ -688,6 +678,7 @@ function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember
688
678
  ...(file.destructive === true ? { destructive: true } : {}),
689
679
  ...(file.empty === true ? { empty: true } : {}),
690
680
  ...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
681
+ ...(file.held ? { held: { rules: [...new Set(file.held.rules)].sort(), ...(file.held.reason ? { reason: file.held.reason } : {}) } } : {}),
691
682
  };
692
683
  }
693
684
 
@@ -768,7 +759,7 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
768
759
  const members = runs.map((run) => {
769
760
  const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
770
761
  const path = join(cwd, opWavePlanPath(spec, k, run.target));
771
- writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}) }) + "\n");
762
+ writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}), ...(m.held ? { held: m.held } : {}) }) + "\n");
772
763
  return m;
773
764
  });
774
765
  const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
@@ -819,16 +810,6 @@ function extraMembers(value: unknown, targets: ReadonlySet<string>, where: strin
819
810
  return out.length > 0 ? out : undefined;
820
811
  }
821
812
 
822
- /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
823
- function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
824
- if (value === undefined) return undefined;
825
- if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
826
- const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
827
- if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
828
- if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
829
- return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
830
- }
831
-
832
813
  /**
833
814
  * A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
834
815
  * or under `digest.over: "changing"` over the runs whose plan is not empty,
@@ -849,107 +830,6 @@ interface HooksOutcome {
849
830
  waitingOn?: OpWaveHeldOn;
850
831
  }
851
832
 
852
- /** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
853
- interface OtherGateHold {
854
- label: string;
855
- name: string;
856
- moment: OpWaveHookMoment;
857
- target?: string;
858
- argv: string[];
859
- env: Record<string, string>;
860
- request: OpWaveHoldRequest;
861
- }
862
-
863
- /** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
864
- function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
865
- if (!existsSync(path)) return undefined;
866
- try {
867
- const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
868
- if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
869
- if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
870
- return { gate: r.gate, digest: r.digest, ...(r.op ? { op: r.op } : {}), ...(typeof r.description === "string" ? { description: r.description } : {}), ...(r.pending ? { pending: pendingFields(r.pending, `the hold ${label} wrote`) ?? {} } : {}) };
871
- } catch (err) {
872
- log(`${label} wrote a hold file that cannot be read (${err instanceof Error ? err.message : String(err)}), so it holds the wave on its set digest`);
873
- return undefined;
874
- }
875
- }
876
-
877
- /** Where hooks run: the wave they belong to and the job's tools. */
878
- interface HookPlace {
879
- /** The spec's name and `op`. */
880
- name: string;
881
- op: string;
882
- waveName: string;
883
- k: number;
884
- cwd: string;
885
- exec: OpWaveExec;
886
- log: (line: string) => void;
887
- }
888
-
889
- /**
890
- * Run one moment's hooks, in order: each once for the wave, or once per run
891
- * for a `perRun` hook. A hook that fails stops the rest, unless its
892
- * `onFailure` is `approve`: then it holds the wave and the hooks after it run.
893
- */
894
- function runHookList(
895
- at: HookPlace,
896
- moment: OpWaveHookMoment,
897
- hooks: readonly OpWaveHook[] | undefined,
898
- runs: readonly OpWaveHookRun[],
899
- runsPath: string,
900
- extraEnv: Record<string, string> = {},
901
- ): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
902
- const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
903
- if (!hooks || hooks.length === 0) return out;
904
- const { name: specName, op, waveName, k, cwd, exec, log } = at;
905
- // #3821: a hook that holds the wave on another gate names it in this file.
906
- const holdPath = `.chant/op-waves/${specName}/wave-${k}/hold.json`;
907
- const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
908
- for (const hook of hooks) {
909
- const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
910
- const calls = hook.perRun
911
- ? runs.map((run) => ({
912
- target: run.target as string | undefined,
913
- argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
914
- env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
915
- }))
916
- : [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
917
- for (const call of calls) {
918
- const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
919
- let code: number;
920
- rmSync(join(cwd, holdPath), { force: true });
921
- try {
922
- code = exec(call.argv, cwd, call.env);
923
- } catch (err) {
924
- log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
925
- code = 127;
926
- }
927
- const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
928
- if (code === 0) {
929
- out.outcomes.push({ ...base, outcome: "passed" });
930
- continue;
931
- }
932
- if (hook.onFailure === "approve") {
933
- out.outcomes.push({ ...base, outcome: "held" });
934
- const request = readHoldRequest(join(cwd, holdPath), log, label);
935
- if (request) {
936
- out.otherGates.push({ label, name, moment, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
937
- log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
938
- continue;
939
- }
940
- out.holds.push(label);
941
- log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
942
- continue;
943
- }
944
- out.outcomes.push({ ...base, outcome: "failed" });
945
- out.failed = `${label} exited ${code}`;
946
- log(out.failed);
947
- return out;
948
- }
949
- }
950
- return out;
951
- }
952
-
953
833
  const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx.spec.op, waveName: ctx.wave.name, k: ctx.k, cwd: ctx.cwd, exec: ctx.exec, log: ctx.log });
954
834
 
955
835
  const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
@@ -1034,6 +914,18 @@ async function runHooks(
1034
914
  return back;
1035
915
  }
1036
916
 
917
+ /**
918
+ * The hooks that run before a job plans (#4288): `before-observe`, then
919
+ * `before-plan`. A failure or a hold on another gate in the first ends it
920
+ * there; the holds of both add up.
921
+ */
922
+ async function runBeforePlanHooks(ctx: WaveContext, hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> | undefined, runs: readonly OpWaveRun[]): Promise<HooksOutcome> {
923
+ const observe = await runHooks(ctx, "before-observe", hooks?.["before-observe"], runs);
924
+ if (observe.failed || observe.waitingOn) return observe;
925
+ const plan = await runHooks(ctx, "before-plan", hooks?.["before-plan"], runs);
926
+ return { ...plan, holds: [...observe.holds, ...plan.holds] };
927
+ }
928
+
1037
929
  /** How many gates one hook call holds a wave on in turn (#3847): the first hold and the holds its approved re-runs ask for. */
1038
930
  export const OP_WAVE_MAX_CHAINED_HOLDS = 10;
1039
931
 
@@ -1396,7 +1288,7 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
1396
1288
  log(`${part} of wave ${k} (${wave.name}) holds none of the wave's runs; nothing to apply`);
1397
1289
  return { end: result };
1398
1290
  }
1399
- const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
1291
+ const before = await runBeforePlanHooks(ctx, decision.hooks, slice);
1400
1292
  if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in ${part} of wave ${k} was planned or applied`) };
1401
1293
  if (before.waitingOn) return { end: await waitOnOther(ctx, decision, before.waitingOn) };
1402
1294
  const { members } = planRuns(ctx, slice);
@@ -1415,6 +1307,12 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
1415
1307
  noteHooksAtBase(ctx, rules);
1416
1308
  // #3836: a point at base that cannot be asked fails the wave; the ledger never stands in for it.
1417
1309
  if (rules.pointRefused) return { end: failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`) };
1310
+ // #4290: rules at base that cannot be read fail the wave; fewer approvers never stand in for them.
1311
+ if (rules.quorumRefused) return { end: failed(ctx, `wave ${k} (${wave.name}): ${rules.quorumRefused}, so nothing in it was planned or applied`) };
1312
+ if (rules.quorum && rules.point) return { end: failed(ctx, `wave ${k} (${wave.name}): approvals.quorum and a decision point are both set at ${base.slice(0, 12)}; a gate that asks a point takes its deciders from the point, so nothing in it was planned or applied`) };
1313
+ if (JSON.stringify(spec.approvals?.quorum ?? null) !== JSON.stringify(rules.quorum ?? null)) {
1314
+ log(`wave ${k} (${wave.name}): its approval rules are read at ${base.slice(0, 12)}, and this change's differ; the ones at base hold`);
1315
+ }
1418
1316
  const own = opWavePointAt(spec, wave.name);
1419
1317
  if (JSON.stringify(own) !== JSON.stringify(rules.point)) {
1420
1318
  log(`wave ${k} (${wave.name}): its gate's decision point is read at ${base.slice(0, 12)} (${rules.point?.name ?? "none"}), and this change's differs (${own?.name ?? "none"}); the one at base holds`);
@@ -1445,7 +1343,7 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
1445
1343
  ...(neverOverMcp ? { neverOverMcp: true as const } : {}),
1446
1344
  ...(rules.point ? { point: { ...rules.point } } : {}),
1447
1345
  };
1448
- const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
1346
+ const before = await runBeforePlanHooks(ctx, rules.hooks, wave.runs);
1449
1347
  if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`) };
1450
1348
  if (before.waitingOn) return { end: await decided(ctx, decision, recordPath, before.waitingOn) };
1451
1349
  const { members, pending, overriders, overridersSource, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
@@ -1519,11 +1417,16 @@ async function decideOpWave(
1519
1417
  const { policy, source, base } = planned.rules;
1520
1418
  decision.digest = digest;
1521
1419
  decision.members = members;
1420
+ // #4290: the rules at base whose targets glob matches one of the wave's runs.
1421
+ const takes = planned.rules.quorum ? quorumRulesFor(planned.rules.quorum, members.map((m) => m.member)) : [];
1422
+ if (takes.length > 0) decision.quorum = takes;
1522
1423
  if (extras) decision.extras = extras;
1523
1424
  if (pending) decision.pending = pending;
1524
1425
  const after = await runHooks(ctx, "after-plan", planned.rules.hooks["after-plan"], wave.runs, planned.planned, digest);
1525
1426
  if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`) };
1526
1427
  holds.push(...planned.before, ...after.holds);
1428
+ // #4294: a run the product's policy holds makes the wave wait for a person, as a held hook does.
1429
+ for (const m of members) if (m.held) holds.push(`policy on ${m.member} (${m.held.rules.join(", ")})${m.held.reason ? `: ${m.held.reason}` : ""}`);
1527
1430
  if (holds.length > 0) decision.heldBy = holds;
1528
1431
  if (after.waitingOn) return { end: await decided(ctx, decision, recordPath, after.waitingOn) };
1529
1432
  // #3821: a run the product's policy denied applies only under an override; with none the wave is refused, and no approval is waited for.
@@ -1779,6 +1682,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
1779
1682
  */
1780
1683
  async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
1781
1684
  if (decision.point) return settlePointGate(options, ctx, decision, decision.point);
1685
+ if (decision.quorum?.length) return settleQuorumGate(options, ctx, decision, decision.quorum);
1782
1686
  const { spec, k, wave, cwd } = ctx;
1783
1687
  const { gate, digest, approval, base, policy, members } = decision;
1784
1688
  const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
@@ -1833,6 +1737,116 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1833
1737
  decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
1834
1738
  }
1835
1739
 
1740
+ /**
1741
+ * Decide a gate under approval rules (#4290): the rules at base the wave's
1742
+ * targets take, each met by distinct human approvers of the digest. Roles
1743
+ * come from `.chant/trust.json` at base, for an approver a seal proves, or
1744
+ * under `pr-review` the merged pull request's review read from the forge in
1745
+ * this run. That review is written to the ledger once per approving
1746
+ * reviewer, so each counts on their own. `notAuthor` reads the author from
1747
+ * the same pull request; with none, no approver can show they are someone
1748
+ * else and the rule waits.
1749
+ */
1750
+ async function settleQuorumGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision, rules: GateQuorumRule[]): Promise<void> {
1751
+ const { spec, k, wave, cwd, log } = ctx;
1752
+ const { gate, digest, approval, base, policy, members } = decision;
1753
+ const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
1754
+ const now = options.now ?? new Date().toISOString();
1755
+ const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
1756
+ const review = await import("./gate-review");
1757
+ const trust = (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base);
1758
+ const label = `wave ${k} (${wave.name})`;
1759
+
1760
+ // The merged pull request: its author for notAuthor, and its review under pr-review.
1761
+ let merged: MergedReview | null = null;
1762
+ let unknown: string | undefined;
1763
+ const asks = approval === "pr-review" || rules.some((r) => r.notAuthor);
1764
+ const head = asks ? (options.head ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).trim()) : "";
1765
+ if (asks) {
1766
+ try {
1767
+ merged = await (options.reviews ?? review.reviewSourceFromEnv()).mergedReview(head);
1768
+ if (!merged) unknown = `no merged pull request produced ${head.slice(0, 12)}`;
1769
+ } catch (err) {
1770
+ unknown = `the forge's review could not be read: ${err instanceof Error ? err.message : String(err)}`;
1771
+ }
1772
+ }
1773
+ if (unknown && rules.some((r) => r.notAuthor)) log(`${label}: ${unknown}, so the change's author is not known`);
1774
+ if (approval === "pr-review" && unknown) decision.review = { reason: unknown };
1775
+
1776
+ let reviewed: { head: string; approvers: string[] } | undefined;
1777
+ if (approval === "pr-review" && merged) {
1778
+ const plans = merged.approvers.length === 0 ? null : await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, cwd)))(spec.name, merged.head);
1779
+ const { reviewApproval } = await import("../gate-decision");
1780
+ const outcome = reviewApproval(merged, plans?.waves.find((w) => w.wave === k)?.digest, digest);
1781
+ decision.review = outcome.review;
1782
+ const reviewSpent = spent !== undefined && isApprovalSpent(spent, { gate, planDigest: digest, review: { head: merged.head } });
1783
+ if (reviewSpent) decision.review = { ...outcome.review, reason: `a wave already applied ${digest} under the review of #${merged.pr} at ${merged.head.slice(0, 12)}, so the review is spent` };
1784
+ if (outcome.approved && !reviewSpent) {
1785
+ reviewed = { head: merged.head, approvers: merged.approvers };
1786
+ // Each approving reviewer once, so the rules count them one by one.
1787
+ const had = (await ledger.read(spec.name)).resolutions.filter((r) => r.gate === gate && r.review?.head === merged!.head && samePlanDigest(r.planDigest, digest));
1788
+ const note = `approved by the review of #${merged.pr} at ${merged.head.slice(0, 12)}: ${merged.approvers.join(", ")}`;
1789
+ for (const who of merged.approvers) {
1790
+ if (had.some((r) => samePrincipal(r.resolvedBy, who))) continue;
1791
+ await ledger.appendResolution?.({
1792
+ op: spec.name,
1793
+ gate,
1794
+ resolvedBy: who,
1795
+ timestamp: now,
1796
+ planDigest: digest,
1797
+ approver: { kind: "human" },
1798
+ gitSha: head,
1799
+ ...(ciRunIdFromEnv() ? { runId: ciRunIdFromEnv()! } : {}),
1800
+ note,
1801
+ ...(merged.url ? { url: merged.url } : {}),
1802
+ review: { pr: merged.pr, head: merged.head, approvers: merged.approvers },
1803
+ });
1804
+ }
1805
+ }
1806
+ }
1807
+
1808
+ const judge = trustQuorumJudge({
1809
+ signers: trust,
1810
+ roles: trust.roles,
1811
+ ...(merged ? { authors: [merged.author] } : {}),
1812
+ ...(reviewed ? { review: reviewed } : {}),
1813
+ });
1814
+ const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
1815
+ const check = await decideWaveGate(port, {
1816
+ op: spec.name,
1817
+ gate,
1818
+ digest,
1819
+ description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
1820
+ now,
1821
+ ...(spent ? { spent } : {}),
1822
+ fields: { ...decision.pending, approvalMode: approval },
1823
+ ...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
1824
+ approval: { quorum: rules, mode: "log-only" },
1825
+ judge,
1826
+ });
1827
+ if (check.satisfied) {
1828
+ const by = (check.approvals ?? [check.resolution]).map((r) => r.resolvedBy);
1829
+ decision.status = "approved";
1830
+ decision.via = reviewed && check.resolution.review?.head === reviewed.head ? "pr-review" : "ledger";
1831
+ decision.approvedBy = by.join(", ");
1832
+ decision.approvedAt = check.resolution.timestamp;
1833
+ log(`${label}: the approval rules at ${base.slice(0, 12)} are met by ${by.join(", ")}`);
1834
+ return;
1835
+ }
1836
+ logRefused(log, label, check);
1837
+ decision.pendingFact = check.pending;
1838
+ decision.recorded = check.recorded;
1839
+ decision.waitingSince = check.pending.timestamp;
1840
+ if (check.mismatch) decision.mismatch = check.mismatch;
1841
+ if (check.quorum?.rules) {
1842
+ decision.quorumProgress = check.quorum.rules;
1843
+ for (const p of check.quorum.rules) log(`${label}: approval rule ${quorumRuleLine(p)}`);
1844
+ log(`${label}: the approval rules at ${base.slice(0, 12)} are not met (${quorumWaiting(check.quorum.rules)}), so it waits for more approvals of ${digest}`);
1845
+ }
1846
+ decision.status = "waiting";
1847
+ decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed" || rules.some(ruleNeedsProof));
1848
+ }
1849
+
1836
1850
  /**
1837
1851
  * Decide a gate that asks a decision point (#3836), as an Op gate with a
1838
1852
  * `point` is decided (#3170): ask it with `gate.planDigest` set to the set