@intentius/chant 0.124.3 → 0.124.4

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,12 @@ 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[];
92
106
  }
93
107
 
94
108
  /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
@@ -114,6 +128,10 @@ export interface OpWaveMember extends WaveMember {
114
128
  op: string;
115
129
  destructive?: boolean;
116
130
  empty?: boolean;
131
+ /** The product's policy denied the plan (#3821). */
132
+ denied?: { rules: string[]; reason?: string };
133
+ /** The override that lets the denied plan through (#3821). */
134
+ override?: { by: string; at: string; digest: string; reason: string };
117
135
  }
118
136
 
119
137
  /** Where a wave's policy came from. */
@@ -134,8 +152,12 @@ export interface OpWaveDecision {
134
152
  base: string;
135
153
  digest: string;
136
154
  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";
155
+ /**
156
+ * `approved`: an approval of this digest stands. `not-required`: the policy
157
+ * lets it through. `waiting`: neither. `refused`: the product's policy
158
+ * denied a run and no override counts (#3821, exit 7).
159
+ */
160
+ status: "approved" | "not-required" | "waiting" | "refused";
139
161
  approvedBy?: string;
140
162
  /** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
141
163
  via?: "ledger" | "pr-review";
@@ -168,6 +190,52 @@ export interface OpWaveDecision {
168
190
  pending?: Record<string, unknown>;
169
191
  /** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
170
192
  neverOverMcp?: true;
193
+ /** While waiting (#3821): the pending fact that stands for the digest, this run's own when `recorded`. */
194
+ pendingFact?: PendingGateRecord;
195
+ /** While waiting: whether this run recorded the pending fact, rather than finding one standing. */
196
+ recorded?: boolean;
197
+ /** While waiting: when the wait began, the standing pending fact's time. */
198
+ waitingSince?: string;
199
+ /** While waiting: the newest approval of this gate for another digest, which the plans moved away from. */
200
+ mismatch?: GateDigestMismatch;
201
+ /** Why a `refused` wave applies nothing (#3821): the runs the policy denied, and whether an override stands for an earlier plan or other rules. */
202
+ refused?: { reason: "policy" | "override-moved"; runs: string[] };
203
+ /**
204
+ * A hook held the wave on another gate (#3821), such as a state migration
205
+ * behind its own: that gate, its digest, and how it came out. While it
206
+ * waits, the wave waits (exit 3) and `approve` approves it.
207
+ */
208
+ heldOn?: OpWaveHeldOn;
209
+ /** When the wave recorded the overrides it applies under (#3821), which spent them. */
210
+ overridesSpentAt?: string;
211
+ }
212
+
213
+ /** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
214
+ export interface OpWaveHoldRequest {
215
+ /** The op the gate is recorded under. Default: the spec's name. */
216
+ op?: string;
217
+ gate: string;
218
+ /** The digest an approval binds. */
219
+ digest: string;
220
+ description?: string;
221
+ /** Fields for the pending fact, as a plans file's `pending`. */
222
+ pending?: Record<string, unknown>;
223
+ }
224
+
225
+ /** A hold on another gate, as the decision records it (#3821). */
226
+ export interface OpWaveHeldOn {
227
+ op: string;
228
+ gate: string;
229
+ digest: string;
230
+ /** The hook that asked. */
231
+ hook: string;
232
+ status: "approved" | "waiting";
233
+ approvedBy?: string;
234
+ approvedAt?: string;
235
+ approve?: string;
236
+ waitingSince?: string;
237
+ recorded?: boolean;
238
+ mismatch?: GateDigestMismatch;
171
239
  }
172
240
 
173
241
  /** One hook's run (#3762). */
@@ -432,10 +500,12 @@ export interface RunOpWaveOptions {
432
500
  headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
433
501
  /** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
434
502
  trust?: (base: string) => TrustPolicy;
503
+ /** The signers at base, for a sealed override of a policy denial (#3821). Default: `gateSignersAt` from `@intentius/chant/gate-decision`. */
504
+ signers?: (base: string) => GateSigners;
435
505
  }
436
506
 
437
507
  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. */
508
+ /** 0 applied (or decided), 1 a run or a hook failed, 3 waiting for an approval, 4 a plan moved since the decision, 7 the product's policy denied a run and no override counts (#3821). */
439
509
  exitCode: number;
440
510
  decision?: OpWaveDecision;
441
511
  applied: string[];
@@ -455,6 +525,9 @@ interface WaveContext {
455
525
  exec: OpWaveExec;
456
526
  log: (line: string) => void;
457
527
  result: RunOpWaveResult;
528
+ options: RunOpWaveOptions;
529
+ /** 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. */
530
+ gateRule: { approval: GateApprovalSource; base: string; neverOverMcp: boolean };
458
531
  }
459
532
 
460
533
  function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
@@ -476,12 +549,20 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
476
549
  }
477
550
 
478
551
  function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
552
+ const denied = file.denied as unknown;
553
+ if (denied !== undefined) {
554
+ const d = denied as { rules?: unknown; reason?: unknown } | null;
555
+ 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")) {
556
+ throw new Error(`the plan of ${target} has denied ${JSON.stringify(denied)}; a denial is { "rules": ["<rule id>", ...], "reason"?: "<text>" }`);
557
+ }
558
+ }
479
559
  return {
480
560
  member: target,
481
561
  planDigest: file.planDigest,
482
562
  op,
483
563
  ...(file.destructive === true ? { destructive: true } : {}),
484
564
  ...(file.empty === true ? { empty: true } : {}),
565
+ ...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
485
566
  };
486
567
  }
487
568
 
@@ -549,16 +630,22 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
549
630
  const members = runs.map((run) => {
550
631
  const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
551
632
  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");
633
+ writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}) }) + "\n");
553
634
  return m;
554
635
  });
555
- return { members, ...(pending ? { pending } : {}) };
636
+ const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
637
+ if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
638
+ throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
639
+ }
640
+ return { members, ...(pending ? { pending } : {}), ...(overriders ? { overriders: overriders as string[] } : {}) };
556
641
  }
557
642
 
558
643
  /** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
559
644
  interface PlannedRuns {
560
645
  members: OpWaveMember[];
561
646
  pending?: Record<string, unknown>;
647
+ /** The plans file's overriders (#3821). */
648
+ overriders?: string[];
562
649
  }
563
650
 
564
651
  /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
@@ -587,6 +674,32 @@ interface HooksOutcome {
587
674
  failed?: string;
588
675
  /** The `onFailure: approve` hooks that failed. */
589
676
  holds: string[];
677
+ /** A hook held the wave on another gate that nobody approved yet (#3821): the wave waits. */
678
+ waitingOn?: OpWaveHeldOn;
679
+ }
680
+
681
+ /** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
682
+ interface OtherGateHold {
683
+ label: string;
684
+ name: string;
685
+ target?: string;
686
+ argv: string[];
687
+ env: Record<string, string>;
688
+ request: OpWaveHoldRequest;
689
+ }
690
+
691
+ /** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
692
+ function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
693
+ if (!existsSync(path)) return undefined;
694
+ try {
695
+ const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
696
+ if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
697
+ if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
698
+ 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`) ?? {} } : {}) };
699
+ } catch (err) {
700
+ 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`);
701
+ return undefined;
702
+ }
590
703
  }
591
704
 
592
705
  /** Where hooks run: the wave they belong to and the job's tools. */
@@ -612,11 +725,14 @@ function runHookList(
612
725
  hooks: readonly OpWaveHook[] | undefined,
613
726
  runs: readonly OpWaveHookRun[],
614
727
  runsPath: string,
615
- ): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
616
- const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
728
+ extraEnv: Record<string, string> = {},
729
+ ): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
730
+ const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
617
731
  if (!hooks || hooks.length === 0) return out;
618
732
  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 };
733
+ // #3821: a hook that holds the wave on another gate names it in this file.
734
+ const holdPath = `.chant/op-waves/${specName}/wave-${k}/hold.json`;
735
+ 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
736
  for (const hook of hooks) {
621
737
  const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
622
738
  const calls = hook.perRun
@@ -629,6 +745,7 @@ function runHookList(
629
745
  for (const call of calls) {
630
746
  const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
631
747
  let code: number;
748
+ rmSync(join(cwd, holdPath), { force: true });
632
749
  try {
633
750
  code = exec(call.argv, cwd, call.env);
634
751
  } catch (err) {
@@ -642,6 +759,12 @@ function runHookList(
642
759
  }
643
760
  if (hook.onFailure === "approve") {
644
761
  out.outcomes.push({ ...base, outcome: "held" });
762
+ const request = readHoldRequest(join(cwd, holdPath), log, label);
763
+ if (request) {
764
+ out.otherGates.push({ label, name, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
765
+ log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
766
+ continue;
767
+ }
645
768
  out.holds.push(label);
646
769
  log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
647
770
  continue;
@@ -660,20 +783,105 @@ const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx
660
783
  const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
661
784
  runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
662
785
 
663
- /** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
664
- function runHooks(
786
+ /**
787
+ * Run one moment's hooks in the wave job (#3762), recording each outcome on
788
+ * the result. A hook that held the wave on another gate (#3821) has that
789
+ * gate settled here: approved, the hook runs again with
790
+ * `CHANT_HOLD_APPROVED` and must pass; otherwise the wave waits on it.
791
+ */
792
+ async function runHooks(
665
793
  ctx: WaveContext,
666
794
  moment: OpWaveHookMoment,
667
795
  hooks: readonly OpWaveHook[] | undefined,
668
796
  runs: readonly OpWaveRun[],
669
797
  members?: ReadonlyMap<string, OpWaveMember>,
670
798
  digest?: string,
671
- ): HooksOutcome {
799
+ extraEnv: Record<string, string> = {},
800
+ ): Promise<HooksOutcome> {
672
801
  if (!hooks || hooks.length === 0) return { holds: [] };
673
802
  const runsPath = writeRunsFile(ctx, runs, members, digest);
674
- const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
803
+ const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath, extraEnv);
675
804
  ctx.result.hooks.push(...out.outcomes);
676
- return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
805
+ const back: HooksOutcome = { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
806
+ if (out.failed) return back;
807
+ for (const hold of out.otherGates) {
808
+ const held = await settleOtherGate(ctx, hold);
809
+ if (held.status === "waiting") {
810
+ back.waitingOn ??= held;
811
+ continue;
812
+ }
813
+ const approvedEnv = { ...hold.env, CHANT_HOLD_APPROVED: JSON.stringify({ op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy, approvedAt: held.approvedAt }) };
814
+ let code: number;
815
+ try {
816
+ code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
817
+ } catch (err) {
818
+ ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
819
+ code = 127;
820
+ }
821
+ ctx.result.hooks.push({ moment, name: hold.name, ...(hold.target ? { target: hold.target } : {}), exit: code, outcome: code === 0 ? "passed" : "failed" });
822
+ if (code !== 0) {
823
+ back.failed = `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`;
824
+ ctx.log(back.failed);
825
+ return back;
826
+ }
827
+ ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
828
+ }
829
+ return back;
830
+ }
831
+
832
+ /** The gate ledger a wave decides by: the job's port, with the signers at base as the rule when `approval` is `sealed`. */
833
+ async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, approval: GateApprovalSource, base: string): Promise<{ ledger: GateLedgerPort; port: GateLedgerPort }> {
834
+ const ledger = options.gates ?? gitGateLedgerPort({ cwd });
835
+ if (approval !== "sealed") return { ledger, port: ledger };
836
+ // #3684: only an approval sealed by a signer listed at base counts.
837
+ const review = await import("./gate-review");
838
+ const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
839
+ return {
840
+ ledger,
841
+ port: {
842
+ ...ledger,
843
+ async approvalRule(g) {
844
+ const own = (await ledger.approvalRule?.(g)) ?? null;
845
+ return g === gate ? review.bothRules(own, sealed) : own;
846
+ },
847
+ },
848
+ };
849
+ }
850
+
851
+ /**
852
+ * Settle a hold on another gate (#3821): an approval of that gate and digest
853
+ * under the wave's approval mode (`pr-review` counts the ledger alone, since
854
+ * no review planned that digest), else a pending fact for it.
855
+ */
856
+ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<OpWaveHeldOn> {
857
+ const { spec, k, wave, log } = ctx;
858
+ const rules = ctx.gateRule;
859
+ const op = hold.request.op ?? spec.name;
860
+ const mode: GateApprovalSource = rules.approval === "sealed" ? "sealed" : "ledger";
861
+ const { port } = await gatePort(ctx.options, ctx.cwd, hold.request.gate, mode, rules.base);
862
+ const neverOverMcp = rules.neverOverMcp;
863
+ const now = ctx.options.now ?? new Date().toISOString();
864
+ const check = await evaluateGate(port, {
865
+ op,
866
+ gate: hold.request.gate,
867
+ planDigest: hold.request.digest,
868
+ description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
869
+ now,
870
+ ...(hold.request.pending ? { fields: hold.request.pending } : {}),
871
+ ...(neverOverMcp ? { neverOverMcp: true } : {}),
872
+ });
873
+ const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
874
+ if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
875
+ const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
876
+ log(`wave ${k} (${wave.name}) waits on ${op} / ${hold.request.gate}, which ${hold.label} holds it on: nobody approved ${hold.request.digest}. Approve: ${approve}`);
877
+ return {
878
+ ...base,
879
+ status: "waiting",
880
+ approve,
881
+ waitingSince: check.pending.timestamp,
882
+ recorded: check.recorded,
883
+ ...(check.mismatch ? { mismatch: check.mismatch } : {}),
884
+ };
677
885
  }
678
886
 
679
887
  /**
@@ -725,12 +933,14 @@ export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMom
725
933
  } else {
726
934
  const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
727
935
  const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
936
+ // A hold on another gate (#3821) is settled only by the wave job; under the apply's lock it stops the apply.
937
+ const others = out.otherGates.map((o) => `${o.label} on ${o.request.op ?? file.opWaves} / ${o.request.gate}`);
728
938
  run = {
729
939
  moment,
730
940
  outcomes: out.outcomes,
731
941
  ...(out.failed ? { failed: out.failed } : {}),
732
- held: out.holds,
733
- proceed: !out.failed && (out.holds.length === 0 || file.approved),
942
+ held: [...out.holds, ...others],
943
+ proceed: !out.failed && others.length === 0 && (out.holds.length === 0 || file.approved),
734
944
  };
735
945
  }
736
946
  if (file.result) {
@@ -827,7 +1037,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
827
1037
  }
828
1038
  const recordPath = join(cwd, opWaveRecordPath(spec, k));
829
1039
  const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
830
- const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result };
1040
+ const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result, options, gateRule: { approval: "ledger", base: "HEAD", neverOverMcp: spec.neverOverMcp === true } };
831
1041
 
832
1042
  if (options.share !== undefined) {
833
1043
  let decision: OpWaveDecision;
@@ -837,18 +1047,21 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
837
1047
  throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
838
1048
  }
839
1049
  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 };
1050
+ ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
1051
+ if (decision.status === "waiting" || decision.status === "refused") {
1052
+ 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());
1053
+ return { ...result, exitCode: decision.status === "refused" ? 7 : 3 };
843
1054
  }
844
1055
  // The share runs the hooks its deciding job read at base, around its own slice.
845
1056
  const slice = opWaveShare(wave.runs, options.share, shares);
846
- const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
1057
+ const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
847
1058
  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`);
1059
+ if (before.waitingOn) return waitOnOther(ctx, decision, before.waitingOn);
848
1060
  const { members } = planRuns(ctx, slice);
849
1061
  const planned = new Map(members.map((m) => [m.member, m]));
850
- const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
1062
+ const after = await runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
851
1063
  if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
1064
+ if (after.waitingOn) return waitOnOther(ctx, decision, after.waitingOn);
852
1065
  const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
853
1066
  const unmoved: OpWaveRun[] = [];
854
1067
  for (const run of slice) {
@@ -871,21 +1084,14 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
871
1084
  // The policy, the approval mode and the hooks, read at base before anything runs.
872
1085
  const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
873
1086
  const { policy, approval, source, base } = rules;
1087
+ const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
1088
+ ctx.gateRule = { approval, base, neverOverMcp };
874
1089
  noteHooksAtBase(ctx, rules);
875
1090
  const holds: string[] = [];
876
1091
  if (rules.hooksRefused) {
877
1092
  holds.push(rules.hooksRefused);
878
1093
  log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
879
1094
  }
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
1095
  const gate = opWaveGateName(spec, k);
890
1096
  const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
891
1097
  const decision: OpWaveDecision = {
@@ -897,41 +1103,229 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
897
1103
  approval,
898
1104
  policySource: source,
899
1105
  base,
900
- digest,
901
- members,
1106
+ digest: "",
1107
+ members: [],
902
1108
  status: "not-required",
903
1109
  ...(Object.keys(hooks).length > 0 ? { hooks } : {}),
904
- ...(holds.length > 0 ? { heldBy: holds } : {}),
905
1110
  ...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
906
1111
  ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
907
1112
  ...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
908
- ...(pending ? { pending } : {}),
909
1113
  ...(neverOverMcp ? { neverOverMcp: true as const } : {}),
910
1114
  };
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;
1115
+ const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
1116
+ if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
1117
+ if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
1118
+ const { members, pending, overriders } = planRuns(ctx, wave.runs);
1119
+ const planned = new Map(members.map((m) => [m.member, m]));
1120
+ const digest = opWaveSetDigest(spec, members);
1121
+ decision.digest = digest;
1122
+ decision.members = members;
1123
+ if (pending) decision.pending = pending;
1124
+ const after = await runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
1125
+ if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
1126
+ holds.push(...before.holds, ...after.holds);
1127
+ if (holds.length > 0) decision.heldBy = holds;
1128
+ if (after.waitingOn) return decided(ctx, decision, recordPath, after.waitingOn);
1129
+ // #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.
1130
+ if (members.some((m) => m.denied)) await settleDenials(ctx, decision, overriders);
1131
+ if (decision.status !== "refused" && (opWaveNeedsApproval(policy, members) || holds.length > 0)) await settleGate(options, ctx, decision);
1132
+ const code = await decided(ctx, decision, recordPath);
1133
+ if (code.exitCode !== 0) return code;
917
1134
  log(
918
1135
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
919
1136
  `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
920
1137
  (decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
921
1138
  (holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
922
1139
  );
923
- if (approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
1140
+ // A wide wave's shares apply under the deciding job's approval and overrides, so they are spent here, once.
1141
+ if (options.decide) {
1142
+ if (!(await spendApproval(options, ctx, decision)) || !(await spendOverrides(ctx, decision))) return ctx.result;
1143
+ if (decision.spentAt || decision.overridesSpentAt) writeRecord(recordPath, decision, result);
1144
+ return result;
1145
+ }
1146
+ await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
1147
+ return result;
1148
+ }
1149
+
1150
+ /**
1151
+ * The gate has decided (#3821): write the decision record, run the
1152
+ * `after-gate` hooks with the decision in their environment, and say whether
1153
+ * the wave goes on. Returns the result with exit 3 when it waits, 7 when its
1154
+ * policy refused it, 1 when an `after-gate` hook failed on a wave that would
1155
+ * apply; exit 0 to go on. A hook that fails while the wave waits or is
1156
+ * refused is logged and leaves the exit code alone.
1157
+ */
1158
+ async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: string, heldOn?: OpWaveHeldOn): Promise<RunOpWaveResult> {
1159
+ const { k, wave, log, result } = ctx;
1160
+ if (heldOn) markHeldOn(decision, heldOn);
1161
+ mkdirSync(dirname(recordPath), { recursive: true });
1162
+ writeRecord(recordPath, decision, result);
1163
+ result.decision = decision;
1164
+ if (decision.approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
924
1165
  log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
925
1166
  }
1167
+ const hook = await runGateHooks(ctx, decision, recordPath);
926
1168
  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)`);
1169
+ 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
1170
  return { ...result, exitCode: 3 };
929
1171
  }
930
- if (options.decide) return result;
931
- await applyStage(options, ctx, decision, wave.runs, planned, [], recordPath);
1172
+ if (decision.status === "refused") {
1173
+ 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"}.`);
1174
+ return { ...result, exitCode: 7 };
1175
+ }
1176
+ if (hook.failed) return failed(ctx, `wave ${k} (${wave.name}): ${hook.failed}, so nothing in it was applied`);
932
1177
  return result;
933
1178
  }
934
1179
 
1180
+ /** A hold on another gate that waits makes the wave wait on it (#3821). */
1181
+ function markHeldOn(decision: OpWaveDecision, heldOn: OpWaveHeldOn): void {
1182
+ decision.heldOn = heldOn;
1183
+ decision.status = "waiting";
1184
+ decision.approve = heldOn.approve;
1185
+ if (heldOn.waitingSince) decision.waitingSince = heldOn.waitingSince;
1186
+ if (heldOn.recorded !== undefined) decision.recorded = heldOn.recorded;
1187
+ }
1188
+
1189
+ /** A share job held on another gate that waits (#3821). */
1190
+ async function waitOnOther(ctx: WaveContext, decision: OpWaveDecision, heldOn: OpWaveHeldOn): Promise<RunOpWaveResult> {
1191
+ markHeldOn(decision, heldOn);
1192
+ 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}`);
1193
+ return { ...ctx.result, decision, exitCode: 3 };
1194
+ }
1195
+
1196
+ /**
1197
+ * Run the `after-gate` hooks (#3821) once the gate decided. Their
1198
+ * environment carries the decision: `CHANT_GATE_STATUS` (`approved`,
1199
+ * `not-required`, `waiting` or `refused`), `CHANT_GATE_OP`, `CHANT_GATE`,
1200
+ * `CHANT_GATE_DIGEST`, `CHANT_DECISION` (the decision record, with the
1201
+ * standing pending fact, the approval of another digest and the denials),
1202
+ * and when set `CHANT_GATE_APPROVE`, `CHANT_GATE_WAITING_SINCE` and
1203
+ * `CHANT_GATE_RECORDED` (`true` when this run recorded the pending fact).
1204
+ */
1205
+ async function runGateHooks(ctx: WaveContext, decision: OpWaveDecision, recordPath: string): Promise<HooksOutcome> {
1206
+ const list = decision.hooks?.["after-gate"];
1207
+ if (!list?.length) return { holds: [] };
1208
+ const on = decision.heldOn;
1209
+ const env: Record<string, string> = {
1210
+ CHANT_GATE_STATUS: decision.status,
1211
+ CHANT_GATE_OP: on?.op ?? decision.op,
1212
+ CHANT_GATE: on?.gate ?? decision.gate,
1213
+ CHANT_GATE_DIGEST: on?.digest ?? decision.digest,
1214
+ CHANT_DECISION: relativeTo(ctx.cwd, recordPath),
1215
+ ...(decision.approve ? { CHANT_GATE_APPROVE: decision.approve } : {}),
1216
+ ...(decision.waitingSince ? { CHANT_GATE_WAITING_SINCE: decision.waitingSince } : {}),
1217
+ ...(decision.recorded !== undefined ? { CHANT_GATE_RECORDED: String(decision.recorded) } : {}),
1218
+ };
1219
+ const planned = new Map(decision.members.map((m) => [m.member, m]));
1220
+ const out = await runHooks(ctx, "after-gate", list, ctx.wave.runs, planned.size > 0 ? planned : undefined, decision.digest || undefined, env);
1221
+ writeRecord(recordPath, decision, ctx.result);
1222
+ if (out.failed && decision.status !== "approved" && decision.status !== "not-required") ctx.log(`${out.failed}; the wave is ${decision.status} whatever the hook came to`);
1223
+ return out;
1224
+ }
1225
+
1226
+ function relativeTo(cwd: string, path: string): string {
1227
+ return path.startsWith(cwd + "/") ? path.slice(cwd.length + 1) : path;
1228
+ }
1229
+
1230
+ /**
1231
+ * Decide each run the product's policy denied (#3821) against the override
1232
+ * ledger, `_gates/policy-override.jsonl`, as `@intentius/chant/override-gate`
1233
+ * decides: an override counts when a listed overrider wrote it for this run,
1234
+ * its plan digest and its rules, gave a reason, and (under `sealed`) sealed
1235
+ * it. Overridden runs carry their override; with any run still denied the
1236
+ * decision is `refused`, and each such run gets a pending fact on the
1237
+ * override ledger unless one stands.
1238
+ */
1239
+ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
1240
+ const { spec, k, wave, cwd, log, options } = ctx;
1241
+ const og = await import("../override-gate");
1242
+ const port = options.gates ?? gitGateLedgerPort({ cwd });
1243
+ const path = spec.override?.overriders ?? og.OVERRIDERS_PATH;
1244
+ const overriders = listed ?? og.overridersAt(cwd, decision.base, path).principals;
1245
+ const sealed = decision.approval === "sealed";
1246
+ const signers = sealed ? (options.signers ?? (await import("../gate-decision")).gateSignersAt.bind(null, cwd))(decision.base) : undefined;
1247
+ const ledger = await port.read(og.OVERRIDE_OP);
1248
+ const applied = ((await port.readApplied?.(og.OVERRIDE_OP)) ?? []) as unknown as import("../override-gate").AppliedOverrideRecord[];
1249
+ const now = options.now ?? new Date().toISOString();
1250
+ const still: string[] = [];
1251
+ let moved = false;
1252
+ for (const m of decision.members) {
1253
+ if (!m.denied) continue;
1254
+ const verdict = og.decideOverride({
1255
+ ledger,
1256
+ applied,
1257
+ subject: m.member,
1258
+ planDigest: m.planDigest,
1259
+ rules: m.denied.rules,
1260
+ overriders,
1261
+ overridersPath: listed ? "the overriders the plans file names" : path,
1262
+ mode: sealed ? "sealed" : "ledger",
1263
+ ...(signers ? { signers } : {}),
1264
+ now,
1265
+ });
1266
+ if (verdict.status === "overridden") {
1267
+ const o = verdict.override;
1268
+ m.override = { by: o.by, at: o.at, digest: o.digest, reason: o.reason };
1269
+ log(`${m.member}: the policy denial (${o.rules.join(", ")}) is overridden by ${o.by} at ${o.at}${o.sealed ? ", sealed" : ""}: ${o.reason}`);
1270
+ continue;
1271
+ }
1272
+ still.push(m.member);
1273
+ for (const why of verdict.refusals) log(`${m.member}: an override does not count: ${why}`);
1274
+ if (verdict.status === "moved") {
1275
+ moved = true;
1276
+ 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`);
1277
+ }
1278
+ if (!verdict.standing) {
1279
+ const { version: _v, kind: _k, ...fact } = og.overridePending({
1280
+ subject: m.member,
1281
+ planDigest: m.planDigest,
1282
+ rules: m.denied.rules,
1283
+ timestamp: now,
1284
+ expiresAt: new Date(new Date(now).getTime() + 48 * 3600 * 1000).toISOString(),
1285
+ description: m.denied.reason ?? `wave ${k} (${wave.name}) of ${spec.name}: ${m.member}: plan ${m.planDigest} denied by ${og.sortedRules(m.denied.rules).join(", ")}`,
1286
+ });
1287
+ try {
1288
+ await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }] } as typeof fact);
1289
+ } catch (err) {
1290
+ log(`${m.member}: the denial was not recorded for an override: ${err instanceof Error ? err.message : String(err)}`);
1291
+ }
1292
+ }
1293
+ log(`${m.member}: ${overriders.length ? `${overriders.join(", ")} may` : "nobody listed may"} override it with: ${og.overrideCommand(m.member, m.denied.rules, sealed)}`);
1294
+ }
1295
+ if (still.length > 0) {
1296
+ decision.status = "refused";
1297
+ decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
1298
+ }
1299
+ }
1300
+
1301
+ /**
1302
+ * Record each override the wave is about to apply under (#3821), in
1303
+ * `_gates/policy-override/applied.jsonl`, which spends it: written before
1304
+ * anything applies, once. False when the lines could not be written: the
1305
+ * wave then fails and applies nothing.
1306
+ */
1307
+ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
1308
+ const used = decision.members.filter((m) => m.override);
1309
+ if (used.length === 0 || decision.overridesSpentAt) return true;
1310
+ const { k, wave, cwd, log, options } = ctx;
1311
+ const og = await import("../override-gate");
1312
+ const port = options.gates ?? gitGateLedgerPort({ cwd });
1313
+ const now = options.now ?? new Date().toISOString();
1314
+ try {
1315
+ if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied override");
1316
+ for (const m of used) {
1317
+ const record = og.appliedOverride({ subject: m.member, digest: m.override!.digest, at: m.override!.at, by: m.override!.by }, { timestamp: now });
1318
+ await port.appendApplied(record as unknown as AppliedGateRecord);
1319
+ }
1320
+ } catch (err) {
1321
+ failed(ctx, `wave ${k} (${wave.name}) could not record the overrides it applies under, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
1322
+ return false;
1323
+ }
1324
+ decision.overridesSpentAt = now;
1325
+ log(`wave ${k} (${wave.name}) applies ${used.map((m) => m.member).join(", ")} under their overrides, which are now spent`);
1326
+ return true;
1327
+ }
1328
+
935
1329
  function failed(ctx: WaveContext, line: string): RunOpWaveResult {
936
1330
  ctx.log(line);
937
1331
  ctx.result.exitCode = 1;
@@ -946,20 +1340,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
946
1340
  async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
947
1341
  const { spec, k, wave, cwd } = ctx;
948
1342
  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
- }
1343
+ const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
963
1344
  const now = options.now ?? new Date().toISOString();
964
1345
  // #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
965
1346
  const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
@@ -981,6 +1362,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
981
1362
  decision.approvedAt = check.resolution.timestamp;
982
1363
  return;
983
1364
  }
1365
+ // #3821: what the wait is, for the after-gate hooks and the record.
1366
+ decision.pendingFact = check.pending;
1367
+ decision.recorded = check.recorded;
1368
+ decision.waitingSince = check.pending.timestamp;
1369
+ if (check.mismatch) decision.mismatch = check.mismatch;
984
1370
  const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
985
1371
  if (used) {
986
1372
  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 +1375,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
989
1375
  const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
990
1376
  decision.review = byReview.review;
991
1377
  if (byReview.approved) {
1378
+ for (const key of ["pendingFact", "recorded", "waitingSince", "mismatch"] as const) delete decision[key];
992
1379
  decision.status = "approved";
993
1380
  decision.via = "pr-review";
994
1381
  decision.approvedBy = byReview.review.approvers!.join(", ");
@@ -1026,6 +1413,8 @@ async function settleHolds(
1026
1413
  await settleGate(options, ctx, decision);
1027
1414
  if (recordPath) writeRecord(recordPath, decision, result);
1028
1415
  if (decision.status === "waiting") {
1416
+ // #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
1417
+ if (recordPath) await runGateHooks(ctx, decision, recordPath);
1029
1418
  log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
1030
1419
  result.exitCode = 3;
1031
1420
  return false;
@@ -1147,16 +1536,27 @@ async function applyStage(
1147
1536
  const byApply = decision.applyHooksRunBy === "apply";
1148
1537
  const holds = [...earlierHolds];
1149
1538
  if (!byApply) {
1150
- const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
1539
+ const before = await runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
1151
1540
  if (before.failed) {
1152
1541
  failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
1153
1542
  return;
1154
1543
  }
1544
+ if (before.waitingOn) {
1545
+ markHeldOn(decision, before.waitingOn);
1546
+ if (recordPath) {
1547
+ writeRecord(recordPath, decision, result);
1548
+ await runGateHooks(ctx, decision, recordPath);
1549
+ }
1550
+ result.decision = decision;
1551
+ result.exitCode = 3;
1552
+ return;
1553
+ }
1155
1554
  holds.push(...before.holds);
1156
1555
  }
1157
1556
  if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
1158
1557
  if (!(await spendApproval(options, ctx, decision))) return;
1159
- if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1558
+ if (!(await spendOverrides(ctx, decision))) return;
1559
+ if (recordPath && (decision.spentAt || decision.overridesSpentAt)) writeRecord(recordPath, decision, result);
1160
1560
  const problems: string[] = [];
1161
1561
  let waiting = false;
1162
1562
  if (spec.applyWave) {
@@ -1206,7 +1606,7 @@ async function applyStage(
1206
1606
  return;
1207
1607
  }
1208
1608
  if (!byApply) {
1209
- const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
1609
+ const after = await runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
1210
1610
  if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
1211
1611
  }
1212
1612
  if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);