@intentius/chant 0.122.0 → 0.124.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 (203) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  4. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts +1 -1
  6. package/dist/cli/handlers/operator.d.ts.map +1 -1
  7. package/dist/cli/handlers/override-approve.d.ts +17 -0
  8. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  9. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +4 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config-import.d.ts +1 -1
  14. package/dist/gate-decision.d.ts +157 -0
  15. package/dist/gate-decision.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-applied.d.ts +75 -0
  17. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-audit.d.ts +145 -0
  19. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  22. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  23. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/override-ledger.d.ts +34 -0
  25. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/rollback.d.ts.map +1 -1
  27. package/dist/lifecycle/work-item-id.d.ts +22 -0
  28. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  29. package/dist/lifecycle/work-lease.d.ts +2 -2
  30. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  31. package/dist/lint/policy-import.d.ts +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify-verify.js +76 -0
  37. package/dist/notify.d.ts +202 -0
  38. package/dist/notify.d.ts.map +1 -0
  39. package/dist/notify.js +307 -0
  40. package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
  41. package/dist/op/activities/activity-contracts.d.ts +4 -4
  42. package/dist/op/activities/gh-host.d.ts +37 -0
  43. package/dist/op/activities/gh-host.d.ts.map +1 -0
  44. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  45. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  46. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  47. package/dist/op/activities/reconcile.d.ts +74 -76
  48. package/dist/op/activities/reconcile.d.ts.map +1 -1
  49. package/dist/op/gate-approval.d.ts +17 -1
  50. package/dist/op/gate-approval.d.ts.map +1 -1
  51. package/dist/op/gate-name.d.ts +8 -1
  52. package/dist/op/gate-name.d.ts.map +1 -1
  53. package/dist/op/gate-resume.d.ts +4 -2
  54. package/dist/op/gate-resume.d.ts.map +1 -1
  55. package/dist/op/gate-review.d.ts +2 -1
  56. package/dist/op/gate-review.d.ts.map +1 -1
  57. package/dist/op/gate.d.ts +43 -6
  58. package/dist/op/gate.d.ts.map +1 -1
  59. package/dist/op/index.d.ts +8 -6
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/op-waves-notify.d.ts +31 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -0
  63. package/dist/op/op-waves-run.d.ts +218 -16
  64. package/dist/op/op-waves-run.d.ts.map +1 -1
  65. package/dist/op/op-waves.d.ts +113 -2
  66. package/dist/op/op-waves.d.ts.map +1 -1
  67. package/dist/op/operator.d.ts +15 -0
  68. package/dist/op/operator.d.ts.map +1 -1
  69. package/dist/op/work-lease-decl.d.ts.map +1 -1
  70. package/dist/op/work-lease-run.d.ts.map +1 -1
  71. package/dist/override-gate.d.ts +224 -0
  72. package/dist/override-gate.d.ts.map +1 -0
  73. package/dist/pr-forge.d.ts +225 -19
  74. package/dist/pr-forge.d.ts.map +1 -1
  75. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
  76. package/dist/workspace/conformance/broker.d.ts +1 -1
  77. package/dist/workspace/status-gates.d.ts +1 -0
  78. package/dist/workspace/status-gates.d.ts.map +1 -1
  79. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  80. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  81. package/dist/workspace/trust/policy.d.ts +2 -41
  82. package/dist/workspace/trust/policy.d.ts.map +1 -1
  83. package/dist/workspace/trust/seal.d.ts +2 -27
  84. package/dist/workspace/trust/seal.d.ts.map +1 -1
  85. package/dist/workspace/trust/trust-files.d.ts +57 -0
  86. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  87. package/dist/workspace/work-cli.d.ts.map +1 -1
  88. package/package.json +15 -2
  89. package/src/cli/commands/build.ts +1 -1
  90. package/src/cli/commands/check-lexicon.ts +3 -1
  91. package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
  92. package/src/cli/commands/lint.test.ts +4 -109
  93. package/src/cli/commands/lint.ts +5 -2
  94. package/src/cli/discovery-skip.e2e.test.ts +18 -3
  95. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  96. package/src/cli/handlers/gate-resume.ts +3 -2
  97. package/src/cli/handlers/operator.test.ts +12 -1
  98. package/src/cli/handlers/operator.ts +25 -13
  99. package/src/cli/handlers/override-approve.test.ts +87 -0
  100. package/src/cli/handlers/override-approve.ts +103 -0
  101. package/src/cli/handlers/run-wave.ts +10 -2
  102. package/src/cli/main.test.ts +8 -0
  103. package/src/cli/main.ts +15 -0
  104. package/src/cli/registry.ts +4 -0
  105. package/src/config-import.ts +1 -1
  106. package/src/discovery/sandbox/run.test.ts +1 -1
  107. package/src/gate-decision-bundle.test.ts +56 -0
  108. package/src/gate-decision.test.ts +237 -0
  109. package/src/gate-decision.ts +321 -0
  110. package/src/lifecycle/gate-applied.ts +115 -0
  111. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  112. package/src/lifecycle/gate-audit.test.ts +134 -0
  113. package/src/lifecycle/gate-audit.ts +438 -0
  114. package/src/lifecycle/gate-ledger-select.ts +113 -0
  115. package/src/lifecycle/gate-ledger.test.ts +22 -0
  116. package/src/lifecycle/gate-ledger.ts +9 -104
  117. package/src/lifecycle/override-ledger.test.ts +63 -0
  118. package/src/lifecycle/override-ledger.ts +69 -0
  119. package/src/lifecycle/rollback.ts +5 -1
  120. package/src/lifecycle/work-item-id.test.ts +71 -0
  121. package/src/lifecycle/work-item-id.ts +35 -0
  122. package/src/lifecycle/work-lease.ts +4 -5
  123. package/src/lint/policy-import.ts +1 -1
  124. package/src/lint/post-synth.test.ts +29 -0
  125. package/src/lint/post-synth.ts +21 -2
  126. package/src/notify-bundle.test.ts +50 -0
  127. package/src/notify-plain-node.e2e.test.ts +68 -0
  128. package/src/notify-verify.test.ts +59 -0
  129. package/src/notify-verify.ts +93 -0
  130. package/src/notify.schema.json +72 -0
  131. package/src/notify.test.ts +279 -0
  132. package/src/notify.ts +398 -0
  133. package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
  134. package/src/op/activities/gh-host.test.ts +130 -0
  135. package/src/op/activities/gh-host.ts +79 -0
  136. package/src/op/activities/lexicon-upgrade.ts +22 -1
  137. package/src/op/activities/propose-upgrade.ts +5 -1
  138. package/src/op/activities/reconcile.test.ts +284 -280
  139. package/src/op/activities/reconcile.ts +128 -131
  140. package/src/op/gate-approval.test.ts +175 -7
  141. package/src/op/gate-approval.ts +27 -1
  142. package/src/op/gate-name.ts +10 -2
  143. package/src/op/gate-resume.ts +6 -3
  144. package/src/op/gate-review.ts +4 -7
  145. package/src/op/gate.ts +117 -16
  146. package/src/op/index.ts +36 -5
  147. package/src/op/op-waves-notify.test.ts +127 -0
  148. package/src/op/op-waves-notify.ts +101 -0
  149. package/src/op/op-waves-run.ts +908 -123
  150. package/src/op/op-waves.test.ts +543 -3
  151. package/src/op/op-waves.ts +215 -6
  152. package/src/op/operator.test.ts +29 -0
  153. package/src/op/operator.ts +27 -2
  154. package/src/op/run-context.test.ts +2 -2
  155. package/src/op/work-lease-decl.ts +4 -4
  156. package/src/op/work-lease-run.test.ts +5 -0
  157. package/src/op/work-lease-run.ts +4 -3
  158. package/src/override-gate-bundle.test.ts +47 -0
  159. package/src/override-gate.test.ts +239 -0
  160. package/src/override-gate.ts +395 -0
  161. package/src/pr-forge-bundle.test.ts +40 -0
  162. package/src/pr-forge.test.ts +378 -0
  163. package/src/pr-forge.ts +546 -47
  164. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
  165. package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
  166. package/src/workspace/conformance/broker.ts +1 -1
  167. package/src/workspace/records-write-contract.test.ts +3 -2
  168. package/src/workspace/records-write.e2e.test.ts +1 -1
  169. package/src/workspace/status-gates.ts +7 -4
  170. package/src/workspace/trust/gate-seal.test.ts +156 -0
  171. package/src/workspace/trust/gate-seal.ts +254 -0
  172. package/src/workspace/trust/policy.ts +10 -165
  173. package/src/workspace/trust/seal.ts +19 -34
  174. package/src/workspace/trust/trust-files.test.ts +50 -0
  175. package/src/workspace/trust/trust-files.ts +245 -0
  176. package/src/workspace/work-cli.ts +5 -3
  177. package/src/workspace/work-history.test.ts +7 -0
  178. /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
  179. /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
  180. /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
  181. /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
  182. /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
  183. /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
  184. /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
  185. /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
  186. /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
  187. /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
  188. /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
  189. /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
  190. /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
  191. /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
  192. /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
  193. /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
  194. /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
  195. /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
  196. /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
  197. /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
  198. /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
  199. /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
  200. /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
  201. /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
  202. /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
  203. /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
@@ -4,7 +4,12 @@
4
4
  * decides its gate and applies it.
5
5
  *
6
6
  * - Plan: each run's `plan` command writes a plan file; its `planDigest` is
7
- * the run's member digest, named by its target.
7
+ * the run's member digest, named by its target. With `planWave` (#3762)
8
+ * one call plans every run and lists each run's plan; the digest and the
9
+ * decision are the same as from per-run plans.
10
+ * - Hooks (#3762): read at base with the policy, run before and after the
11
+ * plan and the apply. One with `onFailure: approve` that fails holds the
12
+ * wave: it waits for an approval of its set digest whatever its policy.
8
13
  * - Decide: the wave's policy comes from the spec file at the base commit
9
14
  * (`git show <base>:<file>`), so a change cannot loosen its own gate. A
10
15
  * wave missing there, or a file missing there, is `always`. A wave that
@@ -16,27 +21,50 @@
16
21
  * Under `pr-review` the pull request's pipeline runs `--record-plans`
17
22
  * ({@link recordOpWaveHeadPlans}), which plans every wave at the head and
18
23
  * records the digests a review of that head approves.
19
- * - Apply: each run's `apply` command. A share job (`--share i`) re-plans its
24
+ * - Spend (#3784): with `approvals.spend` at base, an approval a wave applied
25
+ * under counts for nothing, and the wave records each approval it applies
26
+ * under in `_gates/<name>/applied.jsonl` (`../lifecycle/gate-applied.ts`)
27
+ * just before it applies (a wide wave's deciding job, before its shares).
28
+ * - Apply: each run's `apply` command, or one `applyWave` call for the
29
+ * approved set (#3762). A share job (`--share i`) re-plans its
20
30
  * slice first and refuses, without applying it, a run whose plan digest is
21
31
  * not the one the deciding job recorded (exit 4).
32
+ * - Hooks file (#3804): every apply call gets `CHANT_HOOKS`, a JSON file
33
+ * ({@link OpWaveHooksFile}) with the outcomes of the hooks that already ran
34
+ * and whether an approval stands. Under `hooks.apply.runBy: "apply"` (read
35
+ * at base) chant does not run the apply-stage hooks itself: the file hands
36
+ * them to the apply, which runs them under its own lock with
37
+ * {@link runOpWaveApplyHooks}, and their outcomes come back through
38
+ * `CHANT_HOOKS_RESULT`. A non-wave apply reads the same hooks at base with
39
+ * {@link opWaveHooksAtBase}.
22
40
  */
23
41
 
24
42
  import { execFileSync, spawnSync } from "node:child_process";
25
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
- import { dirname, join, posix } from "node:path";
43
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
+ import { dirname, isAbsolute, join, posix } from "node:path";
27
45
  import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
46
+ import type { ReviewApproval } from "../gate-decision";
28
47
  import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
48
+ import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
29
49
  import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
30
50
  import type { TrustPolicy } from "../workspace/trust/policy";
31
51
  import {
32
52
  OP_WAVE_DEFAULT_APPLY,
33
53
  OP_WAVE_GATE_POLICIES,
54
+ OP_WAVE_HOOK_MOMENTS,
34
55
  GATE_APPROVAL_SOURCES,
56
+ assertOpWaveHooks,
35
57
  assertOpWavesSpec,
58
+ opWaveApplyHooksRunBy,
59
+ opWaveHooksAt,
36
60
  opWaveRecordPath,
37
61
  opWaveShare,
38
62
  type GateApprovalSource,
63
+ type OpWave,
64
+ type OpWaveApplyHooksRunner,
39
65
  type OpWaveGatePolicy,
66
+ type OpWaveHook,
67
+ type OpWaveHookMoment,
40
68
  type OpWaveRun,
41
69
  type OpWavesSpec,
42
70
  } from "./op-waves";
@@ -50,6 +78,23 @@ export interface OpWavePlanFile {
50
78
  empty?: boolean;
51
79
  }
52
80
 
81
+ /** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
82
+ export interface OpWavePlansFile {
83
+ runs: Array<OpWavePlanFile & { target: string }>;
84
+ }
85
+
86
+ /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
87
+ export interface OpWaveRunsFile {
88
+ /** The wave's name. */
89
+ wave: string;
90
+ /** 1-based. */
91
+ number: number;
92
+ /** The wave's set digest, once it is decided. On `applyWave`, the digest the set was approved or let through at. */
93
+ digest?: string;
94
+ /** Each run, with its plan once planned. On `applyWave`, the runs to apply. */
95
+ runs: Array<{ target: string; op: string; planDigest?: string; destructive?: boolean; empty?: boolean }>;
96
+ }
97
+
53
98
  /** One run's plan, as the decision records it. */
54
99
  export interface OpWaveMember extends WaveMember {
55
100
  op: string;
@@ -84,13 +129,113 @@ export interface OpWaveDecision {
84
129
  review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
85
130
  /** The command that approves this digest, when waiting. */
86
131
  approve?: string;
132
+ /** The hooks read at base (#3762), which a wide wave's share jobs run too. Absent when there are none. */
133
+ hooks?: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
134
+ /** The `onFailure: approve` hooks that failed: the wave needs an approval whatever its policy (#3762). */
135
+ heldBy?: string[];
136
+ /**
137
+ * Spent approvals (#3784). `base`: the spec at base sets `approvals.spend`,
138
+ * so a spent approval counts for nothing and the approval this wave applies
139
+ * under is recorded. `change`: only the change sets it, so the approval is
140
+ * recorded and spent from the next decision on. Absent: neither.
141
+ */
142
+ spend?: "base" | "change";
143
+ /** The approval's own timestamp: what the applied record names. */
144
+ approvedAt?: string;
145
+ /** When the wave recorded that it applies under the approval, which spent it. */
146
+ spentAt?: string;
147
+ /** `apply`: the apply command runs the apply-stage hooks under its own lock (#3804), as read at base. Absent: chant runs them. */
148
+ applyHooksRunBy?: "apply";
149
+ /** Every hook's outcome so far in the job that wrote the record (#3804). */
150
+ outcomes?: OpWaveHookOutcome[];
87
151
  }
88
152
 
89
- /** Runs one argv; returns its exit code. Injected so tests run no processes. */
90
- export type OpWaveExec = (argv: string[], cwd: string) => number;
153
+ /** One hook's run (#3762). */
154
+ export interface OpWaveHookOutcome {
155
+ moment: OpWaveHookMoment;
156
+ name: string;
157
+ /** The run, for a `perRun` hook. */
158
+ target?: string;
159
+ exit: number;
160
+ outcome: "passed" | "failed" | "held";
161
+ }
91
162
 
92
- const spawnExec: OpWaveExec = (argv, cwd) => {
93
- const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" });
163
+ /** One run as a hook sees it (#3804). */
164
+ export interface OpWaveHookRun {
165
+ target: string;
166
+ op: string;
167
+ /** The run's plan file, once planned. */
168
+ plan?: string;
169
+ }
170
+
171
+ /**
172
+ * What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
173
+ * {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
174
+ * relative to the job's working directory.
175
+ */
176
+ export interface OpWaveHooksFile {
177
+ version: 1;
178
+ /** The Op waves spec's name. */
179
+ opWaves: string;
180
+ /** The spec's `op`: a hook's `{op}` when it runs once for the wave. */
181
+ op: string;
182
+ wave: string;
183
+ /** 1-based; 0 when the working tree's spec has no such wave. */
184
+ number: number;
185
+ /** The commit the hooks were read at. */
186
+ base?: string;
187
+ /** The wave's set digest, once decided. */
188
+ digest?: string;
189
+ /** An approval of the digest stands, so a held hook does not stop the apply. */
190
+ approved: boolean;
191
+ /** The labels of the hooks that held the wave, approved or not. */
192
+ heldBy?: string[];
193
+ /** Who runs the apply-stage hooks. With `chant`, `hooks` is empty: chant runs them around the apply. */
194
+ runBy: OpWaveApplyHooksRunner;
195
+ /** The runs this apply call applies. */
196
+ runs: OpWaveHookRun[];
197
+ /** The runs file, a hook's `{runs}` and `CHANT_RUNS`. */
198
+ runsFile: string;
199
+ /** The hooks the apply runs, by moment, as read at base. */
200
+ hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
201
+ /** The hooks that already ran for this wave in this job, and how each came out. */
202
+ outcomes: OpWaveHookOutcome[];
203
+ /** Where {@link runOpWaveApplyHooks} writes its outcomes (`CHANT_HOOKS_RESULT`), which chant reads back. */
204
+ result?: string;
205
+ /** Set when the hooks at base could not be read: none run, and the runner refuses to proceed. */
206
+ refused?: string;
207
+ }
208
+
209
+ /** What {@link runOpWaveApplyHooks} writes to `CHANT_HOOKS_RESULT` (#3804). */
210
+ export interface OpWaveHooksResult {
211
+ version: 1;
212
+ /** The moments the apply ran, empty ones included. */
213
+ moments: OpWaveHookMoment[];
214
+ outcomes: OpWaveHookOutcome[];
215
+ }
216
+
217
+ /** One call of {@link runOpWaveApplyHooks}. */
218
+ export interface OpWaveApplyHooksRun {
219
+ moment: OpWaveHookMoment;
220
+ outcomes: OpWaveHookOutcome[];
221
+ /** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
222
+ failed?: string;
223
+ /** The `onFailure: approve` hooks that failed, or why the hooks could not be read at base. */
224
+ held: string[];
225
+ /**
226
+ * Whether the apply may go on: no hook failed, and nothing held it or an
227
+ * approval of the digest stands. When false after a hold, the apply applies
228
+ * nothing and exits non-zero; chant then waits for, or finds, an approval
229
+ * and calls it again.
230
+ */
231
+ proceed: boolean;
232
+ }
233
+
234
+ /** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
235
+ export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
236
+
237
+ const spawnExec: OpWaveExec = (argv, cwd, env) => {
238
+ const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
94
239
  if (result.error) throw result.error;
95
240
  return result.status ?? 1;
96
241
  };
@@ -132,10 +277,31 @@ export function parseOpWavesSpec(text: string, where: string): OpWavesSpec {
132
277
  return spec;
133
278
  }
134
279
 
280
+ /** What {@link readOpWavePolicy} read at base. */
281
+ export interface OpWaveBaseRules {
282
+ policy: OpWaveGatePolicy;
283
+ approval: GateApprovalSource;
284
+ source: OpWavePolicySource;
285
+ base: string;
286
+ /** The wave's hooks at base, by moment (#3762): the spec's, then the wave's. None when the file or the wave is absent there. */
287
+ hooks: Record<OpWaveHookMoment, OpWaveHook[]>;
288
+ /** Set when the hooks at base could not be read: none run, and the wave waits for a person. */
289
+ hooksRefused?: string;
290
+ /** The spec at base sets `approvals.spend` (#3784). */
291
+ spend: boolean;
292
+ /** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
293
+ applyHooksRunBy: OpWaveApplyHooksRunner;
294
+ }
295
+
296
+ const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
297
+ Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, []])) as unknown as Record<OpWaveHookMoment, OpWaveHook[]>;
298
+
135
299
  /**
136
300
  * The policy of wave `name`, as the spec file at `base` declares it. The
137
301
  * file or the wave missing there reads as `always`: a change that adds a
138
- * wave, or the file, waits for a person the first time.
302
+ * wave, or the file, waits for a person the first time. Its stage hooks are
303
+ * read there too (#3762), so a change cannot add, edit or remove a hook its
304
+ * own apply runs; the file or the wave missing there has none.
139
305
  */
140
306
  export function readOpWavePolicy(
141
307
  specFile: string,
@@ -143,9 +309,9 @@ export function readOpWavePolicy(
143
309
  base: string,
144
310
  cwd: string,
145
311
  show: OpWaveShowAtBase = gitShowAtBase,
146
- ): { policy: OpWaveGatePolicy; approval: GateApprovalSource; source: OpWavePolicySource; base: string } {
312
+ ): OpWaveBaseRules {
147
313
  const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
148
- const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha };
314
+ const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
149
315
  if (text === null) return absent;
150
316
  let spec: OpWavesSpec;
151
317
  try {
@@ -153,19 +319,40 @@ export function readOpWavePolicy(
153
319
  } catch {
154
320
  return absent;
155
321
  }
322
+ // The spend rule is the spec's, so it holds for a wave the base does not have yet.
323
+ const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
156
324
  const wave = spec.waves.find((w) => w.name === name);
157
- if (!wave) return absent;
325
+ if (!wave) return { ...absent, spend };
158
326
  const policy = wave.gate ?? "always";
159
327
  // An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
160
328
  const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
161
- return { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha };
329
+ const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
330
+ try {
331
+ assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
332
+ assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
333
+ rules.hooks = opWaveHooksAt(spec, name);
334
+ rules.applyHooksRunBy = opWaveApplyHooksRunBy(spec, name);
335
+ } catch (err) {
336
+ // A hook at base no runner can run: none run, and the wave waits for a person rather than going out without them.
337
+ rules.hooksRefused = `the hooks at base cannot be run (${err instanceof Error ? err.message : String(err)})`;
338
+ }
339
+ return rules;
162
340
  }
163
341
 
164
- /** Fill one argv template for one run. */
165
- export function opWaveArgv(template: readonly string[], values: { op: string; target: string; wave: string; plan: string }): string[] {
166
- return template.map((part) =>
167
- part.replaceAll("{op}", values.op).replaceAll("{target}", values.target).replaceAll("{wave}", values.wave).replaceAll("{plan}", values.plan),
168
- );
342
+ /** Fill one argv template. A placeholder whose value is not given is left as it is. */
343
+ export function opWaveArgv(
344
+ template: readonly string[],
345
+ values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
346
+ ): string[] {
347
+ const pairs: Array<[string, string | undefined]> = [
348
+ ["{op}", values.op],
349
+ ["{target}", values.target],
350
+ ["{wave}", values.wave],
351
+ ["{plans}", values.plans],
352
+ ["{plan}", values.plan],
353
+ ["{runs}", values.runs],
354
+ ];
355
+ return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
169
356
  }
170
357
 
171
358
  /** Where a run's plan file goes. */
@@ -173,6 +360,16 @@ export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, ta
173
360
  return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
174
361
  }
175
362
 
363
+ /** Where a wave's runs file goes: the `{runs}` of a wave-level command or a hook (#3762). */
364
+ export function opWaveRunsPath(spec: Pick<OpWavesSpec, "name">, wave: number): string {
365
+ return `.chant/op-waves/${spec.name}/wave-${wave}/runs.json`;
366
+ }
367
+
368
+ /** Where a wave-level plan command writes its plans: its `{plans}` (#3762). */
369
+ export function opWavePlansPath(spec: Pick<OpWavesSpec, "name">, wave: number): string {
370
+ return `.chant/op-waves/${spec.name}/wave-${wave}/plans.json`;
371
+ }
372
+
176
373
  export interface RunOpWaveOptions {
177
374
  spec: OpWavesSpec;
178
375
  specFile: string;
@@ -201,24 +398,33 @@ export interface RunOpWaveOptions {
201
398
  }
202
399
 
203
400
  export interface RunOpWaveResult {
204
- /** 0 applied (or decided), 1 a run failed, 3 waiting for an approval, 4 a plan moved since the decision. */
401
+ /** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision. */
205
402
  exitCode: number;
206
403
  decision?: OpWaveDecision;
207
404
  applied: string[];
208
405
  failed: string[];
209
406
  /** Runs a share job refused because their plan moved since the decision. */
210
407
  moved: string[];
408
+ /** The hooks that ran (#3762). */
409
+ hooks: OpWaveHookOutcome[];
410
+ }
411
+
412
+ /** What one wave job works with. */
413
+ interface WaveContext {
414
+ spec: OpWavesSpec;
415
+ k: number;
416
+ wave: OpWave;
417
+ cwd: string;
418
+ exec: OpWaveExec;
419
+ log: (line: string) => void;
420
+ result: RunOpWaveResult;
211
421
  }
212
422
 
213
- function planRun(
214
- opts: Required<Pick<RunOpWaveOptions, "spec" | "wave">> & { cwd: string; exec: OpWaveExec },
215
- waveName: string,
216
- run: OpWaveRun,
217
- ): OpWaveMember {
423
+ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
218
424
  const op = run.op ?? opts.spec.op;
219
425
  const plan = opWavePlanPath(opts.spec, opts.wave, run.target);
220
426
  mkdirSync(dirname(join(opts.cwd, plan)), { recursive: true });
221
- const code = opts.exec(opWaveArgv(opts.spec.plan, { op, target: run.target, wave: waveName, plan }), opts.cwd);
427
+ const code = opts.exec(opWaveArgv(opts.spec.plan!, { op, target: run.target, wave: waveName, plan }), opts.cwd);
222
428
  if (code !== 0) throw new Error(`planning ${run.target} exited ${code}`);
223
429
  let file: OpWavePlanFile;
224
430
  try {
@@ -229,8 +435,12 @@ function planRun(
229
435
  if (typeof file.planDigest !== "string" || file.planDigest === "") {
230
436
  throw new Error(`the plan file for ${run.target} (${plan}) has no planDigest`);
231
437
  }
438
+ return member(run.target, op, file);
439
+ }
440
+
441
+ function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
232
442
  return {
233
- member: run.target,
443
+ member: target,
234
444
  planDigest: file.planDigest,
235
445
  op,
236
446
  ...(file.destructive === true ? { destructive: true } : {}),
@@ -238,6 +448,285 @@ function planRun(
238
448
  };
239
449
  }
240
450
 
451
+ /** Write the `{runs}` file: the runs, with their plans once planned. */
452
+ function writeRunsFile(
453
+ at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string },
454
+ runs: readonly OpWaveRun[],
455
+ members?: ReadonlyMap<string, OpWaveMember>,
456
+ digest?: string,
457
+ path: string = opWaveRunsPath(at.spec, at.k),
458
+ ): string {
459
+ const file: OpWaveRunsFile = {
460
+ wave: at.wave.name,
461
+ number: at.k,
462
+ ...(digest ? { digest } : {}),
463
+ runs: runs.map((run) => {
464
+ const m = members?.get(run.target);
465
+ return {
466
+ target: run.target,
467
+ op: run.op ?? at.spec.op,
468
+ ...(m ? { planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) } : {}),
469
+ };
470
+ }),
471
+ };
472
+ mkdirSync(dirname(join(at.cwd, path)), { recursive: true });
473
+ writeFileSync(join(at.cwd, path), JSON.stringify(file, null, 2) + "\n");
474
+ return path;
475
+ }
476
+
477
+ /**
478
+ * Plan `runs` of wave `k`: one `plan` call per run, or one `planWave` call
479
+ * for all of them (#3762). A wave plan is written out as each run's plan file
480
+ * too, so a per-run `apply` or hook reads `{plan}` either way.
481
+ */
482
+ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): OpWaveMember[] {
483
+ const { spec, k, wave, cwd, exec } = at;
484
+ if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
485
+ const runsPath = writeRunsFile(at, runs);
486
+ const plans = opWavePlansPath(spec, k);
487
+ rmSync(join(cwd, plans), { force: true });
488
+ const code = exec(opWaveArgv(spec.planWave, { op: spec.op, wave: wave.name, runs: runsPath, plans }), cwd);
489
+ if (code !== 0) throw new Error(`planning wave ${k} (${wave.name}) exited ${code}`);
490
+ let parsed: unknown;
491
+ try {
492
+ parsed = JSON.parse(readFileSync(join(cwd, plans), "utf-8"));
493
+ } catch (err) {
494
+ throw new Error(`planning wave ${k} (${wave.name}) wrote no readable plans file at ${plans}: ${err instanceof Error ? err.message : String(err)}`);
495
+ }
496
+ const entries = Array.isArray(parsed) ? parsed : (parsed as { runs?: unknown } | null)?.runs;
497
+ if (!Array.isArray(entries)) throw new Error(`the plans file of wave ${k} (${plans}) must hold { "runs": [{ "target", "planDigest" }] }`);
498
+ const wanted = new Set(runs.map((r) => r.target));
499
+ const byTarget = new Map<string, OpWavePlanFile>();
500
+ for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string }>>) {
501
+ const target = entry?.target;
502
+ if (typeof target !== "string" || !wanted.has(target)) {
503
+ throw new Error(`the plans file of wave ${k} (${plans}) plans ${JSON.stringify(target)}, which is not a run it was asked to plan`);
504
+ }
505
+ if (byTarget.has(target)) throw new Error(`the plans file of wave ${k} (${plans}) plans ${target} twice`);
506
+ if (typeof entry.planDigest !== "string" || entry.planDigest === "") throw new Error(`the plans file of wave ${k} (${plans}) has no planDigest for ${target}`);
507
+ byTarget.set(target, entry as OpWavePlanFile);
508
+ }
509
+ const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
510
+ if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
511
+ return runs.map((run) => {
512
+ const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
513
+ const path = join(cwd, opWavePlanPath(spec, k, run.target));
514
+ writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
515
+ return m;
516
+ });
517
+ }
518
+
519
+ /** What one moment's hooks came to. */
520
+ interface HooksOutcome {
521
+ /** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
522
+ failed?: string;
523
+ /** The `onFailure: approve` hooks that failed. */
524
+ holds: string[];
525
+ }
526
+
527
+ /** Where hooks run: the wave they belong to and the job's tools. */
528
+ interface HookPlace {
529
+ /** The spec's name and `op`. */
530
+ name: string;
531
+ op: string;
532
+ waveName: string;
533
+ k: number;
534
+ cwd: string;
535
+ exec: OpWaveExec;
536
+ log: (line: string) => void;
537
+ }
538
+
539
+ /**
540
+ * Run one moment's hooks, in order: each once for the wave, or once per run
541
+ * for a `perRun` hook. A hook that fails stops the rest, unless its
542
+ * `onFailure` is `approve`: then it holds the wave and the hooks after it run.
543
+ */
544
+ function runHookList(
545
+ at: HookPlace,
546
+ moment: OpWaveHookMoment,
547
+ hooks: readonly OpWaveHook[] | undefined,
548
+ runs: readonly OpWaveHookRun[],
549
+ runsPath: string,
550
+ ): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
551
+ const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
552
+ if (!hooks || hooks.length === 0) return out;
553
+ const { name: specName, op, waveName, k, cwd, exec, log } = at;
554
+ const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
555
+ for (const hook of hooks) {
556
+ const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
557
+ const calls = hook.perRun
558
+ ? runs.map((run) => ({
559
+ target: run.target as string | undefined,
560
+ argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
561
+ env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
562
+ }))
563
+ : [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
564
+ for (const call of calls) {
565
+ const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
566
+ let code: number;
567
+ try {
568
+ code = exec(call.argv, cwd, call.env);
569
+ } catch (err) {
570
+ log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
571
+ code = 127;
572
+ }
573
+ const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
574
+ if (code === 0) {
575
+ out.outcomes.push({ ...base, outcome: "passed" });
576
+ continue;
577
+ }
578
+ if (hook.onFailure === "approve") {
579
+ out.outcomes.push({ ...base, outcome: "held" });
580
+ out.holds.push(label);
581
+ log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
582
+ continue;
583
+ }
584
+ out.outcomes.push({ ...base, outcome: "failed" });
585
+ out.failed = `${label} exited ${code}`;
586
+ log(out.failed);
587
+ return out;
588
+ }
589
+ }
590
+ return out;
591
+ }
592
+
593
+ 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 });
594
+
595
+ const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
596
+ runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
597
+
598
+ /** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
599
+ function runHooks(
600
+ ctx: WaveContext,
601
+ moment: OpWaveHookMoment,
602
+ hooks: readonly OpWaveHook[] | undefined,
603
+ runs: readonly OpWaveRun[],
604
+ members?: ReadonlyMap<string, OpWaveMember>,
605
+ digest?: string,
606
+ ): HooksOutcome {
607
+ if (!hooks || hooks.length === 0) return { holds: [] };
608
+ const runsPath = writeRunsFile(ctx, runs, members, digest);
609
+ const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
610
+ ctx.result.hooks.push(...out.outcomes);
611
+ return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
612
+ }
613
+
614
+ /**
615
+ * Read the hooks file chant hands an apply command (#3804): the one at
616
+ * `path`, by default `CHANT_HOOKS`. Null when there is none, as for an apply
617
+ * that a wave job did not start.
618
+ */
619
+ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT_HOOKS, cwd: string = process.cwd()): OpWaveHooksFile | null {
620
+ if (!path) return null;
621
+ const at = isAbsolute(path) ? path : join(cwd, path);
622
+ let parsed: OpWaveHooksFile;
623
+ try {
624
+ parsed = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksFile;
625
+ } catch (err) {
626
+ throw new Error(`the hooks file ${path} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
627
+ }
628
+ if (parsed?.version !== 1 || !Array.isArray(parsed.runs) || typeof parsed.hooks !== "object") throw new Error(`${path} is not an Op waves hooks file (version 1)`);
629
+ return parsed;
630
+ }
631
+
632
+ /**
633
+ * Run one moment's hooks from a hooks file (#3804): what an apply command
634
+ * calls under its own lock, `before-apply` right before it changes anything
635
+ * and `after-apply` once it has, when the spec's `hooks.apply.runBy` at base
636
+ * is `apply`. Also what a product's apply outside the waves calls, with a
637
+ * file from {@link opWaveHooksAtBase}. A moment the file has no hooks for
638
+ * runs nothing and proceeds, so an apply can call it whoever runs the hooks.
639
+ *
640
+ * Each call adds its outcomes to the file's `result` (`CHANT_HOOKS_RESULT`),
641
+ * which chant reads back after the apply exits: the outcomes join the wave's
642
+ * result and decision record, and a hold makes the wave wait for, or find,
643
+ * an approval of its digest. `exec` runs one hook; inject it to run a hook
644
+ * in process or to keep its output.
645
+ */
646
+ export function runOpWaveApplyHooks(
647
+ file: OpWaveHooksFile,
648
+ moment: OpWaveHookMoment,
649
+ options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void } = {},
650
+ ): OpWaveApplyHooksRun {
651
+ const cwd = options.cwd ?? process.cwd();
652
+ const log = options.log ?? ((line: string) => console.error(line));
653
+ let run: OpWaveApplyHooksRun;
654
+ if (file.refused) {
655
+ log(`${file.refused}, so none of the ${moment} hooks run and the apply does not go on`);
656
+ run = { moment, outcomes: [], held: [file.refused], proceed: false };
657
+ } else {
658
+ const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
659
+ const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
660
+ run = {
661
+ moment,
662
+ outcomes: out.outcomes,
663
+ ...(out.failed ? { failed: out.failed } : {}),
664
+ held: out.holds,
665
+ proceed: !out.failed && (out.holds.length === 0 || file.approved),
666
+ };
667
+ }
668
+ if (file.result) {
669
+ const at = isAbsolute(file.result) ? file.result : join(cwd, file.result);
670
+ let prior: OpWaveHooksResult = { version: 1, moments: [], outcomes: [] };
671
+ if (existsSync(at)) {
672
+ try {
673
+ prior = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksResult;
674
+ } catch {
675
+ // A result file that cannot be read is started again.
676
+ }
677
+ }
678
+ const next: OpWaveHooksResult = { version: 1, moments: [...prior.moments, moment], outcomes: [...prior.outcomes, ...run.outcomes] };
679
+ mkdirSync(dirname(at), { recursive: true });
680
+ writeFileSync(at, JSON.stringify(next, null, 2) + "\n");
681
+ }
682
+ return run;
683
+ }
684
+
685
+ /**
686
+ * The hooks of wave `wave` read at base, for an apply outside the waves
687
+ * (#3804): a product's own `apply <env>` that runs the same Op as the wave
688
+ * does, and the same hooks. All four moments are in the file; the apply runs
689
+ * the ones it has a stage for with {@link runOpWaveApplyHooks}. Nothing is
690
+ * approved: a held hook stops the apply. The wave or the file missing at
691
+ * base has no hooks, as in a wave job.
692
+ */
693
+ export function opWaveHooksAtBase(options: {
694
+ specFile: string;
695
+ wave: string;
696
+ /** The runs this apply applies. Default: the wave's runs in the working tree's spec. */
697
+ runs?: ReadonlyArray<{ target: string; op?: string }>;
698
+ /** Default: the spec's `base`, else `HEAD^1`. */
699
+ base?: string;
700
+ cwd?: string;
701
+ show?: OpWaveShowAtBase;
702
+ }): OpWaveHooksFile {
703
+ const cwd = options.cwd ?? process.cwd();
704
+ const spec = parseOpWavesSpec(readFileSync(isAbsolute(options.specFile) ? options.specFile : join(cwd, options.specFile), "utf-8"), options.specFile);
705
+ const k = spec.waves.findIndex((w) => w.name === options.wave) + 1;
706
+ const rules = readOpWavePolicy(options.specFile, options.wave, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
707
+ const runs = (options.runs ?? spec.waves[k - 1]?.runs ?? []).map((r) => ({ target: r.target, op: r.op ?? spec.op }));
708
+ const runsFile = `.chant/op-waves/${spec.name}/${options.wave.replace(/[^A-Za-z0-9._-]/g, "_")}.runs.json`;
709
+ const runsBody: OpWaveRunsFile = { wave: options.wave, number: k, runs };
710
+ mkdirSync(dirname(join(cwd, runsFile)), { recursive: true });
711
+ writeFileSync(join(cwd, runsFile), JSON.stringify(runsBody, null, 2) + "\n");
712
+ const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
713
+ return {
714
+ version: 1,
715
+ opWaves: spec.name,
716
+ op: spec.op,
717
+ wave: options.wave,
718
+ number: k,
719
+ base: rules.base,
720
+ approved: false,
721
+ runBy: "apply",
722
+ runs,
723
+ runsFile,
724
+ hooks,
725
+ outcomes: [],
726
+ ...(rules.hooksRefused ? { refused: rules.hooksRefused } : {}),
727
+ };
728
+ }
729
+
241
730
  /** Whether `policy` lets a wave with these members through without an approval. */
242
731
  export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
243
732
  if (policy === "never") return false;
@@ -246,6 +735,15 @@ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly
246
735
  return true;
247
736
  }
248
737
 
738
+ /** Log when the change's hooks are not the ones at base, which are the ones that run. */
739
+ function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
740
+ const { hooks: atBase, base } = rules;
741
+ const own = opWaveHooksAt(ctx.spec, ctx.wave.name);
742
+ if (JSON.stringify(own) !== JSON.stringify(atBase) || opWaveApplyHooksRunBy(ctx.spec, ctx.wave.name) !== rules.applyHooksRunBy) {
743
+ ctx.log(`wave ${ctx.k} (${ctx.wave.name}): its hooks are read at ${base.slice(0, 12)}, and this change's differ; the ones at base run`);
744
+ }
745
+ }
746
+
249
747
  /** Plan, decide and apply one wave, or one part of a wide wave. */
250
748
  export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
251
749
  const { spec, wave: k } = options;
@@ -259,9 +757,9 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
259
757
  if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
260
758
  throw new Error(`wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
261
759
  }
262
- const ctx = { spec, wave: k, cwd, exec };
263
760
  const recordPath = join(cwd, opWaveRecordPath(spec, k));
264
- const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [] };
761
+ const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
762
+ const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result };
265
763
 
266
764
  if (options.share !== undefined) {
267
765
  let decision: OpWaveDecision;
@@ -275,78 +773,80 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
275
773
  log(`wave ${k} (${wave.name}) is waiting on ${decision.gate}; nothing applied. ${decision.approve ?? ""}`.trim());
276
774
  return { ...result, exitCode: 3 };
277
775
  }
776
+ // The share runs the hooks its deciding job read at base, around its own slice.
777
+ const slice = opWaveShare(wave.runs, options.share, shares);
778
+ const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
779
+ if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`);
780
+ const members = planRuns(ctx, slice);
781
+ const planned = new Map(members.map((m) => [m.member, m]));
782
+ const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
783
+ if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
278
784
  const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
279
- for (const run of opWaveShare(wave.runs, options.share, shares)) {
280
- const member = planRun(ctx, wave.name, run);
281
- if (decided.get(run.target) !== member.planDigest) {
785
+ const unmoved: OpWaveRun[] = [];
786
+ for (const run of slice) {
787
+ const now = planned.get(run.target)!;
788
+ if (decided.get(run.target) !== now.planDigest) {
282
789
  log(
283
790
  `${run.target}: its plan moved since wave ${k} was decided, so it was not applied. ` +
284
- `decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${member.planDigest}`,
791
+ `decided: ${decided.get(run.target) ?? "(not in the decision)"}; planned now: ${now.planDigest}`,
285
792
  );
286
793
  result.moved.push(run.target);
287
794
  continue;
288
795
  }
289
- applyRun(spec, wave.name, k, run, cwd, exec, result, log);
796
+ unmoved.push(run);
290
797
  }
291
- result.exitCode = result.failed.length > 0 ? 1 : result.moved.length > 0 ? 4 : 0;
798
+ await applyStage(options, ctx, decision, unmoved, planned, [...before.holds, ...after.holds]);
799
+ if (result.exitCode === 0 && result.moved.length > 0) result.exitCode = 4;
292
800
  return result;
293
801
  }
294
802
 
295
- const members = wave.runs.map((run) => planRun(ctx, wave.name, run));
803
+ // The policy, the approval mode and the hooks, read at base before anything runs.
804
+ const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
805
+ const { policy, approval, source, base } = rules;
806
+ noteHooksAtBase(ctx, rules);
807
+ const holds: string[] = [];
808
+ if (rules.hooksRefused) {
809
+ holds.push(rules.hooksRefused);
810
+ log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
811
+ }
812
+ const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
813
+ if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
814
+ const members = planRuns(ctx, wave.runs);
815
+ const planned = new Map(members.map((m) => [m.member, m]));
296
816
  const digest = waveSetDigest(members);
297
- const { policy, approval, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
817
+ const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
818
+ if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
819
+ holds.push(...before.holds, ...after.holds);
298
820
  const gate = waveGateName(spec.gate ?? spec.name, k);
299
- const decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, approval, policySource: source, base, digest, members, status: "not-required" };
300
- if (opWaveNeedsApproval(policy, members)) {
301
- const ledger = options.gates ?? gitGateLedgerPort({ cwd });
302
- let port = ledger;
303
- if (approval === "sealed") {
304
- // #3684: only an approval sealed by a signer listed at base counts.
305
- const review = await import("./gate-review");
306
- const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
307
- port = {
308
- ...ledger,
309
- async approvalRule(g) {
310
- const own = (await ledger.approvalRule?.(g)) ?? null;
311
- return g === gate ? review.bothRules(own, sealed) : own;
312
- },
313
- };
314
- }
315
- const now = options.now ?? new Date().toISOString();
316
- const check = await evaluateGate(port, {
317
- op: spec.name,
318
- gate,
319
- planDigest: digest,
320
- description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}`,
321
- now,
322
- });
323
- if (check.satisfied) {
324
- decision.status = "approved";
325
- decision.via = "ledger";
326
- decision.approvedBy = check.resolution.resolvedBy;
327
- } else if (approval === "pr-review") {
328
- const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger });
329
- decision.review = byReview.review;
330
- if (byReview.approved) {
331
- decision.status = "approved";
332
- decision.via = "pr-review";
333
- decision.approvedBy = byReview.review.approvers!.join(", ");
334
- } else {
335
- decision.status = "waiting";
336
- decision.approve = approveCommand(spec.name, gate, undefined, digest);
337
- }
338
- } else {
339
- decision.status = "waiting";
340
- decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
341
- }
342
- }
821
+ const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
822
+ const decision: OpWaveDecision = {
823
+ wave: k,
824
+ name: wave.name,
825
+ op: spec.name,
826
+ gate,
827
+ policy,
828
+ approval,
829
+ policySource: source,
830
+ base,
831
+ digest,
832
+ members,
833
+ status: "not-required",
834
+ ...(Object.keys(hooks).length > 0 ? { hooks } : {}),
835
+ ...(holds.length > 0 ? { heldBy: holds } : {}),
836
+ ...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
837
+ ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
838
+ };
839
+ if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
840
+ // A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
841
+ if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
343
842
  mkdirSync(dirname(recordPath), { recursive: true });
344
- writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
843
+ writeRecord(recordPath, decision, result);
345
844
  result.decision = decision;
346
845
  log(
347
846
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
348
847
  `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
349
- (decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : ""),
848
+ (decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
849
+ (holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
350
850
  );
351
851
  if (approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
352
852
  log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
@@ -356,37 +856,329 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
356
856
  return { ...result, exitCode: 3 };
357
857
  }
358
858
  if (options.decide) return result;
359
- for (const run of wave.runs) applyRun(spec, wave.name, k, run, cwd, exec, result, log);
360
- result.exitCode = result.failed.length > 0 ? 1 : 0;
859
+ await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
361
860
  return result;
362
861
  }
363
862
 
364
- function applyRun(
365
- spec: OpWavesSpec,
366
- waveName: string,
367
- k: number,
368
- run: OpWaveRun,
369
- cwd: string,
370
- exec: OpWaveExec,
371
- result: RunOpWaveResult,
372
- log: (line: string) => void,
373
- ): void {
374
- const op = run.op ?? spec.op;
375
- const plan = opWavePlanPath(spec, k, run.target);
376
- const code = exec(opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op, target: run.target, wave: waveName, plan }), cwd);
377
- if (code === 0) result.applied.push(run.target);
378
- else {
379
- log(`${run.target}: apply exited ${code}`);
380
- result.failed.push(run.target);
863
+ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
864
+ ctx.log(line);
865
+ ctx.result.exitCode = 1;
866
+ return ctx.result;
867
+ }
868
+
869
+ /**
870
+ * Decide whether an approval of the decision's digest stands, whatever its
871
+ * policy: sets its status to `approved` or `waiting`. Called when the policy
872
+ * asks for one, or when a hook holds the wave (#3762).
873
+ */
874
+ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
875
+ const { spec, k, wave, cwd } = ctx;
876
+ const { gate, digest, approval, base, policy, members } = decision;
877
+ const ledger = options.gates ?? gitGateLedgerPort({ cwd });
878
+ let port = ledger;
879
+ if (approval === "sealed") {
880
+ // #3684: only an approval sealed by a signer listed at base counts.
881
+ const review = await import("./gate-review");
882
+ const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
883
+ port = {
884
+ ...ledger,
885
+ async approvalRule(g) {
886
+ const own = (await ledger.approvalRule?.(g)) ?? null;
887
+ return g === gate ? review.bothRules(own, sealed) : own;
888
+ },
889
+ };
890
+ }
891
+ const now = options.now ?? new Date().toISOString();
892
+ // #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
893
+ const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
894
+ const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
895
+ const check = await evaluateGate(port, {
896
+ op: spec.name,
897
+ gate,
898
+ planDigest: digest,
899
+ description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
900
+ now,
901
+ ...(spent ? { spent } : {}),
902
+ });
903
+ if (check.satisfied) {
904
+ decision.status = "approved";
905
+ decision.via = "ledger";
906
+ decision.approvedBy = check.resolution.resolvedBy;
907
+ decision.approvedAt = check.resolution.timestamp;
908
+ return;
909
+ }
910
+ const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
911
+ if (used) {
912
+ ctx.log(`wave ${k} (${wave.name}): the approval of ${digest} by ${used.approvedBy} at ${used.approvedAt} is spent: a wave applied under it at ${used.timestamp}. Applying it again needs a new approval`);
913
+ }
914
+ if (approval === "pr-review") {
915
+ const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
916
+ decision.review = byReview.review;
917
+ if (byReview.approved) {
918
+ decision.status = "approved";
919
+ decision.via = "pr-review";
920
+ decision.approvedBy = byReview.review.approvers!.join(", ");
921
+ decision.approvedAt = now;
922
+ return;
923
+ }
924
+ decision.status = "waiting";
925
+ decision.approve = approveCommand(spec.name, gate, undefined, digest);
926
+ return;
381
927
  }
928
+ decision.status = "waiting";
929
+ decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
382
930
  }
383
931
 
384
- /** What {@link approveByReview} found. */
385
- interface ReviewOutcome {
386
- approved: boolean;
387
- review: NonNullable<OpWaveDecision["review"]>;
932
+ /** Write the decision record, with every hook's outcome so far (#3804). */
933
+ function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
934
+ if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
935
+ writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
936
+ }
937
+
938
+ /**
939
+ * A hold waits only when nobody approved the decided digest: settle the gate
940
+ * once for `holds`. False when the wave now waits (exit 3).
941
+ */
942
+ async function settleHolds(
943
+ options: RunOpWaveOptions,
944
+ ctx: WaveContext,
945
+ decision: OpWaveDecision,
946
+ holds: readonly string[],
947
+ recordPath?: string,
948
+ ): Promise<boolean> {
949
+ const { k, wave, log, result } = ctx;
950
+ if (holds.length === 0 || decision.status === "approved") return true;
951
+ decision.heldBy = [...(decision.heldBy ?? []), ...holds];
952
+ await settleGate(options, ctx, decision);
953
+ if (recordPath) writeRecord(recordPath, decision, result);
954
+ if (decision.status === "waiting") {
955
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
956
+ result.exitCode = 3;
957
+ return false;
958
+ }
959
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
960
+ return true;
388
961
  }
389
962
 
963
+ /** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
964
+ type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
965
+
966
+ /**
967
+ * Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
968
+ * When the apply runs the apply-stage hooks, read their outcomes back: a hold
969
+ * with no approval standing settles the gate and, once approved, calls the
970
+ * apply again; an `after-apply` failure means the runs applied and the wave
971
+ * fails; an apply that exited 0 without running its hooks fails the wave.
972
+ */
973
+ async function callApply(
974
+ options: RunOpWaveOptions,
975
+ ctx: WaveContext,
976
+ decision: OpWaveDecision,
977
+ runs: readonly OpWaveRun[],
978
+ planned: ReadonlyMap<string, OpWaveMember>,
979
+ argv: string[],
980
+ call: { label: string; key: string; runsPath: string },
981
+ recordPath?: string,
982
+ ): Promise<ApplyCall> {
983
+ const { spec, k, wave, cwd, exec, log, result } = ctx;
984
+ const byApply = decision.applyHooksRunBy === "apply";
985
+ const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
986
+ const filePath = `${dir}/${call.key}.json`;
987
+ const resultPath = `${dir}/${call.key}-result.json`;
988
+ const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
989
+ if (byApply) {
990
+ for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
991
+ }
992
+ for (let pass = 0; pass < 2; pass++) {
993
+ const approved = decision.status === "approved";
994
+ const file: OpWaveHooksFile = {
995
+ version: 1,
996
+ opWaves: spec.name,
997
+ op: spec.op,
998
+ wave: wave.name,
999
+ number: k,
1000
+ base: decision.base,
1001
+ digest: decision.digest,
1002
+ approved,
1003
+ ...(decision.heldBy?.length ? { heldBy: decision.heldBy } : {}),
1004
+ runBy: byApply ? "apply" : "chant",
1005
+ runs: hookRuns(spec, k, runs, planned),
1006
+ runsFile: call.runsPath,
1007
+ hooks: handed,
1008
+ outcomes: [...result.hooks],
1009
+ ...(byApply ? { result: resultPath } : {}),
1010
+ };
1011
+ mkdirSync(join(cwd, dir), { recursive: true });
1012
+ writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
1013
+ rmSync(join(cwd, resultPath), { force: true });
1014
+ let code: number;
1015
+ try {
1016
+ code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
1017
+ } catch (err) {
1018
+ log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
1019
+ code = 127;
1020
+ }
1021
+ if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
1022
+ let back: OpWaveHooksResult | null = null;
1023
+ try {
1024
+ back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
1025
+ } catch {
1026
+ back = null;
1027
+ }
1028
+ const outcomes = Array.isArray(back?.outcomes) ? back!.outcomes : [];
1029
+ const moments = Array.isArray(back?.moments) ? back!.moments : [];
1030
+ result.hooks.push(...outcomes);
1031
+ const holds = outcomes.filter((o) => o.moment === "before-apply" && o.outcome === "held").map((o) => `${o.moment} hook ${o.name}${o.target ? ` (${o.target})` : ""}`);
1032
+ if (holds.length > 0 && !approved) {
1033
+ if (code === 0) {
1034
+ log(`${call.label}: the apply exited 0 although ${holds.join(", ")} held it and no approval stood; an apply applies nothing on a hold`);
1035
+ return { outcome: "failed" };
1036
+ }
1037
+ if (pass > 0 || !(await settleHolds(options, ctx, decision, holds, recordPath))) return { outcome: "waiting" };
1038
+ if (!(await spendApproval(options, ctx, decision))) return { outcome: "failed" };
1039
+ if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1040
+ continue;
1041
+ }
1042
+ const afterFailed = outcomes.find((o) => o.moment === "after-apply" && o.outcome === "failed");
1043
+ if (afterFailed) return { outcome: "applied", problem: `${call.label} applied, and then the after-apply hook ${afterFailed.name} failed` };
1044
+ if (code !== 0) return { outcome: "failed" };
1045
+ const skipped = (["before-apply", "after-apply"] as const).filter((m) => handed[m] && !moments.includes(m));
1046
+ if (skipped.length > 0) {
1047
+ return { outcome: "applied", problem: `${call.label}: the apply exited 0 without running its ${skipped.join(" and ")} hooks (runOpWaveApplyHooks with CHANT_HOOKS), which the spec at base hands it` };
1048
+ }
1049
+ return { outcome: "applied" };
1050
+ }
1051
+ return { outcome: "failed" };
1052
+ }
1053
+
1054
+ /**
1055
+ * Apply `runs` of a decided wave: the before-apply hooks, the apply (one call
1056
+ * per run, or one `applyWave` call for the set), then the after-apply hooks.
1057
+ * A hook that holds the wave here, or held a share job's plan, makes it wait
1058
+ * for an approval of the decided digest unless one already stands. Under
1059
+ * `hooks.apply.runBy: "apply"` the apply call runs the apply-stage hooks
1060
+ * itself, under its lock (#3804).
1061
+ */
1062
+ async function applyStage(
1063
+ options: RunOpWaveOptions,
1064
+ ctx: WaveContext,
1065
+ decision: OpWaveDecision,
1066
+ runs: readonly OpWaveRun[],
1067
+ planned: ReadonlyMap<string, OpWaveMember>,
1068
+ earlierHolds: readonly string[],
1069
+ recordPath?: string,
1070
+ ): Promise<void> {
1071
+ const { spec, k, wave, log, result } = ctx;
1072
+ if (runs.length === 0) return;
1073
+ const byApply = decision.applyHooksRunBy === "apply";
1074
+ const holds = [...earlierHolds];
1075
+ if (!byApply) {
1076
+ const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
1077
+ if (before.failed) {
1078
+ failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
1079
+ return;
1080
+ }
1081
+ holds.push(...before.holds);
1082
+ }
1083
+ if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
1084
+ if (!(await spendApproval(options, ctx, decision))) return;
1085
+ if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1086
+ const problems: string[] = [];
1087
+ let waiting = false;
1088
+ if (spec.applyWave) {
1089
+ const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
1090
+ const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
1091
+ const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
1092
+ if (got.outcome === "applied") {
1093
+ result.applied.push(...runs.map((r) => r.target));
1094
+ if (got.problem) problems.push(got.problem);
1095
+ } else if (got.outcome === "waiting") waiting = true;
1096
+ else {
1097
+ log(`wave ${k} (${wave.name}): apply failed, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
1098
+ result.failed.push(...runs.map((r) => r.target));
1099
+ }
1100
+ } else {
1101
+ for (const run of runs) {
1102
+ const key = run.target.replace(/[^A-Za-z0-9._-]/g, "_");
1103
+ // Under runBy "apply" each run's apply runs its own apply-stage hooks, over a runs file of that run alone.
1104
+ const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
1105
+ const argv = opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op: run.op ?? spec.op, target: run.target, wave: wave.name, plan: opWavePlanPath(spec, k, run.target) });
1106
+ const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
1107
+ if (got.outcome === "waiting") {
1108
+ waiting = true;
1109
+ break;
1110
+ }
1111
+ if (got.outcome === "applied") {
1112
+ result.applied.push(run.target);
1113
+ if (got.problem) problems.push(got.problem);
1114
+ } else {
1115
+ log(`${run.target}: apply failed`);
1116
+ result.failed.push(run.target);
1117
+ }
1118
+ }
1119
+ }
1120
+ if (recordPath && byApply) writeRecord(recordPath, decision, result);
1121
+ if (waiting) {
1122
+ result.exitCode = 3;
1123
+ return;
1124
+ }
1125
+ if (result.failed.length > 0) {
1126
+ result.exitCode = 1;
1127
+ return;
1128
+ }
1129
+ if (problems.length > 0) {
1130
+ for (const p of problems) log(p);
1131
+ result.exitCode = 1;
1132
+ return;
1133
+ }
1134
+ if (!byApply) {
1135
+ const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
1136
+ if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
1137
+ }
1138
+ if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
1139
+ }
1140
+
1141
+ /**
1142
+ * Record that the wave applies under its approval (#3784), which spends it:
1143
+ * one line in `_gates/<name>/applied.jsonl`, before anything applies, as
1144
+ * terragucci's wave gate writes it. Only an approved wave under the spend
1145
+ * rule records, and only once. False when the line could not be written: the
1146
+ * wave then fails and applies nothing, since an apply the record misses
1147
+ * would leave its approval standing.
1148
+ */
1149
+ async function spendApproval(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
1150
+ if (!decision.spend || decision.status !== "approved" || decision.spentAt || !decision.approvedAt) return true;
1151
+ const { spec, k, wave, cwd, log } = ctx;
1152
+ const port = options.gates ?? gitGateLedgerPort({ cwd });
1153
+ const now = options.now ?? new Date().toISOString();
1154
+ const record: AppliedGateRecord = {
1155
+ version: 1,
1156
+ kind: "applied",
1157
+ op: spec.name,
1158
+ gate: decision.gate,
1159
+ planDigest: decision.digest,
1160
+ approvedAt: decision.approvedAt,
1161
+ approvedBy: decision.approvedBy ?? "",
1162
+ timestamp: now,
1163
+ wave: { number: k, name: wave.name },
1164
+ ...(decision.via === "pr-review" && decision.review ? { review: { ...(decision.review.pr !== undefined ? { pr: decision.review.pr } : {}), ...(decision.review.head ? { head: decision.review.head } : {}) } } : {}),
1165
+ };
1166
+ try {
1167
+ if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied approval");
1168
+ const pushed = await port.appendApplied(record);
1169
+ if (!pushed.pushed && pushed.pushWarning) log(`wave ${k} (${wave.name}): ${pushed.pushWarning}`);
1170
+ } catch (err) {
1171
+ failed(ctx, `wave ${k} (${wave.name}) could not record that it applies under the approval by ${record.approvedBy}, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
1172
+ return false;
1173
+ }
1174
+ decision.spentAt = now;
1175
+ log(`wave ${k} (${wave.name}) applies under the approval by ${record.approvedBy} at ${record.approvedAt}, which is now spent`);
1176
+ return true;
1177
+ }
1178
+
1179
+ /** What {@link approveByReview} found. */
1180
+ type ReviewOutcome = ReviewApproval;
1181
+
390
1182
  /**
391
1183
  * Under `pr-review` (#3684): the merged pull request's review approves the
392
1184
  * wave when a writer other than the author approved its head, and the head
@@ -395,7 +1187,7 @@ interface ReviewOutcome {
395
1187
  */
396
1188
  async function approveByReview(
397
1189
  options: RunOpWaveOptions,
398
- at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort },
1190
+ at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort; spent?: readonly AppliedGateRecord[] },
399
1191
  ): Promise<ReviewOutcome> {
400
1192
  const review = await import("./gate-review");
401
1193
  const head = options.head ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd: at.cwd, encoding: "utf-8" }).trim();
@@ -406,23 +1198,16 @@ async function approveByReview(
406
1198
  return { approved: false, review: { reason: `the forge's review could not be read: ${err instanceof Error ? err.message : String(err)}` } };
407
1199
  }
408
1200
  if (!merged) return { approved: false, review: { reason: `no merged pull request produced ${head.slice(0, 12)}` } };
409
- const seen = { pr: merged.pr, head: merged.head, ...(merged.url ? { url: merged.url } : {}) };
410
- if (merged.approvers.length === 0) {
411
- const why = merged.refused.length > 0 ? merged.refused.join("; ") : "nobody approved it";
412
- return { approved: false, review: { ...seen, reason: `no writer other than its author approved the head of #${merged.pr}: ${why}` } };
413
- }
414
- const plans = await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, at.cwd)))(options.spec.name, merged.head);
415
- const planned = plans?.waves.find((w) => w.wave === at.k);
416
- if (!planned) {
417
- return {
418
- approved: false,
419
- review: { ...seen, approvers: merged.approvers, reason: `no plans are recorded for the head ${merged.head.slice(0, 12)} of #${merged.pr} (its pipeline runs \`chant run wave --record-plans\`)` },
420
- };
421
- }
422
- if (planned.digest !== at.digest) {
1201
+ // The head's plans are read only when a writer approved it.
1202
+ const plans = merged.approvers.length === 0 ? null : await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, at.cwd)))(options.spec.name, merged.head);
1203
+ const { reviewApproval } = await import("../gate-decision");
1204
+ const outcome = reviewApproval(merged, plans?.waves.find((w) => w.wave === at.k)?.digest, at.digest);
1205
+ if (!outcome.approved) return outcome;
1206
+ // #3784: a review a wave already applied this digest under is spent, so it is not recorded again.
1207
+ if (at.spent && isApprovalSpent(at.spent, { gate: at.gate, planDigest: at.digest, review: { head: merged.head } })) {
423
1208
  return {
424
1209
  approved: false,
425
- review: { ...seen, approvers: merged.approvers, reason: `the plan moved after the review: the head planned ${planned.digest}, and the merge plans ${at.digest}` },
1210
+ review: { ...outcome.review, reason: `a wave already applied ${at.digest} under the review of #${merged.pr} at ${merged.head.slice(0, 12)}, so the review is spent` },
426
1211
  };
427
1212
  }
428
1213
  const note = `approved by the review of #${merged.pr} at ${merged.head.slice(0, 12)}: ${merged.approvers.join(", ")}`;
@@ -437,7 +1222,7 @@ async function approveByReview(
437
1222
  ...(merged.url ? { url: merged.url } : {}),
438
1223
  review: { pr: merged.pr, head: merged.head, approvers: merged.approvers },
439
1224
  });
440
- return { approved: true, review: { ...seen, approvers: merged.approvers } };
1225
+ return outcome;
441
1226
  }
442
1227
 
443
1228
  async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
@@ -485,7 +1270,7 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
485
1270
  head,
486
1271
  timestamp: options.now ?? new Date().toISOString(),
487
1272
  waves: spec.waves.map((wave, i) => {
488
- const members = wave.runs.map((run) => planRun({ spec, wave: i + 1, cwd, exec }, wave.name, run));
1273
+ const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
489
1274
  return { wave: i + 1, name: wave.name, digest: waveSetDigest(members) };
490
1275
  }),
491
1276
  };