@intentius/chant 0.124.2 → 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.
Files changed (73) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/override-approve.d.ts +3 -1
  3. package/dist/cli/handlers/override-approve.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/lifecycle/gate-ledger.d.ts +1 -33
  8. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  9. package/dist/lifecycle/gate-pending.d.ts +41 -0
  10. package/dist/lifecycle/gate-pending.d.ts.map +1 -0
  11. package/dist/op/activities/activity-contracts.d.ts +5 -4
  12. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  13. package/dist/op/activities/decide.d.ts +2 -0
  14. package/dist/op/activities/decide.d.ts.map +1 -1
  15. package/dist/op/gate-point-run.d.ts +5 -77
  16. package/dist/op/gate-point-run.d.ts.map +1 -1
  17. package/dist/op/gate-point.d.ts +18 -0
  18. package/dist/op/gate-point.d.ts.map +1 -1
  19. package/dist/op/gate.d.ts +8 -0
  20. package/dist/op/gate.d.ts.map +1 -1
  21. package/dist/op/index.d.ts +4 -4
  22. package/dist/op/index.d.ts.map +1 -1
  23. package/dist/op/op-waves-notify.d.ts +2 -1
  24. package/dist/op/op-waves-notify.d.ts.map +1 -1
  25. package/dist/op/op-waves-run.d.ts +123 -10
  26. package/dist/op/op-waves-run.d.ts.map +1 -1
  27. package/dist/op/op-waves.d.ts +71 -6
  28. package/dist/op/op-waves.d.ts.map +1 -1
  29. package/dist/op/steward-points.d.ts +7 -0
  30. package/dist/op/steward-points.d.ts.map +1 -1
  31. package/dist/override-gate.d.ts +8 -2
  32. package/dist/override-gate.d.ts.map +1 -1
  33. package/dist/point-decision.d.ts +134 -0
  34. package/dist/point-decision.d.ts.map +1 -0
  35. package/dist/pr-forge.d.ts +37 -2
  36. package/dist/pr-forge.d.ts.map +1 -1
  37. package/dist/workspace/decide.d.ts +3 -1
  38. package/dist/workspace/decide.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/discovery-skip.e2e.test.ts +4 -1
  41. package/src/cli/handlers/operator.ts +25 -11
  42. package/src/cli/handlers/override-approve.test.ts +16 -0
  43. package/src/cli/handlers/override-approve.ts +6 -2
  44. package/src/cli/main.test.ts +3 -0
  45. package/src/cli/main.ts +9 -2
  46. package/src/cli/registry.ts +2 -0
  47. package/src/lifecycle/gate-ledger.ts +3 -53
  48. package/src/lifecycle/gate-pending.ts +62 -0
  49. package/src/notify-bundle.test.ts +20 -0
  50. package/src/op/activities/activity-contracts.ts +1 -0
  51. package/src/op/activities/decide.ts +3 -0
  52. package/src/op/gate-point-ci.e2e.test.ts +171 -0
  53. package/src/op/gate-point-mcp.e2e.test.ts +2 -0
  54. package/src/op/gate-point-run.ts +18 -149
  55. package/src/op/gate-point.e2e.test.ts +2 -0
  56. package/src/op/gate-point.test.ts +1 -1
  57. package/src/op/gate-point.ts +27 -3
  58. package/src/op/gate.ts +10 -0
  59. package/src/op/index.ts +12 -1
  60. package/src/op/op-waves-notify.ts +19 -2
  61. package/src/op/op-waves-run.ts +541 -76
  62. package/src/op/op-waves.test.ts +229 -0
  63. package/src/op/op-waves.ts +110 -10
  64. package/src/op/steward-points.ts +8 -0
  65. package/src/override-gate.test.ts +5 -0
  66. package/src/override-gate.ts +8 -2
  67. package/src/point-decision-bundle.test.ts +62 -0
  68. package/src/point-decision.e2e.test.ts +95 -0
  69. package/src/point-decision.ts +280 -0
  70. package/src/pr-forge.test.ts +62 -0
  71. package/src/pr-forge.ts +60 -9
  72. package/src/workspace/decide.ts +7 -2
  73. package/src/workspace/points-cli.ts +1 -1
@@ -42,12 +42,14 @@
42
42
  import { execFileSync, spawnSync } from "node:child_process";
43
43
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
44
  import { dirname, isAbsolute, join, posix } from "node:path";
45
- import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
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,
@@ -56,6 +58,7 @@ import {
56
58
  assertOpWaveHooks,
57
59
  assertOpWavesSpec,
58
60
  opWaveApplyHooksRunBy,
61
+ opWaveGateName,
59
62
  opWaveHooksAt,
60
63
  opWaveRecordPath,
61
64
  opWaveShare,
@@ -76,13 +79,38 @@ export interface OpWavePlanFile {
76
79
  destructive?: boolean;
77
80
  /** The plan changes nothing. */
78
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 };
79
88
  }
80
89
 
81
90
  /** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
82
91
  export interface OpWavePlansFile {
83
92
  runs: Array<OpWavePlanFile & { target: string }>;
93
+ /**
94
+ * Fields the product adds to the wave's pending fact (#3821), such as the
95
+ * commit it planned or the members its own readers expect. chant's own
96
+ * fields win: a key chant writes (`op`, `gate`, `planDigest`, `timestamp`,
97
+ * ...) is refused, except `description`.
98
+ */
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[];
84
106
  }
85
107
 
108
+ /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
109
+ export const OP_WAVE_PENDING_RESERVED = [
110
+ "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
111
+ "environment", "origin", "neverOverMcp", "approval", "answer",
112
+ ] as const;
113
+
86
114
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
87
115
  export interface OpWaveRunsFile {
88
116
  /** The wave's name. */
@@ -100,6 +128,10 @@ export interface OpWaveMember extends WaveMember {
100
128
  op: string;
101
129
  destructive?: boolean;
102
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 };
103
135
  }
104
136
 
105
137
  /** Where a wave's policy came from. */
@@ -120,8 +152,12 @@ export interface OpWaveDecision {
120
152
  base: string;
121
153
  digest: string;
122
154
  members: OpWaveMember[];
123
- /** `approved`: an approval of this digest stands. `not-required`: the policy lets it through. `waiting`: neither. */
124
- 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";
125
161
  approvedBy?: string;
126
162
  /** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
127
163
  via?: "ledger" | "pr-review";
@@ -148,6 +184,58 @@ export interface OpWaveDecision {
148
184
  applyHooksRunBy?: "apply";
149
185
  /** Every hook's outcome so far in the job that wrote the record (#3804). */
150
186
  outcomes?: OpWaveHookOutcome[];
187
+ /** `changing`: the digest covers only the runs whose plan is not empty, as the spec's `digest.over` says (#3821). Absent: every run. */
188
+ digestOver?: "changing";
189
+ /** The fields the plans file added to the pending fact (#3821). */
190
+ pending?: Record<string, unknown>;
191
+ /** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
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;
151
239
  }
152
240
 
153
241
  /** One hook's run (#3762). */
@@ -216,6 +304,18 @@ export interface OpWaveHooksResult {
216
304
  outcomes: OpWaveHookOutcome[];
217
305
  }
218
306
 
307
+ /** The options of {@link runOpWaveApplyHooks} (#3825). */
308
+ export interface RunOpWaveApplyHooksOptions {
309
+ /** Where the hooks run. Default `process.cwd()`. */
310
+ cwd?: string;
311
+ /** Runs one hook. Default: spawn it with the job's environment. */
312
+ exec?: OpWaveExec;
313
+ /** Default: stderr. */
314
+ log?: (line: string) => void;
315
+ /** Run nothing and record the moment as skipped, with this reason (#3811). */
316
+ skip?: string;
317
+ }
318
+
219
319
  /** One call of {@link runOpWaveApplyHooks}. */
220
320
  export interface OpWaveApplyHooksRun {
221
321
  moment: OpWaveHookMoment;
@@ -293,6 +393,8 @@ export interface OpWaveBaseRules {
293
393
  spend: boolean;
294
394
  /** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
295
395
  applyHooksRunBy: OpWaveApplyHooksRunner;
396
+ /** The spec at base sets `neverOverMcp` (#3821). */
397
+ neverOverMcp: boolean;
296
398
  }
297
399
 
298
400
  const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
@@ -313,7 +415,7 @@ export function readOpWavePolicy(
313
415
  show: OpWaveShowAtBase = gitShowAtBase,
314
416
  ): OpWaveBaseRules {
315
417
  const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
316
- const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
418
+ const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const, neverOverMcp: false };
317
419
  if (text === null) return absent;
318
420
  let spec: OpWavesSpec;
319
421
  try {
@@ -323,12 +425,13 @@ export function readOpWavePolicy(
323
425
  }
324
426
  // The spend rule is the spec's, so it holds for a wave the base does not have yet.
325
427
  const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
428
+ const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
326
429
  const wave = spec.waves.find((w) => w.name === name);
327
- if (!wave) return { ...absent, spend };
430
+ if (!wave) return { ...absent, spend, neverOverMcp };
328
431
  const policy = wave.gate ?? "always";
329
432
  // An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
330
433
  const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
331
- const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
434
+ const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
332
435
  try {
333
436
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
334
437
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
@@ -397,10 +500,12 @@ export interface RunOpWaveOptions {
397
500
  headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
398
501
  /** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
399
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;
400
505
  }
401
506
 
402
507
  export interface RunOpWaveResult {
403
- /** 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). */
404
509
  exitCode: number;
405
510
  decision?: OpWaveDecision;
406
511
  applied: string[];
@@ -420,6 +525,9 @@ interface WaveContext {
420
525
  exec: OpWaveExec;
421
526
  log: (line: string) => void;
422
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 };
423
531
  }
424
532
 
425
533
  function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpWaveExec }, waveName: string, run: OpWaveRun): OpWaveMember {
@@ -441,12 +549,20 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
441
549
  }
442
550
 
443
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
+ }
444
559
  return {
445
560
  member: target,
446
561
  planDigest: file.planDigest,
447
562
  op,
448
563
  ...(file.destructive === true ? { destructive: true } : {}),
449
564
  ...(file.empty === true ? { empty: true } : {}),
565
+ ...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
450
566
  };
451
567
  }
452
568
 
@@ -481,9 +597,9 @@ function writeRunsFile(
481
597
  * for all of them (#3762). A wave plan is written out as each run's plan file
482
598
  * too, so a per-run `apply` or hook reads `{plan}` either way.
483
599
  */
484
- function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): OpWaveMember[] {
600
+ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
485
601
  const { spec, k, wave, cwd, exec } = at;
486
- if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
602
+ if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
487
603
  const runsPath = writeRunsFile(at, runs);
488
604
  const plans = opWavePlansPath(spec, k);
489
605
  rmSync(join(cwd, plans), { force: true });
@@ -510,12 +626,46 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
510
626
  }
511
627
  const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
512
628
  if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
513
- return runs.map((run) => {
629
+ const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
630
+ const members = runs.map((run) => {
514
631
  const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
515
632
  const path = join(cwd, opWavePlanPath(spec, k, run.target));
516
- 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");
517
634
  return m;
518
635
  });
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[] } : {}) };
641
+ }
642
+
643
+ /** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
644
+ interface PlannedRuns {
645
+ members: OpWaveMember[];
646
+ pending?: Record<string, unknown>;
647
+ /** The plans file's overriders (#3821). */
648
+ overriders?: string[];
649
+ }
650
+
651
+ /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
652
+ function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
653
+ if (value === undefined) return undefined;
654
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
655
+ const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
656
+ if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
657
+ if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
658
+ return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
659
+ }
660
+
661
+ /**
662
+ * A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
663
+ * or under `digest.over: "changing"` over the runs whose plan is not empty,
664
+ * falling back to every run when all of them are empty.
665
+ */
666
+ export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>): string {
667
+ const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
668
+ return waveSetDigest(changing.length > 0 ? changing : members);
519
669
  }
520
670
 
521
671
  /** What one moment's hooks came to. */
@@ -524,6 +674,32 @@ interface HooksOutcome {
524
674
  failed?: string;
525
675
  /** The `onFailure: approve` hooks that failed. */
526
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
+ }
527
703
  }
528
704
 
529
705
  /** Where hooks run: the wave they belong to and the job's tools. */
@@ -549,11 +725,14 @@ function runHookList(
549
725
  hooks: readonly OpWaveHook[] | undefined,
550
726
  runs: readonly OpWaveHookRun[],
551
727
  runsPath: string,
552
- ): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
553
- 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: [] };
554
731
  if (!hooks || hooks.length === 0) return out;
555
732
  const { name: specName, op, waveName, k, cwd, exec, log } = at;
556
- 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 };
557
736
  for (const hook of hooks) {
558
737
  const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
559
738
  const calls = hook.perRun
@@ -566,6 +745,7 @@ function runHookList(
566
745
  for (const call of calls) {
567
746
  const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
568
747
  let code: number;
748
+ rmSync(join(cwd, holdPath), { force: true });
569
749
  try {
570
750
  code = exec(call.argv, cwd, call.env);
571
751
  } catch (err) {
@@ -579,6 +759,12 @@ function runHookList(
579
759
  }
580
760
  if (hook.onFailure === "approve") {
581
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
+ }
582
768
  out.holds.push(label);
583
769
  log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
584
770
  continue;
@@ -597,20 +783,105 @@ const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx
597
783
  const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
598
784
  runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
599
785
 
600
- /** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
601
- 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(
602
793
  ctx: WaveContext,
603
794
  moment: OpWaveHookMoment,
604
795
  hooks: readonly OpWaveHook[] | undefined,
605
796
  runs: readonly OpWaveRun[],
606
797
  members?: ReadonlyMap<string, OpWaveMember>,
607
798
  digest?: string,
608
- ): HooksOutcome {
799
+ extraEnv: Record<string, string> = {},
800
+ ): Promise<HooksOutcome> {
609
801
  if (!hooks || hooks.length === 0) return { holds: [] };
610
802
  const runsPath = writeRunsFile(ctx, runs, members, digest);
611
- 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);
612
804
  ctx.result.hooks.push(...out.outcomes);
613
- 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
+ };
614
885
  }
615
886
 
616
887
  /**
@@ -647,11 +918,7 @@ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT
647
918
  * chant counts the moment as handled. A skip without a reason throws. `exec` runs one hook; inject it to run a hook
648
919
  * in process or to keep its output.
649
920
  */
650
- export function runOpWaveApplyHooks(
651
- file: OpWaveHooksFile,
652
- moment: OpWaveHookMoment,
653
- options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void; skip?: string } = {},
654
- ): OpWaveApplyHooksRun {
921
+ export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMoment, options: RunOpWaveApplyHooksOptions = {}): OpWaveApplyHooksRun {
655
922
  const cwd = options.cwd ?? process.cwd();
656
923
  const log = options.log ?? ((line: string) => console.error(line));
657
924
  let run: OpWaveApplyHooksRun;
@@ -666,12 +933,14 @@ export function runOpWaveApplyHooks(
666
933
  } else {
667
934
  const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
668
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}`);
669
938
  run = {
670
939
  moment,
671
940
  outcomes: out.outcomes,
672
941
  ...(out.failed ? { failed: out.failed } : {}),
673
- held: out.holds,
674
- 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),
675
944
  };
676
945
  }
677
946
  if (file.result) {
@@ -768,7 +1037,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
768
1037
  }
769
1038
  const recordPath = join(cwd, opWaveRecordPath(spec, k));
770
1039
  const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
771
- 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 } };
772
1041
 
773
1042
  if (options.share !== undefined) {
774
1043
  let decision: OpWaveDecision;
@@ -778,18 +1047,21 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
778
1047
  throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
779
1048
  }
780
1049
  result.decision = decision;
781
- if (decision.status === "waiting") {
782
- log(`wave ${k} (${wave.name}) is waiting on ${decision.gate}; nothing applied. ${decision.approve ?? ""}`.trim());
783
- 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 };
784
1054
  }
785
1055
  // The share runs the hooks its deciding job read at base, around its own slice.
786
1056
  const slice = opWaveShare(wave.runs, options.share, shares);
787
- const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
1057
+ const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
788
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`);
789
- const members = planRuns(ctx, slice);
1059
+ if (before.waitingOn) return waitOnOther(ctx, decision, before.waitingOn);
1060
+ const { members } = planRuns(ctx, slice);
790
1061
  const planned = new Map(members.map((m) => [m.member, m]));
791
- 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);
792
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);
793
1065
  const decided = new Map(decision.members.map((m) => [m.member, m.planDigest]));
794
1066
  const unmoved: OpWaveRun[] = [];
795
1067
  for (const run of slice) {
@@ -812,21 +1084,15 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
812
1084
  // The policy, the approval mode and the hooks, read at base before anything runs.
813
1085
  const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
814
1086
  const { policy, approval, source, base } = rules;
1087
+ const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
1088
+ ctx.gateRule = { approval, base, neverOverMcp };
815
1089
  noteHooksAtBase(ctx, rules);
816
1090
  const holds: string[] = [];
817
1091
  if (rules.hooksRefused) {
818
1092
  holds.push(rules.hooksRefused);
819
1093
  log(`wave ${k} (${wave.name}): ${rules.hooksRefused}, so none run and the wave waits for an approval`);
820
1094
  }
821
- const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
822
- if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
823
- const members = planRuns(ctx, wave.runs);
824
- const planned = new Map(members.map((m) => [m.member, m]));
825
- const digest = waveSetDigest(members);
826
- const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
827
- if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
828
- holds.push(...before.holds, ...after.holds);
829
- const gate = waveGateName(spec.gate ?? spec.name, k);
1095
+ const gate = opWaveGateName(spec, k);
830
1096
  const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
831
1097
  const decision: OpWaveDecision = {
832
1098
  wave: k,
@@ -837,38 +1103,229 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
837
1103
  approval,
838
1104
  policySource: source,
839
1105
  base,
840
- digest,
841
- members,
1106
+ digest: "",
1107
+ members: [],
842
1108
  status: "not-required",
843
1109
  ...(Object.keys(hooks).length > 0 ? { hooks } : {}),
844
- ...(holds.length > 0 ? { heldBy: holds } : {}),
845
1110
  ...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
846
1111
  ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
1112
+ ...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
1113
+ ...(neverOverMcp ? { neverOverMcp: true as const } : {}),
847
1114
  };
848
- if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
849
- // A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
850
- if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
851
- mkdirSync(dirname(recordPath), { recursive: true });
852
- writeRecord(recordPath, decision, result);
853
- 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;
854
1134
  log(
855
1135
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
856
1136
  `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
857
1137
  (decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : "") +
858
1138
  (holds.length > 0 ? `; held by ${holds.join(", ")}` : ""),
859
1139
  );
860
- 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) {
861
1165
  log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
862
1166
  }
1167
+ const hook = await runGateHooks(ctx, decision, recordPath);
863
1168
  if (decision.status === "waiting") {
864
- 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)`);
865
1170
  return { ...result, exitCode: 3 };
866
1171
  }
867
- if (options.decide) return result;
868
- 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`);
869
1177
  return result;
870
1178
  }
871
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
+
872
1329
  function failed(ctx: WaveContext, line: string): RunOpWaveResult {
873
1330
  ctx.log(line);
874
1331
  ctx.result.exitCode = 1;
@@ -883,20 +1340,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
883
1340
  async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
884
1341
  const { spec, k, wave, cwd } = ctx;
885
1342
  const { gate, digest, approval, base, policy, members } = decision;
886
- const ledger = options.gates ?? gitGateLedgerPort({ cwd });
887
- let port = ledger;
888
- if (approval === "sealed") {
889
- // #3684: only an approval sealed by a signer listed at base counts.
890
- const review = await import("./gate-review");
891
- const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
892
- port = {
893
- ...ledger,
894
- async approvalRule(g) {
895
- const own = (await ledger.approvalRule?.(g)) ?? null;
896
- return g === gate ? review.bothRules(own, sealed) : own;
897
- },
898
- };
899
- }
1343
+ const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
900
1344
  const now = options.now ?? new Date().toISOString();
901
1345
  // #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
902
1346
  const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
@@ -905,9 +1349,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
905
1349
  op: spec.name,
906
1350
  gate,
907
1351
  planDigest: digest,
908
- description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
1352
+ description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
909
1353
  now,
910
1354
  ...(spent ? { spent } : {}),
1355
+ ...(decision.pending ? { fields: decision.pending } : {}),
1356
+ ...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
911
1357
  });
912
1358
  if (check.satisfied) {
913
1359
  decision.status = "approved";
@@ -916,6 +1362,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
916
1362
  decision.approvedAt = check.resolution.timestamp;
917
1363
  return;
918
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;
919
1370
  const used = spent?.filter((a) => isApprovalSpent([a], { gate, planDigest: digest, timestamp: a.approvedAt })).pop();
920
1371
  if (used) {
921
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`);
@@ -924,6 +1375,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
924
1375
  const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger, ...(spent ? { spent } : {}) });
925
1376
  decision.review = byReview.review;
926
1377
  if (byReview.approved) {
1378
+ for (const key of ["pendingFact", "recorded", "waitingSince", "mismatch"] as const) delete decision[key];
927
1379
  decision.status = "approved";
928
1380
  decision.via = "pr-review";
929
1381
  decision.approvedBy = byReview.review.approvers!.join(", ");
@@ -961,6 +1413,8 @@ async function settleHolds(
961
1413
  await settleGate(options, ctx, decision);
962
1414
  if (recordPath) writeRecord(recordPath, decision, result);
963
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);
964
1418
  log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
965
1419
  result.exitCode = 3;
966
1420
  return false;
@@ -1082,16 +1536,27 @@ async function applyStage(
1082
1536
  const byApply = decision.applyHooksRunBy === "apply";
1083
1537
  const holds = [...earlierHolds];
1084
1538
  if (!byApply) {
1085
- 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);
1086
1540
  if (before.failed) {
1087
1541
  failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
1088
1542
  return;
1089
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
+ }
1090
1554
  holds.push(...before.holds);
1091
1555
  }
1092
1556
  if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
1093
1557
  if (!(await spendApproval(options, ctx, decision))) return;
1094
- 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);
1095
1560
  const problems: string[] = [];
1096
1561
  let waiting = false;
1097
1562
  if (spec.applyWave) {
@@ -1141,7 +1606,7 @@ async function applyStage(
1141
1606
  return;
1142
1607
  }
1143
1608
  if (!byApply) {
1144
- 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);
1145
1610
  if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
1146
1611
  }
1147
1612
  if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
@@ -1279,8 +1744,8 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
1279
1744
  head,
1280
1745
  timestamp: options.now ?? new Date().toISOString(),
1281
1746
  waves: spec.waves.map((wave, i) => {
1282
- const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
1283
- return { wave: i + 1, name: wave.name, digest: waveSetDigest(members) };
1747
+ const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
1748
+ return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
1284
1749
  }),
1285
1750
  };
1286
1751
  await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);