@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
@@ -33,9 +33,28 @@
33
33
  * against the signers file at base) or `pr-review` (also the merged pull
34
34
  * request's review of its head, by a writer other than its author, when the
35
35
  * head planned the same digest). See `./gate-review.ts`.
36
+ *
37
+ * ## Spent approvals (#3784)
38
+ *
39
+ * With `approvals: { spend: true }` (read at base, like the policy), a wave
40
+ * records each approval it applies under in `_gates/<name>/applied.jsonl` on
41
+ * `chant/lifecycle`, as terragucci's wave gate does, and that approval is
42
+ * spent: the same digest needs a new approval to apply again.
43
+ *
44
+ * ## Wave-level commands and hooks (#3762)
45
+ *
46
+ * A product whose tool plans a whole wave in one call (Terragrunt's `run
47
+ * --all`, an apply by rows) gives `planWave` and `applyWave` in place of the
48
+ * per-run `plan` and `apply`: one call plans every run and lists each run's
49
+ * plan, and the wave's digest and gate come from that list as from per-run
50
+ * plans. Stage hooks (`hooks`) run commands before and after the plan and the
51
+ * apply; like the policy they are read from the spec at the base commit, and
52
+ * one whose `onFailure` is `approve` holds the wave for an approval whatever
53
+ * its policy says.
36
54
  */
37
55
 
38
56
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
57
+ import { notifyConfigProblem, type NotifyConfig } from "../notify";
39
58
 
40
59
  /** When a wave waits for an approval. */
41
60
  export type OpWaveGatePolicy =
@@ -59,6 +78,53 @@ export type GateApprovalSource =
59
78
 
60
79
  export const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[] = ["ledger", "pr-review", "sealed"];
61
80
 
81
+ /** What a failing hook does: fail the wave, or hold it for an approval of its set digest. */
82
+ export type OpWaveHookFailure = "fail" | "approve";
83
+
84
+ export const OP_WAVE_HOOK_FAILURES: readonly OpWaveHookFailure[] = ["fail", "approve"];
85
+
86
+ /** One stage hook (#3762): a command run before or after a wave's plan or apply. */
87
+ export interface OpWaveHook {
88
+ /** The hook's name in the log and the result. Default: its command. */
89
+ name?: string;
90
+ /**
91
+ * Argv template. `{op}`, `{wave}` and `{runs}` (the runs file, see
92
+ * `opWaveRunsPath`) are filled in; a `perRun` hook also gets `{target}`
93
+ * and, once the run is planned, `{plan}`.
94
+ */
95
+ command: string[];
96
+ /** Default `"fail"`. `"approve"` holds the wave for an approval of its set digest whatever its policy says. Not allowed after apply. */
97
+ onFailure?: OpWaveHookFailure;
98
+ /** Run once per run of the wave, rather than once for the wave. */
99
+ perRun?: boolean;
100
+ }
101
+
102
+ /** The stages a hook runs around. */
103
+ export type OpWaveHookStage = "plan" | "apply";
104
+
105
+ /** When a hook runs. */
106
+ export type OpWaveHookMoment = "before-plan" | "after-plan" | "before-apply" | "after-apply";
107
+
108
+ export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "before-apply", "after-apply"];
109
+
110
+ /**
111
+ * Who runs the `before.apply` and `after.apply` hooks (#3804). `chant`: the
112
+ * wave job, around the apply command. `apply`: the apply command itself,
113
+ * under its own lock, through `runOpWaveApplyHooks` and the file named by
114
+ * `CHANT_HOOKS`; it writes the outcomes back to `CHANT_HOOKS_RESULT`.
115
+ */
116
+ export type OpWaveApplyHooksRunner = "chant" | "apply";
117
+
118
+ export const OP_WAVE_APPLY_HOOKS_RUNNERS: readonly OpWaveApplyHooksRunner[] = ["chant", "apply"];
119
+
120
+ /** Stage hooks (#3762), read from the spec at the base commit like the gate policy. */
121
+ export interface OpWaveHooks {
122
+ before?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
123
+ after?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
124
+ /** Who runs the apply-stage hooks (#3804). Default `{ runBy: "chant" }`. A wave's own setting wins over the spec's. */
125
+ apply?: { runBy?: OpWaveApplyHooksRunner };
126
+ }
127
+
62
128
  /** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
63
129
  export interface OpWaveRun {
64
130
  /** What the run applies to. Unique within its wave; it names the run in the set digest. */
@@ -94,6 +160,8 @@ export interface OpWave {
94
160
  variables?: Record<string, string>;
95
161
  /** Steps this wave's jobs run after the checkout, as `ScheduledOpSpec.setup`. */
96
162
  setup?: OpSetupStep[];
163
+ /** This wave's stage hooks (#3762), run after the spec's own. Read at the base commit. */
164
+ hooks?: OpWaveHooks;
97
165
  }
98
166
 
99
167
  /**
@@ -119,11 +187,29 @@ export interface OpWavesSpec {
119
187
  * Plan one run and write its plan file to `{plan}`: JSON `{ "planDigest":
120
188
  * string, "destructive"?: boolean, "empty"?: boolean }`. `destructive` is
121
189
  * what `on-destructive` waits on; a wave whose runs are all `empty` never
122
- * waits.
190
+ * waits. Give this or `planWave`.
123
191
  */
124
- plan: string[];
192
+ plan?: string[];
125
193
  /** Apply one run whose plan is at `{plan}`. Default `chant run {op} --env {target}`. */
126
194
  apply?: string[];
195
+ /**
196
+ * Plan every run of a wave in one call (#3762), in place of `plan`. `{runs}`
197
+ * is a file chant writes, `{ "wave", "number", "runs": [{ "target", "op" }] }`;
198
+ * the command writes `{plans}`: `{ "runs": [{ "target", "planDigest",
199
+ * "destructive"?, "empty"? }] }` (or the array alone), one entry per run.
200
+ */
201
+ planWave?: string[];
202
+ /**
203
+ * Apply a wave's approved set in one call (#3762), in place of `apply`.
204
+ * `{runs}` lists the runs to apply with the plan digest each was approved
205
+ * at; `{plans}` is the wave plan's file. A non-zero exit fails every run in
206
+ * the set.
207
+ */
208
+ applyWave?: string[];
209
+ /** Stage hooks for every wave (#3762), run before each wave's own. Read at the base commit. */
210
+ hooks?: OpWaveHooks;
211
+ /** What happens to an approval once a wave applies under it (#3784). Read at the base commit. */
212
+ approvals?: OpWavesApprovals;
127
213
  waves: OpWave[];
128
214
  /**
129
215
  * A scheduled job that starts again a wave job whose approval has arrived
@@ -133,6 +219,27 @@ export interface OpWavesSpec {
133
219
  * followed by `chant approve --resume` or a re-run by hand.
134
220
  */
135
221
  resume?: OpWavesResume;
222
+ /**
223
+ * Tell people when a wave waits for an approval, a share job refuses a run
224
+ * whose plan moved, or a wave fails (#3761): the environment variables
225
+ * holding a Slack or Teams incoming webhook's URL, or a generic webhook's
226
+ * URL and the key its `chant.notify/v1` events are signed with. A failed
227
+ * delivery is logged and never changes the job's exit code. See
228
+ * `../notify.ts`.
229
+ */
230
+ notify?: NotifyConfig;
231
+ }
232
+
233
+ /** What happens to an approval once a wave applies under it (#3784). */
234
+ export interface OpWavesApprovals {
235
+ /**
236
+ * Spend it. Before a wave applies under an approval it records that in
237
+ * `_gates/<name>/applied.jsonl` on `chant/lifecycle`, and from then on the
238
+ * approval counts for nothing: applying the same digest again, by a re-run
239
+ * or a revert and re-merge, waits for a new approval. Default false: an
240
+ * approval of a digest passes that digest as often as it is planned.
241
+ */
242
+ spend?: boolean;
136
243
  }
137
244
 
138
245
  /** The scheduled resume job of an Op waves pipeline (#3683). */
@@ -184,16 +291,50 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
184
291
  const where = `Op waves "${spec.name}"`;
185
292
  if (!NAME.test(spec.name)) throw new Error(`${where}: the name must be lowercase letters, digits, "-" and "_"`);
186
293
  if (!spec.op) throw new Error(`${where} names no \`op\`. Name the Op a run runs when it names none.`);
187
- if (!Array.isArray(spec.plan) || spec.plan.length === 0) {
188
- throw new Error(`${where} has no \`plan\` command. Each wave plans its runs to bind its gate to their plan digests.`);
294
+ if (spec.plan !== undefined && spec.planWave !== undefined) {
295
+ throw new Error(`${where} has both \`plan\` and \`planWave\`. A wave is planned one run at a time or all at once; give one.`);
189
296
  }
190
- if (!spec.plan.some((part) => part.includes("{plan}"))) {
191
- throw new Error(`${where}: the \`plan\` command never names {plan}, the file it writes the plan digest to.`);
297
+ if (spec.apply !== undefined && spec.applyWave !== undefined) {
298
+ throw new Error(`${where} has both \`apply\` and \`applyWave\`. A wave is applied one run at a time or all at once; give one.`);
192
299
  }
300
+ if (spec.planWave !== undefined) {
301
+ assertArgv(where, "planWave", spec.planWave);
302
+ if (!spec.planWave.some((part) => part.includes("{plans}"))) {
303
+ throw new Error(`${where}: the \`planWave\` command never names {plans}, the file it writes each run's plan digest to.`);
304
+ }
305
+ } else {
306
+ if (!Array.isArray(spec.plan) || spec.plan.length === 0) {
307
+ throw new Error(`${where} has no \`plan\` command. Each wave plans its runs to bind its gate to their plan digests; give \`plan\` or \`planWave\`.`);
308
+ }
309
+ assertArgv(where, "plan", spec.plan);
310
+ if (!spec.plan.some((part) => part.includes("{plan}"))) {
311
+ throw new Error(`${where}: the \`plan\` command never names {plan}, the file it writes the plan digest to.`);
312
+ }
313
+ }
314
+ if (spec.apply !== undefined) assertArgv(where, "apply", spec.apply);
315
+ if (spec.applyWave !== undefined) {
316
+ assertArgv(where, "applyWave", spec.applyWave);
317
+ if (!spec.applyWave.some((part) => part.includes("{runs}"))) {
318
+ throw new Error(`${where}: the \`applyWave\` command never names {runs}, the file listing the approved runs it applies.`);
319
+ }
320
+ }
321
+ assertOpWaveHooks(where, spec.hooks);
193
322
  if (spec.waves.length === 0) throw new Error(`${where} has no waves.`);
194
323
  if (spec.resume !== undefined && (typeof spec.resume.schedule !== "string" || spec.resume.schedule.trim().split(/\s+/).length !== 5)) {
195
324
  throw new Error(`${where}: resume.schedule must be a five-field cron expression, such as "*/10 * * * *".`);
196
325
  }
326
+ if (spec.approvals !== undefined) {
327
+ const a = spec.approvals as unknown;
328
+ if (a === null || typeof a !== "object" || Array.isArray(a)) throw new Error(`${where}: \`approvals\` must be { "spend"?: true | false }.`);
329
+ for (const [key, value] of Object.entries(a as Record<string, unknown>)) {
330
+ if (key !== "spend") throw new Error(`${where}: approvals.${key} is not a setting; the one there is approvals.spend.`);
331
+ if (typeof value !== "boolean") throw new Error(`${where}: approvals.spend must be true or false.`);
332
+ }
333
+ }
334
+ if (spec.notify !== undefined) {
335
+ const problem = notifyConfigProblem(spec.notify, "notify");
336
+ if (problem) throw new Error(`${where}: ${problem}`);
337
+ }
197
338
  const names = new Set<string>();
198
339
  for (const wave of spec.waves) {
199
340
  if (!NAME.test(wave.name)) {
@@ -207,6 +348,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
207
348
  if (wave.approval !== undefined && !GATE_APPROVAL_SOURCES.includes(wave.approval)) {
208
349
  throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
209
350
  }
351
+ assertOpWaveHooks(`${where}, wave "${wave.name}"`, wave.hooks);
210
352
  if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
211
353
  const targets = new Set<string>();
212
354
  for (const run of wave.runs) {
@@ -224,6 +366,73 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
224
366
  }
225
367
  }
226
368
 
369
+ function assertArgv(where: string, field: string, argv: unknown): void {
370
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((part) => typeof part === "string") || argv[0] === "") {
371
+ throw new Error(`${where}: \`${field}\` must be a command as argv, a non-empty array of strings.`);
372
+ }
373
+ }
374
+
375
+ /** Refuse hooks no runner could run, naming what is wrong (#3762). */
376
+ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined): void {
377
+ if (hooks === undefined) return;
378
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
379
+ throw new Error(`${where}: \`hooks\` must be { "before"?: { "plan"?, "apply"? }, "after"?: { "plan"?, "apply"? } }.`);
380
+ }
381
+ for (const [side, stages] of Object.entries(hooks as Record<string, unknown>)) {
382
+ if (side === "apply") {
383
+ const runBy = (stages as { runBy?: unknown } | null)?.runBy;
384
+ if (stages === null || typeof stages !== "object" || Array.isArray(stages) || Object.keys(stages).some((key) => key !== "runBy")) {
385
+ throw new Error(`${where}: hooks.apply must be { "runBy"?: "chant" | "apply" }.`);
386
+ }
387
+ if (runBy !== undefined && !OP_WAVE_APPLY_HOOKS_RUNNERS.includes(runBy as OpWaveApplyHooksRunner)) {
388
+ throw new Error(`${where}: hooks.apply.runBy is ${JSON.stringify(runBy)}; use ${OP_WAVE_APPLY_HOOKS_RUNNERS.map((r) => `"${r}"`).join(" or ")}.`);
389
+ }
390
+ continue;
391
+ }
392
+ if (side !== "before" && side !== "after") throw new Error(`${where}: hooks.${side} is not a moment; use hooks.before or hooks.after (or hooks.apply.runBy).`);
393
+ if (stages === null || typeof stages !== "object" || Array.isArray(stages)) throw new Error(`${where}: hooks.${side} must be { "plan"?: [...], "apply"?: [...] }.`);
394
+ for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
395
+ const at = `hooks.${side}.${stage}`;
396
+ if (stage !== "plan" && stage !== "apply") throw new Error(`${where}: ${at} is not a stage; a hook runs before or after plan or apply.`);
397
+ if (!Array.isArray(list)) throw new Error(`${where}: ${at} must be a list of hooks.`);
398
+ list.forEach((hook: OpWaveHook, i: number) => {
399
+ const which = `${at}[${i}]${hook && typeof hook === "object" && typeof hook.name === "string" ? ` (${hook.name})` : ""}`;
400
+ if (!hook || typeof hook !== "object") throw new Error(`${where}: ${which} must be { "command": [...], "onFailure"?, "perRun"? }.`);
401
+ assertArgv(where, `${which}.command`, hook.command);
402
+ if (hook.onFailure !== undefined && !OP_WAVE_HOOK_FAILURES.includes(hook.onFailure)) {
403
+ throw new Error(`${where}: ${which} has onFailure "${hook.onFailure}"; use ${OP_WAVE_HOOK_FAILURES.join(" or ")}.`);
404
+ }
405
+ if (hook.onFailure === "approve" && side === "after" && stage === "apply") {
406
+ throw new Error(`${where}: ${which} has onFailure "approve", but after the apply there is nothing left to hold. Use "fail".`);
407
+ }
408
+ if (hook.perRun !== undefined && typeof hook.perRun !== "boolean") throw new Error(`${where}: ${which}.perRun must be true or false.`);
409
+ if (!hook.perRun) {
410
+ const named = hook.command.find((part) => part.includes("{target}") || part.includes("{plan}"));
411
+ if (named) throw new Error(`${where}: ${which} runs once for the wave, so it has no {target} or {plan} ("${named}"). Set "perRun": true, or read the runs from {runs}.`);
412
+ } else if (side === "before" && stage === "plan" && hook.command.some((part) => part.includes("{plan}"))) {
413
+ throw new Error(`${where}: ${which} runs before the plan, so it has no {plan} yet.`);
414
+ }
415
+ });
416
+ }
417
+ }
418
+ }
419
+
420
+ /** The hooks of each moment for wave `name`: the spec's, then the wave's own. */
421
+ export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name: string): Record<OpWaveHookMoment, OpWaveHook[]> {
422
+ const wave = spec.waves.find((w) => w.name === name);
423
+ const at = (hooks: OpWaveHooks | undefined, moment: OpWaveHookMoment): OpWaveHook[] => {
424
+ const [side, stage] = moment.split("-") as ["before" | "after", OpWaveHookStage];
425
+ return hooks?.[side]?.[stage] ?? [];
426
+ };
427
+ return Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, [...at(spec.hooks, m), ...at(wave?.hooks, m)]])) as Record<OpWaveHookMoment, OpWaveHook[]>;
428
+ }
429
+
430
+ /** Who runs wave `name`'s apply-stage hooks (#3804): the wave's `hooks.apply.runBy`, else the spec's, else `chant`. */
431
+ export function opWaveApplyHooksRunBy(spec: Pick<OpWavesSpec, "hooks" | "waves">, name: string): OpWaveApplyHooksRunner {
432
+ const wave = spec.waves.find((w) => w.name === name);
433
+ return wave?.hooks?.apply?.runBy ?? spec.hooks?.apply?.runBy ?? "chant";
434
+ }
435
+
227
436
  /**
228
437
  * The runs share `share` (1-based) of `shares` applies: the wave's runs sorted
229
438
  * by target, cut into contiguous slices whose sizes differ by at most one.
@@ -429,6 +429,35 @@ describe("runOperatorForever — interval/timer behavior", () => {
429
429
  });
430
430
  }, 10_000);
431
431
 
432
+ test("releases the Ops' leases when it stops, so a restarted operator ticks at once (#2879)", async () => {
433
+ await withTestDir(async (dir) => {
434
+ await initRepo(dir);
435
+ writeFixtureConvergeOp(dir, "staging-converge", "staging");
436
+ const activities = fakeTickActivities(dir, "staging", "staging-converge");
437
+
438
+ const controller = new AbortController();
439
+ const rounds: unknown[] = [];
440
+ const loop = runOperatorForever({
441
+ cwd: dir,
442
+ holder: "op-a",
443
+ intervalMs: 40,
444
+ activities,
445
+ profiles: PROFILES,
446
+ signal: controller.signal,
447
+ onRound: () => rounds.push(1),
448
+ });
449
+ await waitForRoundCount(rounds, 1);
450
+ expect((await readLease("staging-converge", { cwd: dir })).record?.holder).toBe("op-a");
451
+
452
+ controller.abort();
453
+ await loop;
454
+ expect((await readLease("staging-converge", { cwd: dir })).record).toBeUndefined();
455
+
456
+ const events = await runOperatorRound({ cwd: dir, holder: "op-b", activities, profiles: PROFILES });
457
+ expect(events.map((e) => e.kind)).toEqual(["ticked"]);
458
+ });
459
+ }, 10_000);
460
+
432
461
  test("default interval is used when none is given", () => {
433
462
  expect(DEFAULT_OPERATOR_INTERVAL_MS).toBeGreaterThan(0);
434
463
  });
@@ -196,6 +196,17 @@ export function createBesideState(): BesideState {
196
196
  return { running: new Map(), started: new Map(), ended: [] };
197
197
  }
198
198
 
199
+ /**
200
+ * Release the per-Op leases a stopped operator still holds (#2879), best
201
+ * effort: a lease that is gone or taken over since is left alone, and one that
202
+ * can't be released expires on its TTL as before.
203
+ */
204
+ export async function releaseHeldLeases(held: Map<string, string>, holder: string, opts: { cwd?: string } = {}): Promise<void> {
205
+ const entries = [...held];
206
+ held.clear();
207
+ await Promise.allSettled(entries.map(([op, token]) => releaseLease(op, holder, token, opts)));
208
+ }
209
+
199
210
  /** Wait for every run in flight beside the turns to end. `chant operator --steward --once` does before it exits. */
200
211
  export async function waitForBesideRuns(state: BesideState): Promise<void> {
201
212
  await Promise.all([...state.running.values()].map((r) => r.handle.done));
@@ -263,6 +274,13 @@ export interface OperatorRoundOptions {
263
274
  * it returns.
264
275
  */
265
276
  besideState?: BesideState;
277
+ /**
278
+ * The per-Op leases this process holds after its ticks, Op name to the
279
+ * token of the newest acquire (#2879). A round records each lease it takes
280
+ * or renews here. `runOperatorForever` owns one and releases what is left when it stops, so
281
+ * a restarted operator does not skip its Ops until the old leases expire.
282
+ */
283
+ heldLeases?: Map<string, string>;
266
284
  /** How a run beside the turns is started. Default: `chant run <op>` in a process of its own ({@link spawnBesideRun}). */
267
285
  launchBeside?: BesideLauncher;
268
286
  /** The environment the steward operator was started for (`--env`), passed on to a run it starts beside its turns. */
@@ -540,6 +558,7 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
540
558
  }
541
559
 
542
560
  const lease = acquired.lease as LeaseRecord;
561
+ opts.heldLeases?.set(config.name, lease.token);
543
562
 
544
563
  // A local steward is one turn at a time (#2750): a hand run (`chant run
545
564
  // <op>`) holds this same lease for as long as it runs, so a round never
@@ -556,12 +575,14 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
556
575
  // catch above: report it and stop, rather than let it propagate and
557
576
  // leak the per-op lease this round just took.
558
577
  await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
578
+ opts.heldLeases?.delete(config.name);
559
579
  const message = err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err);
560
580
  events.push({ kind: "lease-error", op: stewardTurnLeaseName(steward.name), env, error: message });
561
581
  break;
562
582
  }
563
583
  if (!turn.acquired) {
564
584
  await releaseLease(config.name, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
585
+ opts.heldLeases?.delete(config.name);
565
586
  events.push({ kind: "turn-busy", op: config.name, env, steward: steward.name, heldBy: turn.heldBy?.holder });
566
587
  break;
567
588
  }
@@ -898,6 +919,8 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
898
919
  // The runs a steward starts beside its turns (#2861), for the life of the
899
920
  // loop. When the loop stops, so do they.
900
921
  const besideState = opts.besideState ?? createBesideState();
922
+ // The per-Op leases the rounds leave held (#2879), released when the loop stops.
923
+ const heldLeases = opts.heldLeases ?? new Map<string, string>();
901
924
  const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
902
925
  const subscribers = opts.subscribers ?? [];
903
926
 
@@ -906,13 +929,14 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
906
929
  if (subscribers.length === 0) {
907
930
  try {
908
931
  while (!opts.signal?.aborted) {
909
- const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
932
+ const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState, heldLeases });
910
933
  opts.onRound?.(events);
911
934
  if (opts.signal?.aborted) break;
912
935
  await sleepAbortable(intervalMs, opts.signal);
913
936
  }
914
937
  } finally {
915
938
  await stopBesideRuns(besideState);
939
+ await releaseHeldLeases(heldLeases, holder, { cwd: opts.cwd });
916
940
  }
917
941
  return;
918
942
  }
@@ -965,7 +989,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
965
989
  while (!opts.signal?.aborted) {
966
990
  gate.roundStarted();
967
991
  const startedAt = Date.now();
968
- const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
992
+ const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState, heldLeases });
969
993
  opts.onRound?.(events);
970
994
  if (opts.signal?.aborted) break;
971
995
 
@@ -980,6 +1004,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
980
1004
  } finally {
981
1005
  await closeAll();
982
1006
  await stopBesideRuns(besideState);
1007
+ await releaseHeldLeases(heldLeases, holder, { cwd: opts.cwd });
983
1008
  }
984
1009
  }
985
1010
 
@@ -8,7 +8,7 @@ import type { ActivityFn, ActivityProfile } from "./activity-registry";
8
8
  import { runOpLocally } from "./local-executor";
9
9
  import { memoryGateLedgerPort } from "./gate";
10
10
  import { currentOpRun, type OpRunContext } from "./run-context";
11
- import { gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
11
+ import { gateContextDigest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
12
12
  import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
13
13
 
14
14
  const PROFILES: Record<string, ActivityProfile> = {
@@ -81,7 +81,7 @@ describe("the Op run context (#2522)", () => {
81
81
  const permit = (mode: "log-only" | "enforce"): GateResolutionRecord => ({
82
82
  version: 1, op: "release", gate: "ship", resolvedBy: "release-bot", timestamp: "2026-09-05T11:00:00.000Z",
83
83
  approver: { kind: "agent" },
84
- policyDecision: { policy: "ship", version: POLICY.version, mode, decision: "allow", determining: ["ship-0"], errors: [] },
84
+ policyDecision: { policy: "ship", version: POLICY.version, mode, contextDigest: gateContextDigest(undefined), decision: "allow", determining: ["ship-0"], errors: [] },
85
85
  });
86
86
 
87
87
  async function run(mode: "log-only" | "enforce") {
@@ -7,10 +7,11 @@
7
7
 
8
8
  import { isStepOutputRef } from "./step-output-ref";
9
9
  import { parseDuration } from "./duration";
10
+ import { WORK_ITEM_ID_PATTERN, workItemIdProblem } from "../lifecycle/work-item-id";
10
11
  import { WORK_LEASE_STEP_ID, type OpConfig, type PhaseDefinition } from "./types";
11
12
 
12
13
  /** A work item id a lease can be keyed by: the same pattern as `WORK_ITEM_ID_PATTERN` in `../lifecycle/work-lease.ts`. */
13
- export const WORK_LEASE_ITEM_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
14
+ export const WORK_LEASE_ITEM_PATTERN = WORK_ITEM_ID_PATTERN;
14
15
 
15
16
  /** The ids of the activity steps in an Op's main phases, in order. */
16
17
  function mainStepIds(phases: readonly PhaseDefinition[]): string[] {
@@ -42,9 +43,8 @@ export function workLeaseProblems(config: Pick<OpConfig, "name" | "phases" | "wo
42
43
  problems.push(`Op "${name}": step id "${WORK_LEASE_STEP_ID}" is reserved for the run's work lease; rename the step`);
43
44
  }
44
45
  const checkId = (id: unknown) => {
45
- if (typeof id !== "string" || !WORK_LEASE_ITEM_PATTERN.test(id) || id.includes("..")) {
46
- problems.push(`Op "${name}": workLease.item ${JSON.stringify(id)} can't key a work lease (letters, digits, ".", "_" and "-")`);
47
- }
46
+ const problem = workItemIdProblem(id);
47
+ if (problem) problems.push(`Op "${name}": workLease.item ${problem}`);
48
48
  };
49
49
  const item = spec.item;
50
50
  if (isStepOutputRef(item)) {
@@ -80,6 +80,11 @@ describe("the declaration", () => {
80
80
  ).toThrow(/scheduled, but its workLease names no item/);
81
81
  expect(declareSteward({ name: "box", ops: [dispatchOp({ workLease: {} })] }).ops).toHaveLength(1);
82
82
  expect(workLeaseProblems(dispatchOp({ workLease: { item: "../x" } }))[0]).toMatch(/can't key a work lease/);
83
+ for (const item of ["upgrade-.", "x.lock", "x.LOCK", "a..b"]) {
84
+ expect(workLeaseProblems(dispatchOp({ workLease: { item } }))[0], item).toMatch(/can't key a work lease/);
85
+ }
86
+ expect(workLeaseProblems(dispatchOp({ workLease: { item: ["W-1", "upgrade-."] } }))).toHaveLength(1);
87
+ expect(() => Op(dispatchOp({ workLease: { item: "upgrade-." } }))).toThrow(/can't key a work lease/);
83
88
  expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1", ttl: "1s" } }))[0]).toMatch(/shorter than 3s/);
84
89
  expect(workLeaseProblems(dispatchOp({ workLease: { item: stepOutput("nope", "id") } }))[0]).toMatch(/references step "nope"/);
85
90
  expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1" } }, [step("x", {}, { id: WORK_LEASE_STEP_ID })]))[0]).toMatch(/reserved/);
@@ -64,7 +64,8 @@ import {
64
64
  releaseWorkLease,
65
65
  renewWorkLease,
66
66
  workLeaseRef,
67
- WORK_ITEM_ID_PATTERN,
67
+ isWorkItemId,
68
+ workItemIdProblem,
68
69
  type WorkLease,
69
70
  } from "../lifecycle/work-lease";
70
71
  import { resolveMemberLedger } from "../lifecycle/member-ledger";
@@ -216,8 +217,8 @@ export class RunWorkLease {
216
217
  if (value === undefined || value === null || value === "") return [];
217
218
  const list = Array.isArray(value) ? value : [value];
218
219
  return list.filter((v) => v !== undefined && v !== null && v !== "").map((v) => {
219
- if (typeof v !== "string" || !WORK_ITEM_ID_PATTERN.test(v) || v.includes("..")) {
220
- throw new Error(`Op "${this.config.name}": workLease.item resolved to ${JSON.stringify(v)}, which can't key a work lease`);
220
+ if (!isWorkItemId(v)) {
221
+ throw new Error(`Op "${this.config.name}": workLease.item resolved to ${JSON.stringify(v)}, which can't key a work lease${typeof v === "string" ? ` (${workItemIdProblem(v)})` : ""}`);
221
222
  }
222
223
  return v;
223
224
  });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The override gate bundles small (#3760, ruling #3421), as the gate decision
3
+ * does: terragucci and SQL Yodeler decide overrides from one bundled file
4
+ * with no TypeScript toolchain. This bundles `@intentius/chant/override-gate`
5
+ * the way they would and reads esbuild's own list of what went in.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { build } from "esbuild";
10
+ import { describe, expect, test } from "vitest";
11
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
12
+
13
+ const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/lifecycle\/git\.ts$/, /\/src\/lifecycle\/gate-ledger\.ts$/];
14
+
15
+ describe("the override-gate bundle", () => {
16
+ test("holds the decision, the seal check and the digest, and no package or git ledger code", async () => {
17
+ const result = await build({
18
+ entryPoints: [join(REPO, "packages", "core", "src", "override-gate.ts")],
19
+ bundle: true,
20
+ platform: "node",
21
+ format: "esm",
22
+ write: false,
23
+ metafile: true,
24
+ outdir: "out",
25
+ logLevel: "silent",
26
+ });
27
+ const inputs = Object.keys(result.metafile.inputs);
28
+ for (const pattern of FORBIDDEN) {
29
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
30
+ }
31
+ expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
32
+ "packages/core/src/content-digest.ts",
33
+ "packages/core/src/declarable.ts",
34
+ "packages/core/src/effect-receipt.ts",
35
+ "packages/core/src/intrinsic.ts",
36
+ "packages/core/src/lifecycle/gate-ledger-select.ts",
37
+ "packages/core/src/lifecycle/plan-digest.ts",
38
+ "packages/core/src/op/gate-name.ts",
39
+ "packages/core/src/override-gate.ts",
40
+ "packages/core/src/workspace/trust/gate-seal.ts",
41
+ "packages/core/src/workspace/trust/trust-files.ts",
42
+ ]);
43
+ // About 16 KB today.
44
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
45
+ expect(bytes).toBeLessThan(40 * 1024);
46
+ });
47
+ });