@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
@@ -1,9 +1,20 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { opWaveJobs, opWaveShare, type OpWavesSpec } from "./op-waves";
6
- import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
5
+ import { assertOpWavesSpec, opWaveJobs, opWaveShare, type OpWavesSpec } from "./op-waves";
6
+ import {
7
+ opWaveHooksAtBase,
8
+ readOpWaveHooksFile,
9
+ recordOpWaveHeadPlans,
10
+ runOpWave,
11
+ runOpWaveApplyHooks,
12
+ type OpWaveDecision,
13
+ type OpWaveExec,
14
+ type OpWaveHooksFile,
15
+ type OpWaveRunsFile,
16
+ type OpWaveShowAtBase,
17
+ } from "./op-waves-run";
7
18
  import { memoryGateLedgerPort } from "./gate";
8
19
  import { waveSetDigest } from "../gated-waves";
9
20
 
@@ -93,3 +104,532 @@ describe("runOpWave", () => {
93
104
  h.done();
94
105
  });
95
106
  });
107
+
108
+ describe("wave-level commands and hooks in the spec (#3762)", () => {
109
+ const base = { name: "w", op: "migrate", waves: [{ name: "dev", runs: [{ target: "a" }] }] };
110
+ test("a spec without them is unchanged, and the new fields are checked", () => {
111
+ expect(() => assertOpWavesSpec({ ...base, plan: ["p", "{plan}"] })).not.toThrow();
112
+ expect(() => assertOpWavesSpec({ ...base, planWave: ["p", "{runs}", "{plans}"], applyWave: ["a", "{runs}"] })).not.toThrow();
113
+ expect(() => assertOpWavesSpec({ ...base, plan: ["p", "{plan}"], planWave: ["p", "{plans}"] })).toThrow(/both `plan` and `planWave`/);
114
+ expect(() => assertOpWavesSpec({ ...base, planWave: ["p", "{runs}"] })).toThrow(/never names \{plans\}/);
115
+ expect(() => assertOpWavesSpec({ ...base, planWave: ["p", "{plans}"], apply: ["a"], applyWave: ["a", "{runs}"] })).toThrow(/both `apply` and `applyWave`/);
116
+ expect(() => assertOpWavesSpec(base as OpWavesSpec)).toThrow(/give `plan` or `planWave`/);
117
+ const hooked = (hooks: unknown): OpWavesSpec => ({ ...base, plan: ["p", "{plan}"], hooks: hooks as OpWavesSpec["hooks"] });
118
+ expect(() => assertOpWavesSpec(hooked({ before: { plan: [{ command: ["check", "{runs}"], onFailure: "approve" }] }, after: { apply: [{ command: ["smoke", "{target}"], perRun: true }] } }))).not.toThrow();
119
+ expect(() => assertOpWavesSpec(hooked({ after: { apply: [{ command: ["smoke"], onFailure: "approve" }] } }))).toThrow(/nothing left to hold/);
120
+ expect(() => assertOpWavesSpec(hooked({ before: { init: [{ command: ["x"] }] } }))).toThrow(/hooks.before.init is not a stage/);
121
+ expect(() => assertOpWavesSpec(hooked({ during: {} }))).toThrow(/hooks.during is not a moment/);
122
+ expect(() => assertOpWavesSpec(hooked({ after: { plan: [{ command: ["x", "{plan}"] }] } }))).toThrow(/runs once for the wave/);
123
+ expect(() => assertOpWavesSpec(hooked({ before: { plan: [{ command: ["x", "{plan}"], perRun: true }] } }))).toThrow(/no \{plan\} yet/);
124
+ expect(() => assertOpWavesSpec(hooked({ before: { plan: [{ command: ["x"], onFailure: "warn" }] } }))).toThrow(/onFailure "warn"/);
125
+ expect(() => assertOpWavesSpec({ ...base, plan: ["p", "{plan}"], waves: [{ name: "dev", runs: [{ target: "a" }], hooks: { before: { apply: [{ command: [] }] } } }] })).toThrow(/wave "dev".*argv/);
126
+ });
127
+ });
128
+
129
+ describe("runOpWave with wave-level commands (#3762)", () => {
130
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
131
+ const waveSpec: OpWavesSpec = {
132
+ name: "roots",
133
+ op: "tf",
134
+ planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
135
+ applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
136
+ waves: [
137
+ { name: "net", runs: [{ target: "vpc" }, { target: "dns" }], gate: "on-destructive" },
138
+ { name: "apps", runs: [{ target: "a1" }, { target: "a2" }, { target: "a3" }], gate: "never", shares: 2 },
139
+ ],
140
+ };
141
+ function harness(plans: Record<string, { planDigest: string; destructive?: boolean; empty?: boolean }>, atBase: OpWavesSpec | null = waveSpec) {
142
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-wave-"));
143
+ const calls: string[] = [];
144
+ const applied: OpWaveRunsFile[] = [];
145
+ const exec: OpWaveExec = (argv, dir) => {
146
+ calls.push(argv.join(" "));
147
+ const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
148
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: runs.runs.map((r) => ({ target: r.target, ...plans[r.target] })) }));
149
+ if (argv[1] === "apply") applied.push(runs);
150
+ return 0;
151
+ };
152
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
153
+ return { cwd, calls, applied, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
154
+ }
155
+
156
+ test("one call plans the wave; the digest and the gate are those of per-run plans; the apply gets the approved set", async () => {
157
+ const plans = { vpc: { planDigest: digest("1"), destructive: true }, dns: { planDigest: digest("2") } };
158
+ const h = harness(plans);
159
+ const set = waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "dns", planDigest: digest("2") }]);
160
+ const waiting = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
161
+ expect(waiting).toMatchObject({ exitCode: 3, decision: { digest: set, status: "waiting", policy: "on-destructive" } });
162
+ expect(h.calls).toEqual([`tg plan net .chant/op-waves/roots/wave-1/runs.json .chant/op-waves/roots/wave-1/plans.json`]);
163
+ // Each run's plan file is written too, so a per-run apply or hook reads {plan} either way.
164
+ expect(JSON.parse(readFileSync(join(h.cwd, ".chant/op-waves/roots/wave-1/vpc.plan.json"), "utf-8"))).toEqual({ planDigest: digest("1"), destructive: true });
165
+
166
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "roots", gate: "roots-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
167
+ const approved = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
168
+ expect(approved).toMatchObject({ exitCode: 0, applied: ["vpc", "dns"], decision: { status: "approved", approvedBy: "alice" } });
169
+ expect(h.applied).toEqual([
170
+ {
171
+ wave: "net",
172
+ number: 1,
173
+ digest: set,
174
+ runs: [
175
+ { target: "vpc", op: "tf", planDigest: digest("1"), destructive: true },
176
+ { target: "dns", op: "tf", planDigest: digest("2") },
177
+ ],
178
+ },
179
+ ]);
180
+ h.done();
181
+ });
182
+
183
+ test("a plans file that misses a run, or plans one it was not asked to, is refused", async () => {
184
+ const h = harness({ vpc: { planDigest: digest("1") } });
185
+ await expect(runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/no planDigest for dns|no plan for dns/);
186
+ h.done();
187
+ const extra = harness({});
188
+ const exec: OpWaveExec = (argv, dir) => (writeFileSync(join(dir, argv[4]!), JSON.stringify([{ target: "vpc", planDigest: digest("1") }, { target: "dns", planDigest: digest("2") }, { target: "zzz", planDigest: digest("3") }])), 0);
189
+ await expect(runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 1, cwd: extra.cwd, exec, show: extra.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/"zzz", which is not a run/);
190
+ extra.done();
191
+ });
192
+
193
+ test("shares keep working: each share plans its slice in one call and applies only the runs whose plan did not move", async () => {
194
+ const plans: Record<string, { planDigest: string }> = { a1: { planDigest: digest("1") }, a2: { planDigest: digest("2") }, a3: { planDigest: digest("3") } };
195
+ const h = harness(plans);
196
+ const decided = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 2, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
197
+ expect(decided).toMatchObject({ exitCode: 0, applied: [], decision: { status: "not-required" } });
198
+ plans.a2 = { planDigest: digest("9") };
199
+ const share = await runOpWave({ spec: waveSpec, specFile: "waves.json", wave: 2, share: 1, cwd: h.cwd, exec: h.exec, show: h.show });
200
+ expect(share).toMatchObject({ exitCode: 4, applied: ["a1"], moved: ["a2"] });
201
+ expect(h.applied.at(-1)!.runs.map((r) => r.target)).toEqual(["a1"]);
202
+ h.done();
203
+ });
204
+
205
+ test("--record-plans plans each wave with one call", async () => {
206
+ const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") }, a1: { planDigest: digest("1") }, a2: { planDigest: digest("2") }, a3: { planDigest: digest("3") } });
207
+ const plans = await recordOpWaveHeadPlans({ spec: waveSpec, cwd: h.cwd, exec: h.exec, head: "c".repeat(40), now: "t", write: async () => {} });
208
+ expect(plans.waves[0]!.digest).toBe(waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "dns", planDigest: digest("2") }]));
209
+ expect(h.calls.filter((c) => c.startsWith("tg plan"))).toHaveLength(2);
210
+ h.done();
211
+ });
212
+ });
213
+
214
+ describe("runOpWave hooks (#3762)", () => {
215
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
216
+ const hooked: OpWavesSpec = {
217
+ name: "migrations",
218
+ op: "migrate",
219
+ plan: ["tool", "plan", "{target}", "{plan}"],
220
+ apply: ["tool", "apply", "{target}", "{plan}"],
221
+ hooks: { before: { plan: [{ name: "lint", command: ["hook", "lint", "{runs}"] }] } },
222
+ waves: [
223
+ {
224
+ name: "prod",
225
+ runs: [{ target: "t1" }, { target: "t2" }],
226
+ gate: "never",
227
+ hooks: {
228
+ after: { plan: [{ name: "check", command: ["hook", "check", "{target}", "{plan}"], perRun: true, onFailure: "approve" }], apply: [{ name: "smoke", command: ["hook", "smoke", "{wave}"] }] },
229
+ before: { apply: [{ name: "backup", command: ["hook", "backup", "{runs}"] }] },
230
+ },
231
+ },
232
+ ],
233
+ };
234
+ function harness(atBase: OpWavesSpec | null, fails: string[] = []) {
235
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-hooks-"));
236
+ const calls: Array<{ argv: string; env?: Record<string, string> }> = [];
237
+ const exec: OpWaveExec = (argv, dir, env) => {
238
+ calls.push({ argv: argv.join(" "), ...(env ? { env } : {}) });
239
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest(argv[2] === "t1" ? "1" : "2") }));
240
+ return fails.some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
241
+ };
242
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
243
+ return { cwd, calls, exec, show, argv: () => calls.map((c) => c.argv), done: () => rmSync(cwd, { recursive: true, force: true }) };
244
+ }
245
+ const set = waveSetDigest([{ member: "t1", planDigest: digest("1") }, { member: "t2", planDigest: digest("2") }]);
246
+
247
+ test("hooks run around the plan and the apply, per run with {target}, {plan} and the CHANT_ variables", async () => {
248
+ const h = harness(hooked);
249
+ const result = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
250
+ expect(result).toMatchObject({ exitCode: 0, applied: ["t1", "t2"], decision: { status: "not-required" } });
251
+ expect(h.argv()).toEqual([
252
+ "hook lint .chant/op-waves/migrations/wave-1/runs.json",
253
+ "tool plan t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
254
+ "tool plan t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
255
+ "hook check t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
256
+ "hook check t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
257
+ "hook backup .chant/op-waves/migrations/wave-1/runs.json",
258
+ "tool apply t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
259
+ "tool apply t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
260
+ "hook smoke prod",
261
+ ]);
262
+ expect(h.calls[3]!.env).toMatchObject({ CHANT_OP_WAVES: "migrations", CHANT_WAVE: "prod", CHANT_WAVE_NUMBER: "1", CHANT_HOOK: "after-plan", CHANT_TARGET: "t1", CHANT_PLAN: ".chant/op-waves/migrations/wave-1/t1.plan.json" });
263
+ expect(result.hooks.map((o) => `${o.moment} ${o.name}${o.target ? ` ${o.target}` : ""} ${o.outcome}`)).toEqual([
264
+ "before-plan lint passed",
265
+ "after-plan check t1 passed",
266
+ "after-plan check t2 passed",
267
+ "before-apply backup passed",
268
+ "after-apply smoke passed",
269
+ ]);
270
+ h.done();
271
+ });
272
+
273
+ test("hooks are read at base: a change cannot add a hook its own apply runs, nor remove one", async () => {
274
+ const added = harness({ ...hooked, hooks: undefined, waves: hooked.waves.map((w) => ({ ...w, hooks: undefined })) });
275
+ const logs: string[] = [];
276
+ await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: added.cwd, exec: added.exec, show: added.show, gates: memoryGateLedgerPort(), log: (l) => logs.push(l) });
277
+ expect(added.argv().filter((c) => c.startsWith("hook"))).toEqual([]);
278
+ expect(logs.some((l) => l.includes("this change's differ; the ones at base run"))).toBe(true);
279
+ added.done();
280
+
281
+ const removed = harness(hooked);
282
+ const bare = { ...hooked, hooks: undefined, waves: hooked.waves.map((w) => ({ ...w, hooks: undefined })) };
283
+ await runOpWave({ spec: bare, specFile: "waves.json", wave: 1, cwd: removed.cwd, exec: removed.exec, show: removed.show, gates: memoryGateLedgerPort() });
284
+ expect(removed.argv().filter((c) => c.startsWith("hook"))).toHaveLength(5);
285
+ removed.done();
286
+
287
+ const absent = harness(null);
288
+ await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: absent.cwd, exec: absent.exec, show: absent.show, gates: memoryGateLedgerPort() });
289
+ expect(absent.argv().filter((c) => c.startsWith("hook"))).toEqual([]);
290
+ absent.done();
291
+ });
292
+
293
+ test("an approve hook that fails holds a `never` wave until its set digest is approved", async () => {
294
+ const h = harness(hooked, ["hook check t2"]);
295
+ const gates = memoryGateLedgerPort();
296
+ const held = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
297
+ expect(held).toMatchObject({ exitCode: 3, applied: [], decision: { policy: "never", status: "waiting", digest: set, heldBy: ["after-plan hook check (t2)"] } });
298
+ expect(gates.appended[0]!.planDigest).toBe(set);
299
+ expect(h.argv().some((c) => c.startsWith("tool apply") || c.startsWith("hook backup"))).toBe(false);
300
+ h.done();
301
+
302
+ const again = harness(hooked, ["hook check t2"]);
303
+ const approved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "migrations", gate: "migrations-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
304
+ const applied = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: again.cwd, exec: again.exec, show: again.show, gates: approved });
305
+ expect(applied).toMatchObject({ exitCode: 0, applied: ["t1", "t2"], decision: { status: "approved", approvedBy: "alice" } });
306
+ again.done();
307
+ });
308
+
309
+ test("a failing hook fails the wave: before the plan nothing is planned, after the apply the wave exits 1", async () => {
310
+ const early = harness(hooked, ["hook lint"]);
311
+ const r1 = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: early.cwd, exec: early.exec, show: early.show, gates: memoryGateLedgerPort() });
312
+ expect(r1).toMatchObject({ exitCode: 1, applied: [] });
313
+ expect(early.argv()).toEqual(["hook lint .chant/op-waves/migrations/wave-1/runs.json"]);
314
+ early.done();
315
+
316
+ const late = harness(hooked, ["hook smoke"]);
317
+ const r2 = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: late.cwd, exec: late.exec, show: late.show, gates: memoryGateLedgerPort() });
318
+ expect(r2).toMatchObject({ exitCode: 1, applied: ["t1", "t2"] });
319
+ late.done();
320
+ });
321
+
322
+ test("an approve hook before the apply holds a wave the policy let through", async () => {
323
+ const spec: OpWavesSpec = { ...hooked, hooks: undefined, waves: [{ ...hooked.waves[0]!, hooks: { before: { apply: [{ name: "drain", command: ["hook", "drain"], onFailure: "approve" }] } } }] };
324
+ const h = harness(spec, ["hook drain"]);
325
+ const gates = memoryGateLedgerPort();
326
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
327
+ expect(result).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", heldBy: ["before-apply hook drain"] } });
328
+ expect(gates.appended[0]!.planDigest).toBe(set);
329
+ h.done();
330
+ });
331
+
332
+ test("hooks at base that no runner can run hold the wave and none run", async () => {
333
+ const broken = { ...hooked, hooks: { before: { plan: [{ command: "lint" }] } } } as unknown as OpWavesSpec;
334
+ const h = harness(broken);
335
+ const result = await runOpWave({ spec: hooked, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
336
+ expect(result.exitCode).toBe(3);
337
+ expect(result.decision!.heldBy![0]).toMatch(/the hooks at base cannot be run/);
338
+ expect(h.argv().filter((c) => c.startsWith("hook"))).toEqual([]);
339
+ h.done();
340
+ });
341
+
342
+ test("a wide wave's shares run the hooks their deciding job read at base", async () => {
343
+ const wide: OpWavesSpec = { ...hooked, waves: [{ ...hooked.waves[0]!, shares: 2 }] };
344
+ const h = harness(wide);
345
+ await runOpWave({ spec: { ...wide, hooks: undefined }, specFile: "waves.json", wave: 1, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
346
+ const before = h.calls.length;
347
+ const share = await runOpWave({ spec: { ...wide, hooks: undefined }, specFile: "waves.json", wave: 1, share: 2, cwd: h.cwd, exec: h.exec, show: h.show });
348
+ expect(share).toMatchObject({ exitCode: 0, applied: ["t2"] });
349
+ expect(h.argv().slice(before)).toEqual([
350
+ "hook lint .chant/op-waves/migrations/wave-1/runs.json",
351
+ "tool plan t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
352
+ "hook check t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
353
+ "hook backup .chant/op-waves/migrations/wave-1/runs.json",
354
+ "tool apply t2 .chant/op-waves/migrations/wave-1/t2.plan.json",
355
+ "hook smoke prod",
356
+ ]);
357
+ h.done();
358
+ });
359
+
360
+ test("a hold in a share job waits unless the wave was approved", async () => {
361
+ const wide: OpWavesSpec = { ...hooked, waves: [{ ...hooked.waves[0]!, shares: 2 }] };
362
+ const h = harness(wide);
363
+ await runOpWave({ spec: wide, specFile: "waves.json", wave: 1, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
364
+ const failing: OpWaveExec = (argv, dir, env) => (h.exec(argv, dir, env), argv.join(" ").startsWith("hook check") ? 1 : 0);
365
+ const gates = memoryGateLedgerPort();
366
+ const share = await runOpWave({ spec: wide, specFile: "waves.json", wave: 1, share: 1, cwd: h.cwd, exec: failing, show: h.show, gates });
367
+ expect(share).toMatchObject({ exitCode: 3, applied: [] });
368
+ expect(gates.appended[0]!.planDigest).toBe(set);
369
+ h.done();
370
+ });
371
+ });
372
+
373
+ describe("spent approvals (#3784)", () => {
374
+ const spending: OpWavesSpec = { ...spec, approvals: { spend: true } };
375
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
376
+ const set = waveSetDigest([{ member: "staging", planDigest: digest("a") }]);
377
+ const approval = { version: 1 as const, op: "migrations", gate: "migrations-wave-2", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set };
378
+ function harness(atBase: OpWavesSpec | null) {
379
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-spend-"));
380
+ const calls: string[] = [];
381
+ const exec: OpWaveExec = (argv, dir) => {
382
+ calls.push(argv.join(" "));
383
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: digest(argv[2] === "staging" ? "a" : "b"), destructive: true }));
384
+ return 0;
385
+ };
386
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
387
+ return { cwd, calls, exec, show, applies: () => calls.filter((c) => c.startsWith("tool apply")).length, done: () => rmSync(cwd, { recursive: true, force: true }) };
388
+ }
389
+
390
+ test("the spec checks approvals", () => {
391
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { spend: "yes" } } as unknown as OpWavesSpec)).toThrow(/approvals.spend must be true or false/);
392
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { burn: true } } as unknown as OpWavesSpec)).toThrow(/approvals.burn is not a setting/);
393
+ expect(() => assertOpWavesSpec(spending)).not.toThrow();
394
+ });
395
+
396
+ test("with spend at base, an approval passes the same wave once: the second run waits for a new approval", async () => {
397
+ const h = harness(spending);
398
+ const gates = memoryGateLedgerPort({ resolutions: [approval] });
399
+ const now = "2026-10-11T00:00:00Z";
400
+ const first = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now });
401
+ expect(first).toMatchObject({ exitCode: 0, applied: ["staging"], decision: { status: "approved", approvedBy: "alice", approvedAt: approval.timestamp, spend: "base", spentAt: now } });
402
+ expect(gates.applied).toEqual([
403
+ { version: 1, kind: "applied", op: "migrations", gate: "migrations-wave-2", planDigest: set, approvedAt: approval.timestamp, approvedBy: "alice", timestamp: now, wave: { number: 2, name: "staging" } },
404
+ ]);
405
+
406
+ const logs: string[] = [];
407
+ const second = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T01:00:00Z", log: (l) => logs.push(l) });
408
+ expect(second).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting" } });
409
+ expect(h.applies()).toBe(1);
410
+ expect(logs.some((l) => /the approval of .* by alice at 2026-10-10T00:00:00Z is spent/.test(l))).toBe(true);
411
+ expect(gates.appended.at(-1)!.planDigest).toBe(set);
412
+
413
+ // A new approval of the same digest passes it once more, and is spent in turn.
414
+ await gates.appendResolution!({ op: "migrations", gate: "migrations-wave-2", resolvedBy: "bob", timestamp: "2026-10-11T02:00:00Z", planDigest: set });
415
+ const third = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T03:00:00Z" });
416
+ expect(third).toMatchObject({ exitCode: 0, decision: { approvedBy: "bob" } });
417
+ expect(gates.applied).toHaveLength(2);
418
+ h.done();
419
+ });
420
+
421
+ test("without spend at base, the approval passes the digest every time, and only a change that adds spend records it", async () => {
422
+ const off = harness(spec);
423
+ const gates = memoryGateLedgerPort({ resolutions: [approval] });
424
+ for (const now of ["2026-10-11T00:00:00Z", "2026-10-11T01:00:00Z"]) {
425
+ expect(await runOpWave({ spec, specFile: "waves.json", wave: 2, cwd: off.cwd, exec: off.exec, show: off.show, gates, now })).toMatchObject({ exitCode: 0 });
426
+ }
427
+ expect(gates.applied).toEqual([]);
428
+ off.done();
429
+
430
+ // The change turns spend on: this apply is recorded, and the rule holds from the next decision at base.
431
+ const adding = harness(spec);
432
+ const added = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: adding.cwd, exec: adding.exec, show: adding.show, gates, now: "2026-10-11T02:00:00Z" });
433
+ expect(added).toMatchObject({ exitCode: 0, decision: { spend: "change" } });
434
+ expect(gates.applied).toHaveLength(1);
435
+ adding.done();
436
+ const next = harness(spending);
437
+ expect(await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: next.cwd, exec: next.exec, show: next.show, gates, now: "2026-10-11T03:00:00Z" })).toMatchObject({ exitCode: 3 });
438
+ next.done();
439
+ });
440
+
441
+ test("a change cannot turn spend off for its own apply", async () => {
442
+ const h = harness(spending);
443
+ const gates = memoryGateLedgerPort({ resolutions: [approval] });
444
+ await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
445
+ const again = await runOpWave({ spec, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T01:00:00Z" });
446
+ expect(again.exitCode).toBe(3);
447
+ h.done();
448
+ });
449
+
450
+ test("a spent approval of another digest does not refuse; the wave waits for its own", async () => {
451
+ const h = harness(spending);
452
+ const other = { ...approval, planDigest: "sha256:" + "f".repeat(64) };
453
+ const gates = memoryGateLedgerPort({
454
+ resolutions: [other],
455
+ applied: [{ version: 1, kind: "applied", op: "migrations", gate: "migrations-wave-2", planDigest: other.planDigest, approvedAt: other.timestamp, approvedBy: "alice", timestamp: "2026-10-10T01:00:00Z" }],
456
+ });
457
+ const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
458
+ expect(result).toMatchObject({ exitCode: 3, decision: { status: "waiting" } });
459
+ h.done();
460
+ });
461
+
462
+ test("a wide wave's deciding job spends the approval once, before its shares apply", async () => {
463
+ const wide: OpWavesSpec = { ...spending, waves: spending.waves.map((w) => (w.name === "prod" ? { ...w, gate: "always" as const } : w)) };
464
+ const h = harness(wide);
465
+ const prodSet = waveSetDigest(["t1", "t2", "t3"].map((t) => ({ member: t, planDigest: digest("b") })));
466
+ const gates = memoryGateLedgerPort({ resolutions: [{ ...approval, gate: "migrations-wave-3", planDigest: prodSet }] });
467
+ const decided = await runOpWave({ spec: wide, specFile: "waves.json", wave: 3, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
468
+ expect(decided).toMatchObject({ exitCode: 0, decision: { status: "approved", spentAt: "2026-10-11T00:00:00Z" } });
469
+ expect(gates.applied).toHaveLength(1);
470
+ for (const share of [1, 2]) {
471
+ expect(await runOpWave({ spec: wide, specFile: "waves.json", wave: 3, share, cwd: h.cwd, exec: h.exec, show: h.show, gates })).toMatchObject({ exitCode: 0 });
472
+ }
473
+ expect(gates.applied).toHaveLength(1);
474
+ expect(h.applies()).toBe(3);
475
+ h.done();
476
+ });
477
+
478
+ test("a wave that cannot record the approval applies nothing", async () => {
479
+ const h = harness(spending);
480
+ const gates = { ...memoryGateLedgerPort({ resolutions: [approval] }), appendApplied: undefined };
481
+ const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates });
482
+ expect(result).toMatchObject({ exitCode: 1, applied: [] });
483
+ expect(h.applies()).toBe(0);
484
+ h.done();
485
+ });
486
+ });
487
+
488
+ describe("apply-stage hooks run by the apply, under its lock (#3804)", () => {
489
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
490
+ const spec: OpWavesSpec = {
491
+ name: "migrations",
492
+ op: "migrate",
493
+ plan: ["tool", "plan", "{target}", "{plan}"],
494
+ apply: ["tool", "apply", "{target}", "{plan}"],
495
+ hooks: { apply: { runBy: "apply" }, before: { plan: [{ name: "lint", command: ["hook", "lint"] }] } },
496
+ waves: [
497
+ {
498
+ name: "prod",
499
+ runs: [{ target: "t1" }],
500
+ gate: "never",
501
+ hooks: {
502
+ before: { apply: [{ name: "lag", command: ["hook", "lag", "{target}"], perRun: true, onFailure: "approve" }] },
503
+ after: { apply: [{ name: "smoke", command: ["hook", "smoke", "{wave}"] }] },
504
+ },
505
+ },
506
+ ],
507
+ };
508
+
509
+ /**
510
+ * A job whose apply command is a product that takes its lock and runs the
511
+ * hooks it was handed, as SQL Yodeler's apply would. `obey: false` is an
512
+ * apply that ignores the hooks file.
513
+ */
514
+ function harness(atBase: OpWavesSpec | null, opts: { fails?: string[]; obey?: boolean } = {}) {
515
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-by-apply-"));
516
+ const calls: string[] = [];
517
+ const files: OpWaveHooksFile[] = [];
518
+ const inner: OpWaveExec = (argv) => {
519
+ calls.push(` ${argv.join(" ")}`);
520
+ return (opts.fails ?? []).some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
521
+ };
522
+ const exec: OpWaveExec = (argv, dir, env) => {
523
+ calls.push(argv.join(" "));
524
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest("1") }));
525
+ if (argv[1] === "apply") {
526
+ const file = readOpWaveHooksFile(env?.CHANT_HOOKS, dir)!;
527
+ files.push(file);
528
+ if (opts.obey === false) return 0;
529
+ calls.push(" lock");
530
+ const before = runOpWaveApplyHooks(file, "before-apply", { cwd: dir, exec: inner, log: () => {} });
531
+ if (!before.proceed) return before.held.length > 0 ? 3 : 4;
532
+ calls.push(" migrate");
533
+ const after = runOpWaveApplyHooks(file, "after-apply", { cwd: dir, exec: inner, log: () => {} });
534
+ return after.proceed ? 0 : 1;
535
+ }
536
+ return (opts.fails ?? []).some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
537
+ };
538
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
539
+ const record = () => JSON.parse(readFileSync(join(cwd, ".chant/op-waves/migrations/wave-1.json"), "utf-8")) as OpWaveDecision;
540
+ return { cwd, calls, files, exec, show, record, done: () => rmSync(cwd, { recursive: true, force: true }) };
541
+ }
542
+ const set = waveSetDigest([{ member: "t1", planDigest: digest("1") }]);
543
+
544
+ test("the spec checks hooks.apply.runBy and keeps the rest as it was", () => {
545
+ const base = { name: "w", op: "o", plan: ["p", "{plan}"], waves: [{ name: "dev", runs: [{ target: "a" }] }] };
546
+ expect(() => assertOpWavesSpec({ ...base, hooks: { apply: { runBy: "apply" } } })).not.toThrow();
547
+ expect(() => assertOpWavesSpec({ ...base, hooks: { apply: { runBy: "later" } } } as unknown as OpWavesSpec)).toThrow(/hooks.apply.runBy is "later"/);
548
+ expect(() => assertOpWavesSpec({ ...base, hooks: { apply: [{ command: ["x"] }] } } as unknown as OpWavesSpec)).toThrow(/hooks.apply must be/);
549
+ });
550
+
551
+ test("chant hands the apply-stage hooks to the apply, which runs them under its lock; the outcomes come back", async () => {
552
+ const h = harness(spec);
553
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
554
+ expect(result).toMatchObject({ exitCode: 0, applied: ["t1"], decision: { applyHooksRunBy: "apply" } });
555
+ expect(h.calls).toEqual([
556
+ "hook lint",
557
+ "tool plan t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
558
+ "tool apply t1 .chant/op-waves/migrations/wave-1/t1.plan.json",
559
+ " lock",
560
+ " hook lag t1",
561
+ " migrate",
562
+ " hook smoke prod",
563
+ ]);
564
+ // The apply sees the outcomes of the hooks chant ran before it, and the hooks it runs itself.
565
+ expect(h.files[0]).toMatchObject({ runBy: "apply", approved: false, digest: set, outcomes: [{ moment: "before-plan", name: "lint", outcome: "passed" }] });
566
+ expect(Object.keys(h.files[0]!.hooks)).toEqual(["before-apply", "after-apply"]);
567
+ expect(result.hooks.map((o) => `${o.moment} ${o.name} ${o.outcome}`)).toEqual(["before-plan lint passed", "before-apply lag passed", "after-apply smoke passed"]);
568
+ expect(h.record().outcomes).toHaveLength(3);
569
+ h.done();
570
+ });
571
+
572
+ test("a hold inside the apply applies nothing and waits; with an approval of the digest the apply is called again and goes on", async () => {
573
+ const h = harness(spec, { fails: ["hook lag"] });
574
+ const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
575
+ expect(waiting).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", heldBy: ["before-apply hook lag (t1)"] } });
576
+ expect(h.calls).not.toContain(" migrate");
577
+ h.done();
578
+
579
+ const again = harness(spec, { fails: ["hook lag"] });
580
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "migrations", gate: "migrations-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
581
+ const applied = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: again.cwd, exec: again.exec, show: again.show, gates });
582
+ expect(applied).toMatchObject({ exitCode: 0, applied: ["t1"], decision: { status: "approved", approvedBy: "alice" } });
583
+ expect(again.calls.filter((c) => c === " lock")).toHaveLength(2);
584
+ expect(again.calls.filter((c) => c === " migrate")).toHaveLength(1);
585
+ // The second call knows the approval stands, and which hook held the wave.
586
+ expect(again.files[1]).toMatchObject({ approved: true, heldBy: ["before-apply hook lag (t1)"] });
587
+ expect(applied.hooks.filter((o) => o.name === "lag").map((o) => o.outcome)).toEqual(["held", "held"]);
588
+ again.done();
589
+ });
590
+
591
+ test("an after-apply failure inside the apply counts the run applied and fails the wave", async () => {
592
+ const h = harness(spec, { fails: ["hook smoke"] });
593
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
594
+ expect(result).toMatchObject({ exitCode: 1, applied: ["t1"], failed: [] });
595
+ expect(result.hooks.at(-1)).toMatchObject({ moment: "after-apply", name: "smoke", outcome: "failed" });
596
+ h.done();
597
+ });
598
+
599
+ test("an apply that ignores the hooks it was handed fails the wave", async () => {
600
+ const h = harness(spec, { obey: false });
601
+ const logs: string[] = [];
602
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: (l) => logs.push(l) });
603
+ expect(result).toMatchObject({ exitCode: 1, applied: ["t1"] });
604
+ expect(logs.join("\n")).toMatch(/without running its before-apply and after-apply hooks/);
605
+ h.done();
606
+ });
607
+
608
+ test("runBy is read at base: a change that sets it still has chant run the hooks, and the apply gets its outcomes", async () => {
609
+ const atBase: OpWavesSpec = { ...spec, hooks: { before: spec.hooks!.before } };
610
+ const h = harness(atBase);
611
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
612
+ expect(result.exitCode).toBe(0);
613
+ expect(h.calls.slice(0, 4)).toEqual(["hook lint", "tool plan t1 .chant/op-waves/migrations/wave-1/t1.plan.json", "hook lag t1", "tool apply t1 .chant/op-waves/migrations/wave-1/t1.plan.json"]);
614
+ expect(h.files[0]).toMatchObject({ runBy: "chant", hooks: {} });
615
+ expect(h.files[0]!.outcomes.map((o) => o.name)).toEqual(["lint", "lag"]);
616
+ h.done();
617
+ });
618
+
619
+ test("an apply outside the waves reads the same hooks at base and runs them itself; a hold stops it", () => {
620
+ const h = harness(spec);
621
+ writeFileSync(join(h.cwd, "waves.json"), JSON.stringify({ ...spec, hooks: undefined, waves: [{ ...spec.waves[0]!, hooks: undefined }] }));
622
+ const file = opWaveHooksAtBase({ specFile: "waves.json", wave: "prod", cwd: h.cwd, show: h.show, base: "HEAD^1" });
623
+ expect(file).toMatchObject({ opWaves: "migrations", wave: "prod", number: 1, approved: false, runBy: "apply", runs: [{ target: "t1", op: "migrate" }] });
624
+ expect(Object.keys(file.hooks)).toEqual(["before-plan", "before-apply", "after-apply"]);
625
+ const ran: string[] = [];
626
+ const exec: OpWaveExec = (argv) => (ran.push(argv.join(" ")), argv[1] === "lag" ? 1 : 0);
627
+ const before = runOpWaveApplyHooks(file, "before-apply", { cwd: h.cwd, exec, log: () => {} });
628
+ expect(before).toMatchObject({ proceed: false, held: ["before-apply hook lag (t1)"], outcomes: [{ name: "lag", target: "t1", exit: 1, outcome: "held" }] });
629
+ expect(runOpWaveApplyHooks(file, "after-plan", { cwd: h.cwd, exec, log: () => {} })).toMatchObject({ proceed: true, outcomes: [] });
630
+ expect(ran).toEqual(["hook lag t1"]);
631
+ const absent = opWaveHooksAtBase({ specFile: "waves.json", wave: "prod", cwd: h.cwd, show: () => ({ sha: "c".repeat(40), text: null }) });
632
+ expect(absent.hooks).toEqual({});
633
+ h.done();
634
+ });
635
+ });