@intentius/chant 0.124.3 → 0.124.5

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.
@@ -44,10 +44,12 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
44
44
  import { dirname, isAbsolute, join, posix } from "node:path";
45
45
  import { waveSetDigest, type WaveMember } from "../gated-waves";
46
46
  import type { ReviewApproval } from "../gate-decision";
47
- import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
47
+ import { approveCommand, evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "./gate";
48
+ import type { PendingGateRecord } from "../lifecycle/gate-ledger";
48
49
  import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
49
50
  import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
50
51
  import type { TrustPolicy } from "../workspace/trust/policy";
52
+ import type { GateSigners } from "../workspace/trust/gate-seal";
51
53
  import {
52
54
  OP_WAVE_DEFAULT_APPLY,
53
55
  OP_WAVE_GATE_POLICIES,
@@ -77,6 +79,12 @@ export interface OpWavePlanFile {
77
79
  destructive?: boolean;
78
80
  /** The plan changes nothing. */
79
81
  empty?: boolean;
82
+ /**
83
+ * The product's policy denied this plan (#3821): the ids of the rules that
84
+ * denied it. The wave applies nothing unless an override of this run, its
85
+ * plan digest and these rules counts (`@intentius/chant/override-gate`).
86
+ */
87
+ denied?: { rules: string[]; reason?: string };
80
88
  }
81
89
 
82
90
  /** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
@@ -89,6 +97,20 @@ export interface OpWavePlansFile {
89
97
  * ...) is refused, except `description`.
90
98
  */
91
99
  pending?: Record<string, unknown>;
100
+ /**
101
+ * Who may override a denial (#3821), for a product that keeps the list in
102
+ * its own config at base and read it there. Absent: the spec's
103
+ * `override.overriders` file at base, else `.chant/overriders` at base.
104
+ */
105
+ overriders?: string[];
106
+ /**
107
+ * Digest members that are not runs (#3821), such as the wave's monthly cost
108
+ * when a cost threshold applies, so an approval at one cost does not apply
109
+ * at another. Each needs a name no run has.
110
+ */
111
+ members?: WaveMember[];
112
+ /** On a discovering wave (#3821): how many waves the product found, which a `rest` wave walks to. */
113
+ waves?: number;
92
114
  }
93
115
 
94
116
  /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
@@ -105,6 +127,8 @@ export interface OpWaveRunsFile {
105
127
  number: number;
106
128
  /** The wave's set digest, once it is decided. On `applyWave`, the digest the set was approved or let through at. */
107
129
  digest?: string;
130
+ /** Set when the wave discovers its runs (#3821): `runs` is empty, and the plan command plans the runs it finds for this wave. */
131
+ discover?: true;
108
132
  /** Each run, with its plan once planned. On `applyWave`, the runs to apply. */
109
133
  runs: Array<{ target: string; op: string; planDigest?: string; destructive?: boolean; empty?: boolean }>;
110
134
  }
@@ -114,6 +138,10 @@ export interface OpWaveMember extends WaveMember {
114
138
  op: string;
115
139
  destructive?: boolean;
116
140
  empty?: boolean;
141
+ /** The product's policy denied the plan (#3821). */
142
+ denied?: { rules: string[]; reason?: string };
143
+ /** The override that lets the denied plan through (#3821). */
144
+ override?: { by: string; at: string; digest: string; reason: string };
117
145
  }
118
146
 
119
147
  /** Where a wave's policy came from. */
@@ -134,8 +162,12 @@ export interface OpWaveDecision {
134
162
  base: string;
135
163
  digest: string;
136
164
  members: OpWaveMember[];
137
- /** `approved`: an approval of this digest stands. `not-required`: the policy lets it through. `waiting`: neither. */
138
- status: "approved" | "not-required" | "waiting";
165
+ /**
166
+ * `approved`: an approval of this digest stands. `not-required`: the policy
167
+ * lets it through. `waiting`: neither. `refused`: the product's policy
168
+ * denied a run and no override counts (#3821, exit 7).
169
+ */
170
+ status: "approved" | "not-required" | "waiting" | "refused";
139
171
  approvedBy?: string;
140
172
  /** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
141
173
  via?: "ledger" | "pr-review";
@@ -168,6 +200,73 @@ export interface OpWaveDecision {
168
200
  pending?: Record<string, unknown>;
169
201
  /** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
170
202
  neverOverMcp?: true;
203
+ /** While waiting (#3821): the pending fact that stands for the digest, this run's own when `recorded`. */
204
+ pendingFact?: PendingGateRecord;
205
+ /** While waiting: whether this run recorded the pending fact, rather than finding one standing. */
206
+ recorded?: boolean;
207
+ /** While waiting: when the wait began, the standing pending fact's time. */
208
+ waitingSince?: string;
209
+ /** While waiting: the newest approval of this gate for another digest, which the plans moved away from. */
210
+ mismatch?: GateDigestMismatch;
211
+ /** Why a `refused` wave applies nothing (#3821): the runs the policy denied, and whether an override stands for an earlier plan or other rules. */
212
+ refused?: { reason: "policy" | "override-moved"; runs: string[] };
213
+ /**
214
+ * A hook held the wave on another gate (#3821), such as a state migration
215
+ * behind its own: that gate, its digest, and how it came out. While it
216
+ * waits, the wave waits (exit 3) and `approve` approves it.
217
+ */
218
+ heldOn?: OpWaveHeldOn;
219
+ /** When the wave recorded the overrides it applies under (#3821), which spent them. */
220
+ overridesSpentAt?: string;
221
+ /** Digest members that are no run, from the plans file (#3821), such as the wave's cost. */
222
+ extras?: WaveMember[];
223
+ /** On a discovering wave (#3821): how many waves the plan command found. */
224
+ discovered?: { waves?: number };
225
+ }
226
+
227
+ /**
228
+ * What an apply call writes to `CHANT_APPLY_OUTCOME` when it applied nothing
229
+ * for a reason that is not a failure (#3821). `stood-down`: a newer run (a
230
+ * newer push on the branch) applies instead, exit 6. `held`: another run
231
+ * holds what the apply changes and the apply does not wait for it, exit 5.
232
+ * `replan`: the run it waited for let go, so the plans may have moved and
233
+ * the wave plans again in the same job. An apply that applied writes nothing.
234
+ */
235
+ export interface OpWaveApplyOutcome {
236
+ outcome: "stood-down" | "held" | "replan";
237
+ reason?: string;
238
+ /** The run holding what the apply changes, for `held`. */
239
+ holder?: { run: string; url?: string };
240
+ }
241
+
242
+ export const OP_WAVE_APPLY_OUTCOMES = ["stood-down", "held", "replan"] as const;
243
+
244
+ /** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
245
+ export interface OpWaveHoldRequest {
246
+ /** The op the gate is recorded under. Default: the spec's name. */
247
+ op?: string;
248
+ gate: string;
249
+ /** The digest an approval binds. */
250
+ digest: string;
251
+ description?: string;
252
+ /** Fields for the pending fact, as a plans file's `pending`. */
253
+ pending?: Record<string, unknown>;
254
+ }
255
+
256
+ /** A hold on another gate, as the decision records it (#3821). */
257
+ export interface OpWaveHeldOn {
258
+ op: string;
259
+ gate: string;
260
+ digest: string;
261
+ /** The hook that asked. */
262
+ hook: string;
263
+ status: "approved" | "waiting";
264
+ approvedBy?: string;
265
+ approvedAt?: string;
266
+ approve?: string;
267
+ waitingSince?: string;
268
+ recorded?: boolean;
269
+ mismatch?: GateDigestMismatch;
171
270
  }
172
271
 
173
272
  /** One hook's run (#3762). */
@@ -432,11 +531,25 @@ export interface RunOpWaveOptions {
432
531
  headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
433
532
  /** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
434
533
  trust?: (base: string) => TrustPolicy;
534
+ /** The signers at base, for a sealed override of a policy denial (#3821). Default: `gateSignersAt` from `@intentius/chant/gate-decision`. */
535
+ signers?: (base: string) => GateSigners;
435
536
  }
436
537
 
437
538
  export interface RunOpWaveResult {
438
- /** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision. */
539
+ /**
540
+ * 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an
541
+ * approval, 4 a plan moved since the decision, 5 the apply applied nothing
542
+ * because another run holds what it changes, 6 the apply stood down for a
543
+ * newer run, 7 the product's policy denied a run and no override counts
544
+ * (5, 6 and 7: #3821).
545
+ */
439
546
  exitCode: number;
547
+ /** Set when the apply applied nothing and stood down or was held by another run (#3821): not a failure. */
548
+ outcome?: OpWaveApplyOutcome;
549
+ /** Set when the apply asked to plan again (#3821); {@link runOpWave} does, so a caller sees it only on a pass that gave up. */
550
+ replan?: { reason?: string };
551
+ /** The waves a `rest` wave's job ran (#3821), in order, with how each ended. */
552
+ rest?: Array<{ wave: number; exitCode: number; digest?: string }>;
440
553
  decision?: OpWaveDecision;
441
554
  applied: string[];
442
555
  failed: string[];
@@ -455,6 +568,9 @@ interface WaveContext {
455
568
  exec: OpWaveExec;
456
569
  log: (line: string) => void;
457
570
  result: RunOpWaveResult;
571
+ options: RunOpWaveOptions;
572
+ /** How another gate a hook holds the wave on is decided (#3821): the approval mode and base the wave's rules were read at, and whether pending facts carry neverOverMcp. */
573
+ gateRule: { approval: GateApprovalSource; base: string; neverOverMcp: boolean };
458
574
  }
459
575
 
460
576
  function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
@@ -476,12 +592,20 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
476
592
  }
477
593
 
478
594
  function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
595
+ const denied = file.denied as unknown;
596
+ if (denied !== undefined) {
597
+ const d = denied as { rules?: unknown; reason?: unknown } | null;
598
+ if (!d || typeof d !== "object" || !Array.isArray(d.rules) || d.rules.length === 0 || !d.rules.every((r) => typeof r === "string" && r.trim() !== "") || (d.reason !== undefined && typeof d.reason !== "string")) {
599
+ throw new Error(`the plan of ${target} has denied ${JSON.stringify(denied)}; a denial is { "rules": ["<rule id>", ...], "reason"?: "<text>" }`);
600
+ }
601
+ }
479
602
  return {
480
603
  member: target,
481
604
  planDigest: file.planDigest,
482
605
  op,
483
606
  ...(file.destructive === true ? { destructive: true } : {}),
484
607
  ...(file.empty === true ? { empty: true } : {}),
608
+ ...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
485
609
  };
486
610
  }
487
611
 
@@ -492,11 +616,13 @@ function writeRunsFile(
492
616
  members?: ReadonlyMap<string, OpWaveMember>,
493
617
  digest?: string,
494
618
  path: string = opWaveRunsPath(at.spec, at.k),
619
+ discover = false,
495
620
  ): string {
496
621
  const file: OpWaveRunsFile = {
497
622
  wave: at.wave.name,
498
623
  number: at.k,
499
624
  ...(digest ? { digest } : {}),
625
+ ...(discover ? { discover: true as const } : {}),
500
626
  runs: runs.map((run) => {
501
627
  const m = members?.get(run.target);
502
628
  return {
@@ -516,10 +642,10 @@ function writeRunsFile(
516
642
  * for all of them (#3762). A wave plan is written out as each run's plan file
517
643
  * too, so a per-run `apply` or hook reads `{plan}` either way.
518
644
  */
519
- function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
645
+ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[], discover = false): PlannedRuns {
520
646
  const { spec, k, wave, cwd, exec } = at;
521
647
  if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
522
- const runsPath = writeRunsFile(at, runs);
648
+ const runsPath = writeRunsFile(at, runs, undefined, undefined, undefined, discover);
523
649
  const plans = opWavePlansPath(spec, k);
524
650
  rmSync(join(cwd, plans), { force: true });
525
651
  const code = exec(opWaveArgv(spec.planWave, { op: spec.op, wave: wave.name, runs: runsPath, plans }), cwd);
@@ -534,8 +660,15 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
534
660
  if (!Array.isArray(entries)) throw new Error(`the plans file of wave ${k} (${plans}) must hold { "runs": [{ "target", "planDigest" }] }`);
535
661
  const wanted = new Set(runs.map((r) => r.target));
536
662
  const byTarget = new Map<string, OpWavePlanFile>();
537
- for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string }>>) {
663
+ // #3821: a discovering wave's runs are the ones the plans file names.
664
+ const found: OpWaveRun[] = [];
665
+ for (const entry of entries as Array<Partial<OpWavePlanFile & { target: string; op: string }>>) {
538
666
  const target = entry?.target;
667
+ if (discover && typeof target === "string" && target !== "" && !byTarget.has(target)) {
668
+ if (entry.op !== undefined && (typeof entry.op !== "string" || entry.op === "")) throw new Error(`the plans file of wave ${k} (${plans}) gives ${target} an op that is not a name`);
669
+ found.push({ target, ...(entry.op && entry.op !== spec.op ? { op: entry.op } : {}) });
670
+ wanted.add(target);
671
+ }
539
672
  if (typeof target !== "string" || !wanted.has(target)) {
540
673
  throw new Error(`the plans file of wave ${k} (${plans}) plans ${JSON.stringify(target)}, which is not a run it was asked to plan`);
541
674
  }
@@ -545,20 +678,56 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
545
678
  }
546
679
  const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
547
680
  if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
681
+ if (discover) runs = found;
682
+ const extras = Array.isArray(parsed) ? undefined : extraMembers((parsed as { members?: unknown }).members, new Set(runs.map((r) => r.target)), `the plans file of wave ${k} (${plans})`);
683
+ const count = Array.isArray(parsed) ? undefined : (parsed as { waves?: unknown }).waves;
684
+ if (count !== undefined && (!Number.isInteger(count) || (count as number) < 0)) throw new Error(`the plans file of wave ${k} (${plans}): \`waves\` must be the number of waves found, a whole number`);
548
685
  const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
549
686
  const members = runs.map((run) => {
550
687
  const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
551
688
  const path = join(cwd, opWavePlanPath(spec, k, run.target));
552
- writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
689
+ writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}) }) + "\n");
553
690
  return m;
554
691
  });
555
- return { members, ...(pending ? { pending } : {}) };
692
+ const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
693
+ if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
694
+ throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
695
+ }
696
+ return {
697
+ members,
698
+ ...(pending ? { pending } : {}),
699
+ ...(overriders ? { overriders: overriders as string[] } : {}),
700
+ ...(extras ? { extras } : {}),
701
+ ...(count !== undefined ? { waves: count as number } : {}),
702
+ };
556
703
  }
557
704
 
558
705
  /** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
559
706
  interface PlannedRuns {
560
707
  members: OpWaveMember[];
561
708
  pending?: Record<string, unknown>;
709
+ /** The plans file's overriders (#3821). */
710
+ overriders?: string[];
711
+ /** Digest members that are not runs, such as the wave's cost (#3821). */
712
+ extras?: WaveMember[];
713
+ /** How many waves the product found (#3821), for a `rest` wave. */
714
+ waves?: number;
715
+ }
716
+
717
+ /** A plans file's `members` (#3821): digest members that are no run, each named once and by no run's target. */
718
+ function extraMembers(value: unknown, targets: ReadonlySet<string>, where: string): WaveMember[] | undefined {
719
+ if (value === undefined) return undefined;
720
+ if (!Array.isArray(value)) throw new Error(`${where}: \`members\` must be a list of { "member", "planDigest" }`);
721
+ const seen = new Set<string>();
722
+ const out: WaveMember[] = [];
723
+ for (const m of value as Array<Partial<WaveMember>>) {
724
+ if (!m || typeof m.member !== "string" || m.member === "" || typeof m.planDigest !== "string" || m.planDigest === "") throw new Error(`${where}: each of \`members\` needs a member and a planDigest`);
725
+ if (targets.has(m.member)) throw new Error(`${where}: member ${m.member} is also a run's target; a digest member that is no run needs a name of its own`);
726
+ if (seen.has(m.member)) throw new Error(`${where}: names member ${m.member} twice`);
727
+ seen.add(m.member);
728
+ out.push({ member: m.member, planDigest: m.planDigest });
729
+ }
730
+ return out.length > 0 ? out : undefined;
562
731
  }
563
732
 
564
733
  /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
@@ -574,11 +743,12 @@ function pendingFields(value: unknown, where: string): Record<string, unknown> |
574
743
  /**
575
744
  * A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
576
745
  * or under `digest.over: "changing"` over the runs whose plan is not empty,
577
- * falling back to every run when all of them are empty.
746
+ * falling back to every run when all of them are empty. `extras`, the plans
747
+ * file's members that are no run (a cost), are always in it.
578
748
  */
579
- export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>): string {
749
+ export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>, extras: readonly WaveMember[] = []): string {
580
750
  const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
581
- return waveSetDigest(changing.length > 0 ? changing : members);
751
+ return waveSetDigest([...(changing.length > 0 ? changing : members), ...extras]);
582
752
  }
583
753
 
584
754
  /** What one moment's hooks came to. */
@@ -587,6 +757,32 @@ interface HooksOutcome {
587
757
  failed?: string;
588
758
  /** The `onFailure: approve` hooks that failed. */
589
759
  holds: string[];
760
+ /** A hook held the wave on another gate that nobody approved yet (#3821): the wave waits. */
761
+ waitingOn?: OpWaveHeldOn;
762
+ }
763
+
764
+ /** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
765
+ interface OtherGateHold {
766
+ label: string;
767
+ name: string;
768
+ target?: string;
769
+ argv: string[];
770
+ env: Record<string, string>;
771
+ request: OpWaveHoldRequest;
772
+ }
773
+
774
+ /** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
775
+ function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
776
+ if (!existsSync(path)) return undefined;
777
+ try {
778
+ const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
779
+ if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
780
+ if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
781
+ return { gate: r.gate, digest: r.digest, ...(r.op ? { op: r.op } : {}), ...(typeof r.description === "string" ? { description: r.description } : {}), ...(r.pending ? { pending: pendingFields(r.pending, `the hold ${label} wrote`) ?? {} } : {}) };
782
+ } catch (err) {
783
+ log(`${label} wrote a hold file that cannot be read (${err instanceof Error ? err.message : String(err)}), so it holds the wave on its set digest`);
784
+ return undefined;
785
+ }
590
786
  }
591
787
 
592
788
  /** Where hooks run: the wave they belong to and the job's tools. */
@@ -612,11 +808,14 @@ function runHookList(
612
808
  hooks: readonly OpWaveHook[] | undefined,
613
809
  runs: readonly OpWaveHookRun[],
614
810
  runsPath: string,
615
- ): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
616
- const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
811
+ extraEnv: Record<string, string> = {},
812
+ ): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
813
+ const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
617
814
  if (!hooks || hooks.length === 0) return out;
618
815
  const { name: specName, op, waveName, k, cwd, exec, log } = at;
619
- const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
816
+ // #3821: a hook that holds the wave on another gate names it in this file.
817
+ const holdPath = `.chant/op-waves/${specName}/wave-${k}/hold.json`;
818
+ const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
620
819
  for (const hook of hooks) {
621
820
  const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
622
821
  const calls = hook.perRun
@@ -629,6 +828,7 @@ function runHookList(
629
828
  for (const call of calls) {
630
829
  const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
631
830
  let code: number;
831
+ rmSync(join(cwd, holdPath), { force: true });
632
832
  try {
633
833
  code = exec(call.argv, cwd, call.env);
634
834
  } catch (err) {
@@ -642,6 +842,12 @@ function runHookList(
642
842
  }
643
843
  if (hook.onFailure === "approve") {
644
844
  out.outcomes.push({ ...base, outcome: "held" });
845
+ const request = readHoldRequest(join(cwd, holdPath), log, label);
846
+ if (request) {
847
+ out.otherGates.push({ label, name, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
848
+ log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
849
+ continue;
850
+ }
645
851
  out.holds.push(label);
646
852
  log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
647
853
  continue;
@@ -660,20 +866,105 @@ const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx
660
866
  const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
661
867
  runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
662
868
 
663
- /** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
664
- function runHooks(
869
+ /**
870
+ * Run one moment's hooks in the wave job (#3762), recording each outcome on
871
+ * the result. A hook that held the wave on another gate (#3821) has that
872
+ * gate settled here: approved, the hook runs again with
873
+ * `CHANT_HOLD_APPROVED` and must pass; otherwise the wave waits on it.
874
+ */
875
+ async function runHooks(
665
876
  ctx: WaveContext,
666
877
  moment: OpWaveHookMoment,
667
878
  hooks: readonly OpWaveHook[] | undefined,
668
879
  runs: readonly OpWaveRun[],
669
880
  members?: ReadonlyMap<string, OpWaveMember>,
670
881
  digest?: string,
671
- ): HooksOutcome {
882
+ extraEnv: Record<string, string> = {},
883
+ ): Promise<HooksOutcome> {
672
884
  if (!hooks || hooks.length === 0) return { holds: [] };
673
885
  const runsPath = writeRunsFile(ctx, runs, members, digest);
674
- const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
886
+ const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath, extraEnv);
675
887
  ctx.result.hooks.push(...out.outcomes);
676
- return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
888
+ const back: HooksOutcome = { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
889
+ if (out.failed) return back;
890
+ for (const hold of out.otherGates) {
891
+ const held = await settleOtherGate(ctx, hold);
892
+ if (held.status === "waiting") {
893
+ back.waitingOn ??= held;
894
+ continue;
895
+ }
896
+ const approvedEnv = { ...hold.env, CHANT_HOLD_APPROVED: JSON.stringify({ op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy, approvedAt: held.approvedAt }) };
897
+ let code: number;
898
+ try {
899
+ code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
900
+ } catch (err) {
901
+ ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
902
+ code = 127;
903
+ }
904
+ ctx.result.hooks.push({ moment, name: hold.name, ...(hold.target ? { target: hold.target } : {}), exit: code, outcome: code === 0 ? "passed" : "failed" });
905
+ if (code !== 0) {
906
+ back.failed = `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`;
907
+ ctx.log(back.failed);
908
+ return back;
909
+ }
910
+ ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
911
+ }
912
+ return back;
913
+ }
914
+
915
+ /** The gate ledger a wave decides by: the job's port, with the signers at base as the rule when `approval` is `sealed`. */
916
+ async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, approval: GateApprovalSource, base: string): Promise<{ ledger: GateLedgerPort; port: GateLedgerPort }> {
917
+ const ledger = options.gates ?? gitGateLedgerPort({ cwd });
918
+ if (approval !== "sealed") return { ledger, port: ledger };
919
+ // #3684: only an approval sealed by a signer listed at base counts.
920
+ const review = await import("./gate-review");
921
+ const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
922
+ return {
923
+ ledger,
924
+ port: {
925
+ ...ledger,
926
+ async approvalRule(g) {
927
+ const own = (await ledger.approvalRule?.(g)) ?? null;
928
+ return g === gate ? review.bothRules(own, sealed) : own;
929
+ },
930
+ },
931
+ };
932
+ }
933
+
934
+ /**
935
+ * Settle a hold on another gate (#3821): an approval of that gate and digest
936
+ * under the wave's approval mode (`pr-review` counts the ledger alone, since
937
+ * no review planned that digest), else a pending fact for it.
938
+ */
939
+ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<OpWaveHeldOn> {
940
+ const { spec, k, wave, log } = ctx;
941
+ const rules = ctx.gateRule;
942
+ const op = hold.request.op ?? spec.name;
943
+ const mode: GateApprovalSource = rules.approval === "sealed" ? "sealed" : "ledger";
944
+ const { port } = await gatePort(ctx.options, ctx.cwd, hold.request.gate, mode, rules.base);
945
+ const neverOverMcp = rules.neverOverMcp;
946
+ const now = ctx.options.now ?? new Date().toISOString();
947
+ const check = await evaluateGate(port, {
948
+ op,
949
+ gate: hold.request.gate,
950
+ planDigest: hold.request.digest,
951
+ description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
952
+ now,
953
+ ...(hold.request.pending ? { fields: hold.request.pending } : {}),
954
+ ...(neverOverMcp ? { neverOverMcp: true } : {}),
955
+ });
956
+ const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
957
+ if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
958
+ const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
959
+ log(`wave ${k} (${wave.name}) waits on ${op} / ${hold.request.gate}, which ${hold.label} holds it on: nobody approved ${hold.request.digest}. Approve: ${approve}`);
960
+ return {
961
+ ...base,
962
+ status: "waiting",
963
+ approve,
964
+ waitingSince: check.pending.timestamp,
965
+ recorded: check.recorded,
966
+ ...(check.mismatch ? { mismatch: check.mismatch } : {}),
967
+ };
677
968
  }
678
969
 
679
970
  /**
@@ -725,12 +1016,14 @@ export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMom
725
1016
  } else {
726
1017
  const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
727
1018
  const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
1019
+ // A hold on another gate (#3821) is settled only by the wave job; under the apply's lock it stops the apply.
1020
+ const others = out.otherGates.map((o) => `${o.label} on ${o.request.op ?? file.opWaves} / ${o.request.gate}`);
728
1021
  run = {
729
1022
  moment,
730
1023
  outcomes: out.outcomes,
731
1024
  ...(out.failed ? { failed: out.failed } : {}),
732
- held: out.holds,
733
- proceed: !out.failed && (out.holds.length === 0 || file.approved),
1025
+ held: [...out.holds, ...others],
1026
+ proceed: !out.failed && others.length === 0 && (out.holds.length === 0 || file.approved),
734
1027
  };
735
1028
  }
736
1029
  if (file.result) {
@@ -814,12 +1107,49 @@ function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
814
1107
 
815
1108
  /** Plan, decide and apply one wave, or one part of a wide wave. */
816
1109
  export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveResult> {
817
- const { spec, wave: k } = options;
1110
+ const { spec } = options;
818
1111
  assertOpWavesSpec(spec);
1112
+ const log = options.log ?? ((line: string) => console.error(line));
1113
+ const last = spec.waves[spec.waves.length - 1]!;
1114
+ const applied: string[] = [];
1115
+ const rest: NonNullable<RunOpWaveResult["rest"]> = [];
1116
+ for (let k = options.wave; ; k++) {
1117
+ let result: RunOpWaveResult | undefined;
1118
+ let prior: OpWaveDecision | undefined;
1119
+ // #3821: an apply that came back "replan" plans the wave again, a bounded number of times.
1120
+ for (let pass = 0; ; pass++) {
1121
+ result = await runOneWave(options, k, prior, pass);
1122
+ if (!result.replan) break;
1123
+ if (pass + 1 >= OP_WAVE_MAX_REPLANS) {
1124
+ log(`wave ${k}: its apply asked to plan again ${OP_WAVE_MAX_REPLANS} times, so the wave stops here`);
1125
+ result.exitCode = 1;
1126
+ break;
1127
+ }
1128
+ log(`wave ${k}: the apply applied nothing and asks to plan again${result.replan.reason ? ` (${result.replan.reason})` : ""}; planning again`);
1129
+ prior = result.decision;
1130
+ }
1131
+ applied.push(...result.applied);
1132
+ // #3821: a `rest` wave goes on through the waves its plan command found.
1133
+ const found = result.decision?.discovered?.waves;
1134
+ const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && !options.decide && result.exitCode === 0 && found !== undefined && k < found;
1135
+ if (rest.length > 0 || onward) rest.push({ wave: k, exitCode: result.exitCode, ...(result.decision ? { digest: result.decision.digest } : {}) });
1136
+ if (!onward) return { ...result, applied, ...(rest.length > 0 ? { rest } : {}) };
1137
+ log(`wave ${k} applied; the plan found ${found} waves, so this job goes on to wave ${k + 1}`);
1138
+ }
1139
+ }
1140
+
1141
+ /** How many times one wave job plans again when its apply asks to (#3821). */
1142
+ export const OP_WAVE_MAX_REPLANS = 10;
1143
+
1144
+ /** Plan, decide and apply wave `k`, or one part of a wide wave. `prior`: the decision of the pass whose apply asked to plan again. */
1145
+ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDecision | undefined, pass: number): Promise<RunOpWaveResult> {
1146
+ const { spec } = options;
819
1147
  const cwd = options.cwd ?? process.cwd();
820
1148
  const exec = options.exec ?? spawnExec;
821
1149
  const log = options.log ?? ((line: string) => console.error(line));
822
- const wave = spec.waves[k - 1];
1150
+ const last = spec.waves[spec.waves.length - 1]!;
1151
+ // #3821: past the spec's waves, a `rest` wave stands for each wave its plan command found.
1152
+ let wave = spec.waves[k - 1] ?? (last.rest ? last : undefined);
823
1153
  if (!wave) throw new Error(`Op waves "${spec.name}" has ${spec.waves.length} waves; there is no wave ${k}.`);
824
1154
  const shares = wave.shares ?? 1;
825
1155
  if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
@@ -827,7 +1157,8 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
827
1157
  }
828
1158
  const recordPath = join(cwd, opWaveRecordPath(spec, k));
829
1159
  const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
830
- const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result };
1160
+ const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result, options, gateRule: { approval: "ledger", base: "HEAD", neverOverMcp: spec.neverOverMcp === true } };
1161
+ if (pass > 0) log(`wave ${k} (${wave.name}): plan ${pass + 1}`);
831
1162
 
832
1163
  if (options.share !== undefined) {
833
1164
  let decision: OpWaveDecision;
@@ -837,18 +1168,26 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
837
1168
  throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
838
1169
  }
839
1170
  result.decision = decision;
840
- if (decision.status === "waiting") {
841
- log(`wave ${k} (${wave.name}) is waiting on ${decision.gate}; nothing applied. ${decision.approve ?? ""}`.trim());
842
- return { ...result, exitCode: 3 };
1171
+ ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
1172
+ if (decision.status === "waiting" || decision.status === "refused") {
1173
+ log(`wave ${k} (${wave.name}) is ${decision.status === "refused" ? "refused by its policy" : `waiting on ${decision.heldOn?.gate ?? decision.gate}`}; nothing applied. ${decision.approve ?? ""}`.trim());
1174
+ return { ...result, exitCode: decision.status === "refused" ? 7 : 3 };
843
1175
  }
844
1176
  // The share runs the hooks its deciding job read at base, around its own slice.
1177
+ if (wave.discover) {
1178
+ // #3821: a discovering wave's runs are the ones its deciding job planned.
1179
+ wave = { ...wave, runs: decision.members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
1180
+ ctx.wave = wave;
1181
+ }
845
1182
  const slice = opWaveShare(wave.runs, options.share, shares);
846
- const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
1183
+ const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
847
1184
  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`);
1185
+ if (before.waitingOn) return waitOnOther(ctx, decision, before.waitingOn);
848
1186
  const { members } = planRuns(ctx, slice);
849
1187
  const planned = new Map(members.map((m) => [m.member, m]));
850
- const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
1188
+ const after = await runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
851
1189
  if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
1190
+ if (after.waitingOn) return waitOnOther(ctx, decision, after.waitingOn);
852
1191
  const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
853
1192
  const unmoved: OpWaveRun[] = [];
854
1193
  for (const run of slice) {
@@ -871,21 +1210,14 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
871
1210
  // The policy, the approval mode and the hooks, read at base before anything runs.
872
1211
  const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
873
1212
  const { policy, approval, source, base } = rules;
1213
+ const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
1214
+ ctx.gateRule = { approval, base, neverOverMcp };
874
1215
  noteHooksAtBase(ctx, rules);
875
1216
  const holds: string[] = [];
876
1217
  if (rules.hooksRefused) {
877
1218
  holds.push(rules.hooksRefused);
878
1219
  log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
879
1220
  }
880
- const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
881
- if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
882
- const { members, pending } = planRuns(ctx, wave.runs);
883
- const planned = new Map(members.map((m) => [m.member, m]));
884
- const digest = opWaveSetDigest(spec, members);
885
- const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
886
- const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
887
- if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
888
- holds.push(...before.holds, ...after.holds);
889
1221
  const gate = opWaveGateName(spec, k);
890
1222
  const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
891
1223
  const decision: OpWaveDecision = {
@@ -897,41 +1229,248 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
897
1229
  approval,
898
1230
  policySource: source,
899
1231
  base,
900
- digest,
901
- members,
1232
+ digest: "",
1233
+ members: [],
902
1234
  status: "not-required",
903
1235
  ...(Object.keys(hooks).length > 0 ? { hooks } : {}),
904
- ...(holds.length > 0 ? { heldBy: holds } : {}),
905
1236
  ...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
906
1237
  ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
907
1238
  ...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
908
- ...(pending ? { pending } : {}),
909
1239
  ...(neverOverMcp ? { neverOverMcp: true as const } : {}),
910
1240
  };
911
- if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
912
- // A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
913
- if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
914
- mkdirSync(dirname(recordPath), { recursive: true });
915
- writeRecord(recordPath, decision, result);
916
- result.decision = decision;
1241
+ const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
1242
+ if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
1243
+ if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
1244
+ const { members, pending, overriders, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
1245
+ if (wave.discover) {
1246
+ wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
1247
+ ctx.wave = wave;
1248
+ decision.discovered = { ...(found !== undefined ? { waves: found } : {}) };
1249
+ log(`wave ${k} (${wave.name}): the plan found ${members.length} run${members.length === 1 ? "" : "s"}${found !== undefined ? ` of ${found} waves` : ""}${members.length ? `: ${members.map((m) => m.member).join(", ")}` : ""}`);
1250
+ }
1251
+ const planned = new Map(members.map((m) => [m.member, m]));
1252
+ const digest = opWaveSetDigest(spec, members, extras);
1253
+ decision.digest = digest;
1254
+ decision.members = members;
1255
+ if (extras) decision.extras = extras;
1256
+ if (pending) decision.pending = pending;
1257
+ const after = await runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
1258
+ if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
1259
+ holds.push(...before.holds, ...after.holds);
1260
+ if (holds.length > 0) decision.heldBy = holds;
1261
+ if (after.waitingOn) return decided(ctx, decision, recordPath, after.waitingOn);
1262
+ // #3821: a run the product's policy denied applies only under an override; with none the wave is refused, and no approval is waited for.
1263
+ const kept = prior !== undefined && (prior.status === "approved" || prior.status === "not-required") && prior.digest === digest;
1264
+ if (kept) {
1265
+ // The overrides this job applied under, and spent, still let the same plans through.
1266
+ const byRun = new Map(prior.members.map((m) => [m.member, m.override]));
1267
+ for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
1268
+ if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
1269
+ }
1270
+ if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders);
1271
+ if (kept && prior.status === "approved" && decision.status !== "refused") {
1272
+ // #3821: planned again after the apply asked to, to the same digest: the approval this job found still holds, and is not spent twice.
1273
+ for (const key of ["via", "approvedBy", "approvedAt", "review", "spentAt"] as const) if (prior[key] !== undefined) (decision as unknown as Record<string, unknown>)[key] = prior[key];
1274
+ decision.status = "approved";
1275
+ log(`wave ${k} (${wave.name}): planned again to the same digest, so the approval by ${prior.approvedBy} still holds`);
1276
+ } else if (decision.status !== "refused" && (opWaveNeedsApproval(policy, members) || holds.length > 0)) await settleGate(options, ctx, decision);
1277
+ const code = await decided(ctx, decision, recordPath);
1278
+ if (code.exitCode !== 0) return code;
917
1279
  log(
918
1280
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
919
1281
  `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
920
1282
  (decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
921
1283
  (holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
922
1284
  );
923
- if (approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
1285
+ // A wide wave's shares apply under the deciding job's approval and overrides, so they are spent here, once.
1286
+ if (options.decide) {
1287
+ if (!(await spendApproval(options, ctx, decision)) || !(await spendOverrides(ctx, decision))) return ctx.result;
1288
+ if (decision.spentAt || decision.overridesSpentAt) writeRecord(recordPath, decision, result);
1289
+ return result;
1290
+ }
1291
+ await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
1292
+ return result;
1293
+ }
1294
+
1295
+ /**
1296
+ * The gate has decided (#3821): write the decision record, run the
1297
+ * `after-gate` hooks with the decision in their environment, and say whether
1298
+ * the wave goes on. Returns the result with exit 3 when it waits, 7 when its
1299
+ * policy refused it, 1 when an `after-gate` hook failed on a wave that would
1300
+ * apply; exit 0 to go on. A hook that fails while the wave waits or is
1301
+ * refused is logged and leaves the exit code alone.
1302
+ */
1303
+ async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: string, heldOn?: OpWaveHeldOn): Promise<RunOpWaveResult> {
1304
+ const { k, wave, log, result } = ctx;
1305
+ if (heldOn) markHeldOn(decision, heldOn);
1306
+ mkdirSync(dirname(recordPath), { recursive: true });
1307
+ writeRecord(recordPath, decision, result);
1308
+ result.decision = decision;
1309
+ if (decision.approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
924
1310
  log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
925
1311
  }
1312
+ const hook = await runGateHooks(ctx, decision, recordPath);
926
1313
  if (decision.status === "waiting") {
927
- log(`Nothing in wave ${k} or after it ran. Read the plans, then approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
1314
+ log(`Nothing in wave ${k} or after it ran. ${decision.heldOn ? `It waits on ${decision.heldOn.op} / ${decision.heldOn.gate}.` : "Read the plans, then"} approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
928
1315
  return { ...result, exitCode: 3 };
929
1316
  }
930
- if (options.decide) return result;
931
- await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
1317
+ if (decision.status === "refused") {
1318
+ log(`Nothing in wave ${k} (${wave.name}) or after it ran: the policy denied ${decision.refused?.runs.join(", ")}, and no override counts for ${decision.refused?.runs.length === 1 ? "it" : "them"}.`);
1319
+ return { ...result, exitCode: 7 };
1320
+ }
1321
+ if (hook.failed) return failed(ctx, `wave ${k} (${wave.name}): ${hook.failed}, so nothing in it was applied`);
932
1322
  return result;
933
1323
  }
934
1324
 
1325
+ /** A hold on another gate that waits makes the wave wait on it (#3821). */
1326
+ function markHeldOn(decision: OpWaveDecision, heldOn: OpWaveHeldOn): void {
1327
+ decision.heldOn = heldOn;
1328
+ decision.status = "waiting";
1329
+ decision.approve = heldOn.approve;
1330
+ if (heldOn.waitingSince) decision.waitingSince = heldOn.waitingSince;
1331
+ if (heldOn.recorded !== undefined) decision.recorded = heldOn.recorded;
1332
+ }
1333
+
1334
+ /** A share job held on another gate that waits (#3821). */
1335
+ async function waitOnOther(ctx: WaveContext, decision: OpWaveDecision, heldOn: OpWaveHeldOn): Promise<RunOpWaveResult> {
1336
+ markHeldOn(decision, heldOn);
1337
+ ctx.log(`share ${ctx.options.share} of wave ${ctx.k} (${ctx.wave.name}) waits on ${heldOn.op} / ${heldOn.gate}; nothing in it applied. Approve: ${heldOn.approve}`);
1338
+ return { ...ctx.result, decision, exitCode: 3 };
1339
+ }
1340
+
1341
+ /**
1342
+ * Run the `after-gate` hooks (#3821) once the gate decided. Their
1343
+ * environment carries the decision: `CHANT_GATE_STATUS` (`approved`,
1344
+ * `not-required`, `waiting` or `refused`), `CHANT_GATE_OP`, `CHANT_GATE`,
1345
+ * `CHANT_GATE_DIGEST`, `CHANT_DECISION` (the decision record, with the
1346
+ * standing pending fact, the approval of another digest and the denials),
1347
+ * and when set `CHANT_GATE_APPROVE`, `CHANT_GATE_WAITING_SINCE` and
1348
+ * `CHANT_GATE_RECORDED` (`true` when this run recorded the pending fact).
1349
+ */
1350
+ async function runGateHooks(ctx: WaveContext, decision: OpWaveDecision, recordPath: string): Promise<HooksOutcome> {
1351
+ const list = decision.hooks?.["after-gate"];
1352
+ if (!list?.length) return { holds: [] };
1353
+ const on = decision.heldOn;
1354
+ const env: Record<string, string> = {
1355
+ CHANT_GATE_STATUS: decision.status,
1356
+ CHANT_GATE_OP: on?.op ?? decision.op,
1357
+ CHANT_GATE: on?.gate ?? decision.gate,
1358
+ CHANT_GATE_DIGEST: on?.digest ?? decision.digest,
1359
+ CHANT_DECISION: relativeTo(ctx.cwd, recordPath),
1360
+ ...(decision.approve ? { CHANT_GATE_APPROVE: decision.approve } : {}),
1361
+ ...(decision.waitingSince ? { CHANT_GATE_WAITING_SINCE: decision.waitingSince } : {}),
1362
+ ...(decision.recorded !== undefined ? { CHANT_GATE_RECORDED: String(decision.recorded) } : {}),
1363
+ };
1364
+ const planned = new Map(decision.members.map((m) => [m.member, m]));
1365
+ const out = await runHooks(ctx, "after-gate", list, ctx.wave.runs, planned.size > 0 ? planned : undefined, decision.digest || undefined, env);
1366
+ writeRecord(recordPath, decision, ctx.result);
1367
+ if (out.failed && decision.status !== "approved" && decision.status !== "not-required") ctx.log(`${out.failed}; the wave is ${decision.status} whatever the hook came to`);
1368
+ return out;
1369
+ }
1370
+
1371
+ function relativeTo(cwd: string, path: string): string {
1372
+ return path.startsWith(cwd + "/") ? path.slice(cwd.length + 1) : path;
1373
+ }
1374
+
1375
+ /**
1376
+ * Decide each run the product's policy denied (#3821) against the override
1377
+ * ledger, `_gates/policy-override.jsonl`, as `@intentius/chant/override-gate`
1378
+ * decides: an override counts when a listed overrider wrote it for this run,
1379
+ * its plan digest and its rules, gave a reason, and (under `sealed`) sealed
1380
+ * it. Overridden runs carry their override; with any run still denied the
1381
+ * decision is `refused`, and each such run gets a pending fact on the
1382
+ * override ledger unless one stands.
1383
+ */
1384
+ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
1385
+ const { spec, k, wave, cwd, log, options } = ctx;
1386
+ const og = await import("../override-gate");
1387
+ const port = options.gates ?? gitGateLedgerPort({ cwd });
1388
+ const path = spec.override?.overriders ?? og.OVERRIDERS_PATH;
1389
+ const overriders = listed ?? og.overridersAt(cwd, decision.base, path).principals;
1390
+ const sealed = decision.approval === "sealed";
1391
+ const signers = sealed ? (options.signers ?? (await import("../gate-decision")).gateSignersAt.bind(null, cwd))(decision.base) : undefined;
1392
+ const ledger = await port.read(og.OVERRIDE_OP);
1393
+ const applied = ((await port.readApplied?.(og.OVERRIDE_OP)) ?? []) as unknown as import("../override-gate").AppliedOverrideRecord[];
1394
+ const now = options.now ?? new Date().toISOString();
1395
+ const still: string[] = [];
1396
+ let moved = false;
1397
+ for (const m of decision.members) {
1398
+ if (!m.denied || m.override) continue;
1399
+ const verdict = og.decideOverride({
1400
+ ledger,
1401
+ applied,
1402
+ subject: m.member,
1403
+ planDigest: m.planDigest,
1404
+ rules: m.denied.rules,
1405
+ overriders,
1406
+ overridersPath: listed ? "the overriders the plans file names" : path,
1407
+ mode: sealed ? "sealed" : "ledger",
1408
+ ...(signers ? { signers } : {}),
1409
+ now,
1410
+ });
1411
+ if (verdict.status === "overridden") {
1412
+ const o = verdict.override;
1413
+ m.override = { by: o.by, at: o.at, digest: o.digest, reason: o.reason };
1414
+ log(`${m.member}: the policy denial (${o.rules.join(", ")}) is overridden by ${o.by} at ${o.at}${o.sealed ? ", sealed" : ""}: ${o.reason}`);
1415
+ continue;
1416
+ }
1417
+ still.push(m.member);
1418
+ for (const why of verdict.refusals) log(`${m.member}: an override does not count: ${why}`);
1419
+ if (verdict.status === "moved") {
1420
+ moved = true;
1421
+ log(`${m.member}: ${verdict.by} overrode an earlier plan or other rules (${verdict.was ?? "no digest"}); this plan and its rules give ${verdict.digest}, so that override counts for nothing`);
1422
+ }
1423
+ if (!verdict.standing) {
1424
+ const { version: _v, kind: _k, ...fact } = og.overridePending({
1425
+ subject: m.member,
1426
+ planDigest: m.planDigest,
1427
+ rules: m.denied.rules,
1428
+ timestamp: now,
1429
+ expiresAt: new Date(new Date(now).getTime() + 48 * 3600 * 1000).toISOString(),
1430
+ description: m.denied.reason ?? `wave ${k} (${wave.name}) of ${spec.name}: ${m.member}: plan ${m.planDigest} denied by ${og.sortedRules(m.denied.rules).join(", ")}`,
1431
+ });
1432
+ try {
1433
+ await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }] } as typeof fact);
1434
+ } catch (err) {
1435
+ log(`${m.member}: the denial was not recorded for an override: ${err instanceof Error ? err.message : String(err)}`);
1436
+ }
1437
+ }
1438
+ log(`${m.member}: ${overriders.length ? `${overriders.join(", ")} may` : "nobody listed may"} override it with: ${og.overrideCommand(m.member, m.denied.rules, sealed)}`);
1439
+ }
1440
+ if (still.length > 0) {
1441
+ decision.status = "refused";
1442
+ decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
1443
+ }
1444
+ }
1445
+
1446
+ /**
1447
+ * Record each override the wave is about to apply under (#3821), in
1448
+ * `_gates/policy-override/applied.jsonl`, which spends it: written before
1449
+ * anything applies, once. False when the lines could not be written: the
1450
+ * wave then fails and applies nothing.
1451
+ */
1452
+ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
1453
+ const used = decision.members.filter((m) => m.override);
1454
+ if (used.length === 0 || decision.overridesSpentAt) return true;
1455
+ const { k, wave, cwd, log, options } = ctx;
1456
+ const og = await import("../override-gate");
1457
+ const port = options.gates ?? gitGateLedgerPort({ cwd });
1458
+ const now = options.now ?? new Date().toISOString();
1459
+ try {
1460
+ if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied override");
1461
+ for (const m of used) {
1462
+ const record = og.appliedOverride({ subject: m.member, digest: m.override!.digest, at: m.override!.at, by: m.override!.by }, { timestamp: now });
1463
+ await port.appendApplied(record as unknown as AppliedGateRecord);
1464
+ }
1465
+ } catch (err) {
1466
+ failed(ctx, `wave ${k} (${wave.name}) could not record the overrides it applies under, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
1467
+ return false;
1468
+ }
1469
+ decision.overridesSpentAt = now;
1470
+ log(`wave ${k} (${wave.name}) applies ${used.map((m) => m.member).join(", ")} under their overrides, which are now spent`);
1471
+ return true;
1472
+ }
1473
+
935
1474
  function failed(ctx: WaveContext, line: string): RunOpWaveResult {
936
1475
  ctx.log(line);
937
1476
  ctx.result.exitCode = 1;
@@ -946,20 +1485,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
946
1485
  async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
947
1486
  const { spec, k, wave, cwd } = ctx;
948
1487
  const { gate, digest, approval, base, policy, members } = decision;
949
- const ledger = options.gates ?? gitGateLedgerPort({ cwd });
950
- let port = ledger;
951
- if (approval === "sealed") {
952
- // #3684: only an approval sealed by a signer listed at base counts.
953
- const review = await import("./gate-review");
954
- const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
955
- port = {
956
- ...ledger,
957
- async approvalRule(g) {
958
- const own = (await ledger.approvalRule?.(g)) ?? null;
959
- return g === gate ? review.bothRules(own, sealed) : own;
960
- },
961
- };
962
- }
1488
+ const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
963
1489
  const now = options.now ?? new Date().toISOString();
964
1490
  // #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
965
1491
  const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
@@ -981,6 +1507,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
981
1507
  decision.approvedAt = check.resolution.timestamp;
982
1508
  return;
983
1509
  }
1510
+ // #3821: what the wait is, for the after-gate hooks and the record.
1511
+ decision.pendingFact = check.pending;
1512
+ decision.recorded = check.recorded;
1513
+ decision.waitingSince = check.pending.timestamp;
1514
+ if (check.mismatch) decision.mismatch = check.mismatch;
984
1515
  const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
985
1516
  if (used) {
986
1517
  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`);
@@ -989,6 +1520,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
989
1520
  const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
990
1521
  decision.review = byReview.review;
991
1522
  if (byReview.approved) {
1523
+ for (const key of ["pendingFact", "recorded", "waitingSince", "mismatch"] as const) delete decision[key];
992
1524
  decision.status = "approved";
993
1525
  decision.via = "pr-review";
994
1526
  decision.approvedBy = byReview.review.approvers!.join(", ");
@@ -1026,6 +1558,8 @@ async function settleHolds(
1026
1558
  await settleGate(options, ctx, decision);
1027
1559
  if (recordPath) writeRecord(recordPath, decision, result);
1028
1560
  if (decision.status === "waiting") {
1561
+ // #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
1562
+ if (recordPath) await runGateHooks(ctx, decision, recordPath);
1029
1563
  log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
1030
1564
  result.exitCode = 3;
1031
1565
  return false;
@@ -1034,8 +1568,23 @@ async function settleHolds(
1034
1568
  return true;
1035
1569
  }
1036
1570
 
1037
- /** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
1038
- type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
1571
+ /** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. `applied-nothing`: the apply said why (#3821). */
1572
+ type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" } | { outcome: "applied-nothing"; said: OpWaveApplyOutcome };
1573
+
1574
+ /** Read what an apply wrote to `CHANT_APPLY_OUTCOME` (#3821), or undefined when it wrote nothing. A file that says something else is logged and ignored. */
1575
+ function readApplyOutcome(path: string, label: string, log: (line: string) => void): OpWaveApplyOutcome | undefined {
1576
+ if (!existsSync(path)) return undefined;
1577
+ try {
1578
+ const o = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveApplyOutcome>;
1579
+ if (!OP_WAVE_APPLY_OUTCOMES.includes(o.outcome as "held")) throw new Error(`outcome ${JSON.stringify(o.outcome)} is none of ${OP_WAVE_APPLY_OUTCOMES.join(", ")}`);
1580
+ if (o.reason !== undefined && typeof o.reason !== "string") throw new Error("its reason is not text");
1581
+ const holder = o.holder && typeof o.holder === "object" && typeof o.holder.run === "string" ? { run: o.holder.run, ...(typeof o.holder.url === "string" ? { url: o.holder.url } : {}) } : undefined;
1582
+ return { outcome: o.outcome as OpWaveApplyOutcome["outcome"], ...(o.reason ? { reason: o.reason } : {}), ...(holder ? { holder } : {}) };
1583
+ } catch (err) {
1584
+ log(`${label}: the apply wrote an outcome that cannot be read (${err instanceof Error ? err.message : String(err)}), so its exit code decides`);
1585
+ return undefined;
1586
+ }
1587
+ }
1039
1588
 
1040
1589
  /**
1041
1590
  * Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
@@ -1059,6 +1608,7 @@ async function callApply(
1059
1608
  const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
1060
1609
  const filePath = `${dir}/${call.key}.json`;
1061
1610
  const resultPath = `${dir}/${call.key}-result.json`;
1611
+ const outcomePath = `${dir}/${call.key}-outcome.json`;
1062
1612
  const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
1063
1613
  if (byApply) {
1064
1614
  for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
@@ -1085,13 +1635,28 @@ async function callApply(
1085
1635
  mkdirSync(join(cwd, dir), { recursive: true });
1086
1636
  writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
1087
1637
  rmSync(join(cwd, resultPath), { force: true });
1638
+ rmSync(join(cwd, outcomePath), { force: true });
1088
1639
  let code: number;
1089
1640
  try {
1090
- code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
1641
+ code = exec(argv, cwd, { CHANT_HOOKS: filePath, CHANT_APPLY_OUTCOME: outcomePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
1091
1642
  } catch (err) {
1092
1643
  log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
1093
1644
  code = 127;
1094
1645
  }
1646
+ // #3821: an apply that applied nothing, and says why, is not a failure.
1647
+ const said = readApplyOutcome(join(cwd, outcomePath), call.label, log);
1648
+ if (said) {
1649
+ if (byApply) {
1650
+ try {
1651
+ const back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
1652
+ if (Array.isArray(back?.outcomes)) result.hooks.push(...back.outcomes);
1653
+ } catch {
1654
+ // No hooks ran.
1655
+ }
1656
+ }
1657
+ log(`${call.label}: the apply applied nothing: ${said.outcome}${said.reason ? ` (${said.reason})` : ""}${said.holder ? `, held by run ${said.holder.run}` : ""}`);
1658
+ return { outcome: "applied-nothing", said };
1659
+ }
1095
1660
  if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
1096
1661
  let back: OpWaveHooksResult | null = null;
1097
1662
  try {
@@ -1147,23 +1712,36 @@ async function applyStage(
1147
1712
  const byApply = decision.applyHooksRunBy === "apply";
1148
1713
  const holds = [...earlierHolds];
1149
1714
  if (!byApply) {
1150
- const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
1715
+ const before = await runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
1151
1716
  if (before.failed) {
1152
1717
  failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
1153
1718
  return;
1154
1719
  }
1720
+ if (before.waitingOn) {
1721
+ markHeldOn(decision, before.waitingOn);
1722
+ if (recordPath) {
1723
+ writeRecord(recordPath, decision, result);
1724
+ await runGateHooks(ctx, decision, recordPath);
1725
+ }
1726
+ result.decision = decision;
1727
+ result.exitCode = 3;
1728
+ return;
1729
+ }
1155
1730
  holds.push(...before.holds);
1156
1731
  }
1157
1732
  if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
1158
1733
  if (!(await spendApproval(options, ctx, decision))) return;
1159
- if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1734
+ if (!(await spendOverrides(ctx, decision))) return;
1735
+ if (recordPath && (decision.spentAt || decision.overridesSpentAt)) writeRecord(recordPath, decision, result);
1160
1736
  const problems: string[] = [];
1161
1737
  let waiting = false;
1738
+ let nothing: OpWaveApplyOutcome | undefined;
1162
1739
  if (spec.applyWave) {
1163
1740
  const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
1164
1741
  const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
1165
1742
  const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
1166
- if (got.outcome === "applied") {
1743
+ if (got.outcome === "applied-nothing") nothing = got.said;
1744
+ else if (got.outcome === "applied") {
1167
1745
  result.applied.push(...runs.map((r) => r.target));
1168
1746
  if (got.problem) problems.push(got.problem);
1169
1747
  } else if (got.outcome === "waiting") waiting = true;
@@ -1178,6 +1756,10 @@ async function applyStage(
1178
1756
  const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
1179
1757
  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) });
1180
1758
  const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
1759
+ if (got.outcome === "applied-nothing") {
1760
+ nothing = got.said;
1761
+ break;
1762
+ }
1181
1763
  if (got.outcome === "waiting") {
1182
1764
  waiting = true;
1183
1765
  break;
@@ -1192,6 +1774,17 @@ async function applyStage(
1192
1774
  }
1193
1775
  }
1194
1776
  if (recordPath && byApply) writeRecord(recordPath, decision, result);
1777
+ if (nothing) {
1778
+ // #3821: no run counts as failed; the wave stops here, or plans again.
1779
+ if (nothing.outcome === "replan") {
1780
+ result.replan = { ...(nothing.reason ? { reason: nothing.reason } : {}) };
1781
+ return;
1782
+ }
1783
+ result.outcome = nothing;
1784
+ result.exitCode = nothing.outcome === "stood-down" ? 6 : 5;
1785
+ if (recordPath) writeRecord(recordPath, decision, result);
1786
+ return;
1787
+ }
1195
1788
  if (waiting) {
1196
1789
  result.exitCode = 3;
1197
1790
  return;
@@ -1206,7 +1799,7 @@ async function applyStage(
1206
1799
  return;
1207
1800
  }
1208
1801
  if (!byApply) {
1209
- const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
1802
+ const after = await runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
1210
1803
  if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
1211
1804
  }
1212
1805
  if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
@@ -1343,11 +1936,16 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
1343
1936
  name: spec.name,
1344
1937
  head,
1345
1938
  timestamp: options.now ?? new Date().toISOString(),
1346
- waves: spec.waves.map((wave, i) => {
1347
- const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
1348
- return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
1349
- }),
1939
+ waves: [],
1350
1940
  };
1941
+ // A `rest` wave (#3821) goes on through the waves the plan command found.
1942
+ const last = spec.waves[spec.waves.length - 1]!;
1943
+ for (let k = 1; ; k++) {
1944
+ const wave = spec.waves[k - 1] ?? last;
1945
+ const { members, extras, waves: found } = planRuns({ spec, k, wave, cwd, exec }, wave.runs, wave.discover === true);
1946
+ plans.waves.push({ wave: k, name: wave.name, digest: opWaveSetDigest(spec, members, extras) });
1947
+ if (k >= spec.waves.length && !(last.rest && found !== undefined && k < found)) break;
1948
+ }
1351
1949
  await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
1352
1950
  return plans;
1353
1951
  }