@intentius/chant 0.123.0 → 0.124.1

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 (62) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  2. package/dist/config-import.d.ts +1 -1
  3. package/dist/lint/policy-import.d.ts +1 -1
  4. package/dist/notify-verify.js +76 -0
  5. package/dist/notify.js +307 -0
  6. package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
  7. package/dist/op/index.d.ts +4 -4
  8. package/dist/op/index.d.ts.map +1 -1
  9. package/dist/op/op-waves-run.d.ts +134 -2
  10. package/dist/op/op-waves-run.d.ts.map +1 -1
  11. package/dist/op/op-waves.d.ts +14 -0
  12. package/dist/op/op-waves.d.ts.map +1 -1
  13. package/dist/pr-forge.d.ts +30 -3
  14. package/dist/pr-forge.d.ts.map +1 -1
  15. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
  16. package/dist/workspace/conformance/broker.d.ts +1 -1
  17. package/package.json +14 -2
  18. package/src/cli/commands/check-lexicon.ts +3 -1
  19. package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
  20. package/src/cli/commands/lint.test.ts +4 -109
  21. package/src/cli/discovery-skip.e2e.test.ts +18 -3
  22. package/src/config-import.ts +1 -1
  23. package/src/discovery/sandbox/run.test.ts +1 -1
  24. package/src/lint/policy-import.ts +1 -1
  25. package/src/notify-plain-node.e2e.test.ts +68 -0
  26. package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
  27. package/src/op/index.ts +18 -2
  28. package/src/op/op-waves-run.ts +454 -83
  29. package/src/op/op-waves.test.ts +191 -1
  30. package/src/op/op-waves.ts +29 -1
  31. package/src/pr-forge.test.ts +76 -1
  32. package/src/pr-forge.ts +52 -8
  33. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
  34. package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
  35. package/src/workspace/conformance/broker.ts +1 -1
  36. package/src/workspace/records-write.e2e.test.ts +1 -1
  37. /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
  38. /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
  39. /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
  40. /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
  41. /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
  42. /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
  43. /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
  44. /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
  45. /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
  46. /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
  47. /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
  48. /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
  49. /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
  50. /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
  51. /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
  52. /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
  53. /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
  54. /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
  55. /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
  56. /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
  57. /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
  58. /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
  59. /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
  60. /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
  61. /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
  62. /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
@@ -29,11 +29,19 @@
29
29
  * approved set (#3762). A share job (`--share i`) re-plans its
30
30
  * slice first and refuses, without applying it, a run whose plan digest is
31
31
  * not the one the deciding job recorded (exit 4).
32
+ * - Hooks file (#3804): every apply call gets `CHANT_HOOKS`, a JSON file
33
+ * ({@link OpWaveHooksFile}) with the outcomes of the hooks that already ran
34
+ * and whether an approval stands. Under `hooks.apply.runBy: "apply"` (read
35
+ * at base) chant does not run the apply-stage hooks itself: the file hands
36
+ * them to the apply, which runs them under its own lock with
37
+ * {@link runOpWaveApplyHooks}, and their outcomes come back through
38
+ * `CHANT_HOOKS_RESULT`. A non-wave apply reads the same hooks at base with
39
+ * {@link opWaveHooksAtBase}.
32
40
  */
33
41
 
34
42
  import { execFileSync, spawnSync } from "node:child_process";
35
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
36
- import { dirname, join, posix } from "node:path";
43
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
+ import { dirname, isAbsolute, join, posix } from "node:path";
37
45
  import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
38
46
  import type { ReviewApproval } from "../gate-decision";
39
47
  import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
@@ -47,11 +55,13 @@ import {
47
55
  GATE_APPROVAL_SOURCES,
48
56
  assertOpWaveHooks,
49
57
  assertOpWavesSpec,
58
+ opWaveApplyHooksRunBy,
50
59
  opWaveHooksAt,
51
60
  opWaveRecordPath,
52
61
  opWaveShare,
53
62
  type GateApprovalSource,
54
63
  type OpWave,
64
+ type OpWaveApplyHooksRunner,
55
65
  type OpWaveGatePolicy,
56
66
  type OpWaveHook,
57
67
  type OpWaveHookMoment,
@@ -134,6 +144,10 @@ export interface OpWaveDecision {
134
144
  approvedAt?: string;
135
145
  /** When the wave recorded that it applies under the approval, which spent it. */
136
146
  spentAt?: string;
147
+ /** `apply`: the apply command runs the apply-stage hooks under its own lock (#3804), as read at base. Absent: chant runs them. */
148
+ applyHooksRunBy?: "apply";
149
+ /** Every hook's outcome so far in the job that wrote the record (#3804). */
150
+ outcomes?: OpWaveHookOutcome[];
137
151
  }
138
152
 
139
153
  /** One hook's run (#3762). */
@@ -143,7 +157,80 @@ export interface OpWaveHookOutcome {
143
157
  /** The run, for a `perRun` hook. */
144
158
  target?: string;
145
159
  exit: number;
146
- outcome: "passed" | "failed" | "held";
160
+ outcome: "passed" | "failed" | "held" | "skipped";
161
+ /** Why the moment was skipped, for `skipped` (#3811). */
162
+ reason?: string;
163
+ }
164
+
165
+ /** One run as a hook sees it (#3804). */
166
+ export interface OpWaveHookRun {
167
+ target: string;
168
+ op: string;
169
+ /** The run's plan file, once planned. */
170
+ plan?: string;
171
+ }
172
+
173
+ /**
174
+ * What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
175
+ * {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
176
+ * relative to the job's working directory.
177
+ */
178
+ export interface OpWaveHooksFile {
179
+ version: 1;
180
+ /** The Op waves spec's name. */
181
+ opWaves: string;
182
+ /** The spec's `op`: a hook's `{op}` when it runs once for the wave. */
183
+ op: string;
184
+ wave: string;
185
+ /** 1-based; 0 when the working tree's spec has no such wave. */
186
+ number: number;
187
+ /** The commit the hooks were read at. */
188
+ base?: string;
189
+ /** The wave's set digest, once decided. */
190
+ digest?: string;
191
+ /** An approval of the digest stands, so a held hook does not stop the apply. */
192
+ approved: boolean;
193
+ /** The labels of the hooks that held the wave, approved or not. */
194
+ heldBy?: string[];
195
+ /** Who runs the apply-stage hooks. With `chant`, `hooks` is empty: chant runs them around the apply. */
196
+ runBy: OpWaveApplyHooksRunner;
197
+ /** The runs this apply call applies. */
198
+ runs: OpWaveHookRun[];
199
+ /** The runs file, a hook's `{runs}` and `CHANT_RUNS`. */
200
+ runsFile: string;
201
+ /** The hooks the apply runs, by moment, as read at base. */
202
+ hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
203
+ /** The hooks that already ran for this wave in this job, and how each came out. */
204
+ outcomes: OpWaveHookOutcome[];
205
+ /** Where {@link runOpWaveApplyHooks} writes its outcomes (`CHANT_HOOKS_RESULT`), which chant reads back. */
206
+ result?: string;
207
+ /** Set when the hooks at base could not be read: none run, and the runner refuses to proceed. */
208
+ refused?: string;
209
+ }
210
+
211
+ /** What {@link runOpWaveApplyHooks} writes to `CHANT_HOOKS_RESULT` (#3804). */
212
+ export interface OpWaveHooksResult {
213
+ version: 1;
214
+ /** The moments the apply ran, empty ones included. */
215
+ moments: OpWaveHookMoment[];
216
+ outcomes: OpWaveHookOutcome[];
217
+ }
218
+
219
+ /** One call of {@link runOpWaveApplyHooks}. */
220
+ export interface OpWaveApplyHooksRun {
221
+ moment: OpWaveHookMoment;
222
+ outcomes: OpWaveHookOutcome[];
223
+ /** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
224
+ failed?: string;
225
+ /** The `onFailure: approve` hooks that failed, or why the hooks could not be read at base. */
226
+ held: string[];
227
+ /**
228
+ * Whether the apply may go on: no hook failed, and nothing held it or an
229
+ * approval of the digest stands. When false after a hold, the apply applies
230
+ * nothing and exits non-zero; chant then waits for, or finds, an approval
231
+ * and calls it again.
232
+ */
233
+ proceed: boolean;
147
234
  }
148
235
 
149
236
  /** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
@@ -204,6 +291,8 @@ export interface OpWaveBaseRules {
204
291
  hooksRefused?: string;
205
292
  /** The spec at base sets `approvals.spend` (#3784). */
206
293
  spend: boolean;
294
+ /** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
295
+ applyHooksRunBy: OpWaveApplyHooksRunner;
207
296
  }
208
297
 
209
298
  const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
@@ -224,7 +313,7 @@ export function readOpWavePolicy(
224
313
  show: OpWaveShowAtBase = gitShowAtBase,
225
314
  ): OpWaveBaseRules {
226
315
  const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
227
- const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false };
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 };
228
317
  if (text === null) return absent;
229
318
  let spec: OpWavesSpec;
230
319
  try {
@@ -239,11 +328,12 @@ export function readOpWavePolicy(
239
328
  const policy = wave.gate ?? "always";
240
329
  // An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
241
330
  const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
242
- const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend };
331
+ const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
243
332
  try {
244
333
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
245
334
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
246
335
  rules.hooks = opWaveHooksAt(spec, name);
336
+ rules.applyHooksRunBy = opWaveApplyHooksRunBy(spec, name);
247
337
  } catch (err) {
248
338
  // A hook at base no runner can run: none run, and the wave waits for a person rather than going out without them.
249
339
  rules.hooksRefused = `the hooks at base cannot be run (${err instanceof Error ? err.message : String(err)})`;
@@ -366,8 +456,8 @@ function writeRunsFile(
366
456
  runs: readonly OpWaveRun[],
367
457
  members?: ReadonlyMap<string, OpWaveMember>,
368
458
  digest?: string,
459
+ path: string = opWaveRunsPath(at.spec, at.k),
369
460
  ): string {
370
- const path = opWaveRunsPath(at.spec, at.k);
371
461
  const file: OpWaveRunsFile = {
372
462
  wave: at.wave.name,
373
463
  number: at.k,
@@ -436,36 +526,43 @@ interface HooksOutcome {
436
526
  holds: string[];
437
527
  }
438
528
 
529
+ /** Where hooks run: the wave they belong to and the job's tools. */
530
+ interface HookPlace {
531
+ /** The spec's name and `op`. */
532
+ name: string;
533
+ op: string;
534
+ waveName: string;
535
+ k: number;
536
+ cwd: string;
537
+ exec: OpWaveExec;
538
+ log: (line: string) => void;
539
+ }
540
+
439
541
  /**
440
- * Run one moment's hooks (#3762), in order: each once for the wave, or once
441
- * per run for a `perRun` hook. A hook that fails stops the rest, unless its
542
+ * Run one moment's hooks, in order: each once for the wave, or once per run
543
+ * for a `perRun` hook. A hook that fails stops the rest, unless its
442
544
  * `onFailure` is `approve`: then it holds the wave and the hooks after it run.
443
545
  */
444
- function runHooks(
445
- ctx: WaveContext,
546
+ function runHookList(
547
+ at: HookPlace,
446
548
  moment: OpWaveHookMoment,
447
549
  hooks: readonly OpWaveHook[] | undefined,
448
- runs: readonly OpWaveRun[],
449
- members?: ReadonlyMap<string, OpWaveMember>,
450
- digest?: string,
451
- ): HooksOutcome {
452
- const out: HooksOutcome = { holds: [] };
550
+ runs: readonly OpWaveHookRun[],
551
+ runsPath: string,
552
+ ): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
553
+ const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
453
554
  if (!hooks || hooks.length === 0) return out;
454
- const { spec, k, wave, cwd, exec, log } = ctx;
455
- const runsPath = writeRunsFile(ctx, runs, members, digest);
456
- const env = { CHANT_OP_WAVES: spec.name, CHANT_WAVE: wave.name, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
555
+ 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 };
457
557
  for (const hook of hooks) {
458
558
  const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
459
559
  const calls = hook.perRun
460
- ? runs.map((run) => {
461
- const plan = members?.has(run.target) ? opWavePlanPath(spec, k, run.target) : undefined;
462
- return {
463
- target: run.target as string | undefined,
464
- argv: opWaveArgv(hook.command, { op: run.op ?? spec.op, wave: wave.name, target: run.target, runs: runsPath, ...(plan ? { plan } : {}) }),
465
- env: { ...env, CHANT_TARGET: run.target, ...(plan ? { CHANT_PLAN: plan } : {}) } as Record<string, string>,
466
- };
467
- })
468
- : [{ target: undefined, argv: opWaveArgv(hook.command, { op: spec.op, wave: wave.name, runs: runsPath }), env: env as Record<string, string> }];
560
+ ? runs.map((run) => ({
561
+ target: run.target as string | undefined,
562
+ argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
563
+ env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
564
+ }))
565
+ : [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
469
566
  for (const call of calls) {
470
567
  const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
471
568
  let code: number;
@@ -477,16 +574,16 @@ function runHooks(
477
574
  }
478
575
  const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
479
576
  if (code === 0) {
480
- ctx.result.hooks.push({ ...base, outcome: "passed" });
577
+ out.outcomes.push({ ...base, outcome: "passed" });
481
578
  continue;
482
579
  }
483
580
  if (hook.onFailure === "approve") {
484
- ctx.result.hooks.push({ ...base, outcome: "held" });
581
+ out.outcomes.push({ ...base, outcome: "held" });
485
582
  out.holds.push(label);
486
- log(`${label} exited ${code}, so wave ${k} (${wave.name}) waits for an approval of its set digest`);
583
+ log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
487
584
  continue;
488
585
  }
489
- ctx.result.hooks.push({ ...base, outcome: "failed" });
586
+ out.outcomes.push({ ...base, outcome: "failed" });
490
587
  out.failed = `${label} exited ${code}`;
491
588
  log(out.failed);
492
589
  return out;
@@ -495,6 +592,150 @@ function runHooks(
495
592
  return out;
496
593
  }
497
594
 
595
+ const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx.spec.op, waveName: ctx.wave.name, k: ctx.k, cwd: ctx.cwd, exec: ctx.exec, log: ctx.log });
596
+
597
+ const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
598
+ runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
599
+
600
+ /** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
601
+ function runHooks(
602
+ ctx: WaveContext,
603
+ moment: OpWaveHookMoment,
604
+ hooks: readonly OpWaveHook[] | undefined,
605
+ runs: readonly OpWaveRun[],
606
+ members?: ReadonlyMap<string, OpWaveMember>,
607
+ digest?: string,
608
+ ): HooksOutcome {
609
+ if (!hooks || hooks.length === 0) return { holds: [] };
610
+ const runsPath = writeRunsFile(ctx, runs, members, digest);
611
+ const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
612
+ ctx.result.hooks.push(...out.outcomes);
613
+ return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
614
+ }
615
+
616
+ /**
617
+ * Read the hooks file chant hands an apply command (#3804): the one at
618
+ * `path`, by default `CHANT_HOOKS`. Null when there is none, as for an apply
619
+ * that a wave job did not start.
620
+ */
621
+ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT_HOOKS, cwd: string = process.cwd()): OpWaveHooksFile | null {
622
+ if (!path) return null;
623
+ const at = isAbsolute(path) ? path : join(cwd, path);
624
+ let parsed: OpWaveHooksFile;
625
+ try {
626
+ parsed = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksFile;
627
+ } catch (err) {
628
+ throw new Error(`the hooks file ${path} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
629
+ }
630
+ if (parsed?.version !== 1 || !Array.isArray(parsed.runs) || typeof parsed.hooks !== "object") throw new Error(`${path} is not an Op waves hooks file (version 1)`);
631
+ return parsed;
632
+ }
633
+
634
+ /**
635
+ * Run one moment's hooks from a hooks file (#3804): what an apply command
636
+ * calls under its own lock, `before-apply` right before it changes anything
637
+ * and `after-apply` once it has, when the spec's `hooks.apply.runBy` at base
638
+ * is `apply`. Also what a product's apply outside the waves calls, with a
639
+ * file from {@link opWaveHooksAtBase}. A moment the file has no hooks for
640
+ * runs nothing and proceeds, so an apply can call it whoever runs the hooks.
641
+ *
642
+ * Each call adds its outcomes to the file's `result` (`CHANT_HOOKS_RESULT`),
643
+ * which chant reads back after the apply exits: the outcomes join the wave's
644
+ * result and decision record, and a hold makes the wave wait for, or find,
645
+ * an approval of its digest. `skip: "<reason>"` runs nothing and records the
646
+ * moment as skipped with the reason (#3811), for an apply that changes nothing:
647
+ * chant counts the moment as handled. A skip without a reason throws. `exec` runs one hook; inject it to run a hook
648
+ * in process or to keep its output.
649
+ */
650
+ export function runOpWaveApplyHooks(
651
+ file: OpWaveHooksFile,
652
+ moment: OpWaveHookMoment,
653
+ options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void; skip?: string } = {},
654
+ ): OpWaveApplyHooksRun {
655
+ const cwd = options.cwd ?? process.cwd();
656
+ const log = options.log ?? ((line: string) => console.error(line));
657
+ let run: OpWaveApplyHooksRun;
658
+ if (options.skip !== undefined) {
659
+ const reason = options.skip.trim();
660
+ if (!reason) throw new Error(`skipping the ${moment} hooks needs a reason (skip: "nothing to apply")`);
661
+ log(`${moment} hooks skipped: ${reason}`);
662
+ run = { moment, outcomes: [{ moment, name: "skip", exit: 0, outcome: "skipped", reason }], held: [], proceed: true };
663
+ } else if (file.refused) {
664
+ log(`${file.refused}, so none of the ${moment} hooks run and the apply does not go on`);
665
+ run = { moment, outcomes: [], held: [file.refused], proceed: false };
666
+ } else {
667
+ const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
668
+ const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
669
+ run = {
670
+ moment,
671
+ outcomes: out.outcomes,
672
+ ...(out.failed ? { failed: out.failed } : {}),
673
+ held: out.holds,
674
+ proceed: !out.failed && (out.holds.length === 0 || file.approved),
675
+ };
676
+ }
677
+ if (file.result) {
678
+ const at = isAbsolute(file.result) ? file.result : join(cwd, file.result);
679
+ let prior: OpWaveHooksResult = { version: 1, moments: [], outcomes: [] };
680
+ if (existsSync(at)) {
681
+ try {
682
+ prior = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksResult;
683
+ } catch {
684
+ // A result file that cannot be read is started again.
685
+ }
686
+ }
687
+ const next: OpWaveHooksResult = { version: 1, moments: [...prior.moments, moment], outcomes: [...prior.outcomes, ...run.outcomes] };
688
+ mkdirSync(dirname(at), { recursive: true });
689
+ writeFileSync(at, JSON.stringify(next, null, 2) + "\n");
690
+ }
691
+ return run;
692
+ }
693
+
694
+ /**
695
+ * The hooks of wave `wave` read at base, for an apply outside the waves
696
+ * (#3804): a product's own `apply <env>` that runs the same Op as the wave
697
+ * does, and the same hooks. All four moments are in the file; the apply runs
698
+ * the ones it has a stage for with {@link runOpWaveApplyHooks}. Nothing is
699
+ * approved: a held hook stops the apply. The wave or the file missing at
700
+ * base has no hooks, as in a wave job.
701
+ */
702
+ export function opWaveHooksAtBase(options: {
703
+ specFile: string;
704
+ wave: string;
705
+ /** The runs this apply applies. Default: the wave's runs in the working tree's spec. */
706
+ runs?: ReadonlyArray<{ target: string; op?: string }>;
707
+ /** Default: the spec's `base`, else `HEAD^1`. */
708
+ base?: string;
709
+ cwd?: string;
710
+ show?: OpWaveShowAtBase;
711
+ }): OpWaveHooksFile {
712
+ const cwd = options.cwd ?? process.cwd();
713
+ const spec = parseOpWavesSpec(readFileSync(isAbsolute(options.specFile) ? options.specFile : join(cwd, options.specFile), "utf-8"), options.specFile);
714
+ const k = spec.waves.findIndex((w) => w.name === options.wave) + 1;
715
+ const rules = readOpWavePolicy(options.specFile, options.wave, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
716
+ const runs = (options.runs ?? spec.waves[k - 1]?.runs ?? []).map((r) => ({ target: r.target, op: r.op ?? spec.op }));
717
+ const runsFile = `.chant/op-waves/${spec.name}/${options.wave.replace(/[^A-Za-z0-9._-]/g, "_")}.runs.json`;
718
+ const runsBody: OpWaveRunsFile = { wave: options.wave, number: k, runs };
719
+ mkdirSync(dirname(join(cwd, runsFile)), { recursive: true });
720
+ writeFileSync(join(cwd, runsFile), JSON.stringify(runsBody, null, 2) + "\n");
721
+ const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
722
+ return {
723
+ version: 1,
724
+ opWaves: spec.name,
725
+ op: spec.op,
726
+ wave: options.wave,
727
+ number: k,
728
+ base: rules.base,
729
+ approved: false,
730
+ runBy: "apply",
731
+ runs,
732
+ runsFile,
733
+ hooks,
734
+ outcomes: [],
735
+ ...(rules.hooksRefused ? { refused: rules.hooksRefused } : {}),
736
+ };
737
+ }
738
+
498
739
  /** Whether `policy` lets a wave with these members through without an approval. */
499
740
  export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
500
741
  if (policy === "never") return false;
@@ -504,9 +745,10 @@ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly
504
745
  }
505
746
 
506
747
  /** Log when the change's hooks are not the ones at base, which are the ones that run. */
507
- function noteHooksAtBase(ctx: WaveContext, atBase: Record<OpWaveHookMoment, OpWaveHook[]>, base: string): void {
748
+ function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
749
+ const { hooks: atBase, base } = rules;
508
750
  const own = opWaveHooksAt(ctx.spec, ctx.wave.name);
509
- if (JSON.stringify(own) !== JSON.stringify(atBase)) {
751
+ if (JSON.stringify(own) !== JSON.stringify(atBase) || opWaveApplyHooksRunBy(ctx.spec, ctx.wave.name) !== rules.applyHooksRunBy) {
510
752
  ctx.log(`wave ${ctx.k} (${ctx.wave.name}): its hooks are read at ${base.slice(0, 12)}, and this change's differ; the ones at base run`);
511
753
  }
512
754
  }
@@ -570,7 +812,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
570
812
  // The policy, the approval mode and the hooks, read at base before anything runs.
571
813
  const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
572
814
  const { policy, approval, source, base } = rules;
573
- noteHooksAtBase(ctx, rules.hooks, base);
815
+ noteHooksAtBase(ctx, rules);
574
816
  const holds: string[] = [];
575
817
  if (rules.hooksRefused) {
576
818
  holds.push(rules.hooksRefused);
@@ -601,12 +843,13 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
601
843
  ...(Object.keys(hooks).length > 0 ? { hooks } : {}),
602
844
  ...(holds.length > 0 ? { heldBy: holds } : {}),
603
845
  ...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
846
+ ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
604
847
  };
605
848
  if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
606
849
  // A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
607
850
  if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
608
851
  mkdirSync(dirname(recordPath), { recursive: true });
609
- writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
852
+ writeRecord(recordPath, decision, result);
610
853
  result.decision = decision;
611
854
  log(
612
855
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
@@ -695,11 +938,135 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
695
938
  decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
696
939
  }
697
940
 
941
+ /** Write the decision record, with every hook's outcome so far (#3804). */
942
+ function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
943
+ if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
944
+ writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
945
+ }
946
+
947
+ /**
948
+ * A hold waits only when nobody approved the decided digest: settle the gate
949
+ * once for `holds`. False when the wave now waits (exit 3).
950
+ */
951
+ async function settleHolds(
952
+ options: RunOpWaveOptions,
953
+ ctx: WaveContext,
954
+ decision: OpWaveDecision,
955
+ holds: readonly string[],
956
+ recordPath?: string,
957
+ ): Promise<boolean> {
958
+ const { k, wave, log, result } = ctx;
959
+ if (holds.length === 0 || decision.status === "approved") return true;
960
+ decision.heldBy = [...(decision.heldBy ?? []), ...holds];
961
+ await settleGate(options, ctx, decision);
962
+ if (recordPath) writeRecord(recordPath, decision, result);
963
+ if (decision.status === "waiting") {
964
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
965
+ result.exitCode = 3;
966
+ return false;
967
+ }
968
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
969
+ return true;
970
+ }
971
+
972
+ /** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
973
+ type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
974
+
975
+ /**
976
+ * Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
977
+ * When the apply runs the apply-stage hooks, read their outcomes back: a hold
978
+ * with no approval standing settles the gate and, once approved, calls the
979
+ * apply again; an `after-apply` failure means the runs applied and the wave
980
+ * fails; an apply that exited 0 without running its hooks fails the wave.
981
+ */
982
+ async function callApply(
983
+ options: RunOpWaveOptions,
984
+ ctx: WaveContext,
985
+ decision: OpWaveDecision,
986
+ runs: readonly OpWaveRun[],
987
+ planned: ReadonlyMap<string, OpWaveMember>,
988
+ argv: string[],
989
+ call: { label: string; key: string; runsPath: string },
990
+ recordPath?: string,
991
+ ): Promise<ApplyCall> {
992
+ const { spec, k, wave, cwd, exec, log, result } = ctx;
993
+ const byApply = decision.applyHooksRunBy === "apply";
994
+ const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
995
+ const filePath = `${dir}/${call.key}.json`;
996
+ const resultPath = `${dir}/${call.key}-result.json`;
997
+ const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
998
+ if (byApply) {
999
+ for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
1000
+ }
1001
+ for (let pass = 0; pass < 2; pass++) {
1002
+ const approved = decision.status === "approved";
1003
+ const file: OpWaveHooksFile = {
1004
+ version: 1,
1005
+ opWaves: spec.name,
1006
+ op: spec.op,
1007
+ wave: wave.name,
1008
+ number: k,
1009
+ base: decision.base,
1010
+ digest: decision.digest,
1011
+ approved,
1012
+ ...(decision.heldBy?.length ? { heldBy: decision.heldBy } : {}),
1013
+ runBy: byApply ? "apply" : "chant",
1014
+ runs: hookRuns(spec, k, runs, planned),
1015
+ runsFile: call.runsPath,
1016
+ hooks: handed,
1017
+ outcomes: [...result.hooks],
1018
+ ...(byApply ? { result: resultPath } : {}),
1019
+ };
1020
+ mkdirSync(join(cwd, dir), { recursive: true });
1021
+ writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
1022
+ rmSync(join(cwd, resultPath), { force: true });
1023
+ let code: number;
1024
+ try {
1025
+ code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
1026
+ } catch (err) {
1027
+ log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
1028
+ code = 127;
1029
+ }
1030
+ if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
1031
+ let back: OpWaveHooksResult | null = null;
1032
+ try {
1033
+ back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
1034
+ } catch {
1035
+ back = null;
1036
+ }
1037
+ const outcomes = Array.isArray(back?.outcomes) ? back!.outcomes : [];
1038
+ const moments = Array.isArray(back?.moments) ? back!.moments : [];
1039
+ result.hooks.push(...outcomes);
1040
+ const holds = outcomes.filter((o) => o.moment === "before-apply" && o.outcome === "held").map((o) => `${o.moment} hook ${o.name}${o.target ? ` (${o.target})` : ""}`);
1041
+ if (holds.length > 0 && !approved) {
1042
+ if (code === 0) {
1043
+ log(`${call.label}: the apply exited 0 although ${holds.join(", ")} held it and no approval stood; an apply applies nothing on a hold`);
1044
+ return { outcome: "failed" };
1045
+ }
1046
+ if (pass > 0 || !(await settleHolds(options, ctx, decision, holds, recordPath))) return { outcome: "waiting" };
1047
+ if (!(await spendApproval(options, ctx, decision))) return { outcome: "failed" };
1048
+ if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1049
+ continue;
1050
+ }
1051
+ const afterFailed = outcomes.find((o) => o.moment === "after-apply" && o.outcome === "failed");
1052
+ if (afterFailed) return { outcome: "applied", problem: `${call.label} applied, and then the after-apply hook ${afterFailed.name} failed` };
1053
+ if (code !== 0) return { outcome: "failed" };
1054
+ const skipped = (["before-apply", "after-apply"] as const).filter((m) => handed[m] && !moments.includes(m));
1055
+ if (skipped.length > 0) {
1056
+ return { outcome: "applied", problem: `${call.label}: the apply exited 0 without running its ${skipped.join(" and ")} hooks (runOpWaveApplyHooks with CHANT_HOOKS), which the spec at base hands it` };
1057
+ }
1058
+ return { outcome: "applied" };
1059
+ }
1060
+ return { outcome: "failed" };
1061
+ }
1062
+
698
1063
  /**
699
1064
  * Apply `runs` of a decided wave: the before-apply hooks, the apply (one call
700
1065
  * per run, or one `applyWave` call for the set), then the after-apply hooks.
701
1066
  * A hook that holds the wave here, or held a share job's plan, makes it wait
702
- * for an approval of the decided digest unless one already stands.
1067
+ * for an approval of the decided digest unless one already stands. Under
1068
+ * `hooks.apply.runBy: "apply"` the apply call runs the apply-stage hooks
1069
+ * itself, under its lock (#3804).
703
1070
  */
704
1071
  async function applyStage(
705
1072
  options: RunOpWaveOptions,
@@ -710,70 +1077,74 @@ async function applyStage(
710
1077
  earlierHolds: readonly string[],
711
1078
  recordPath?: string,
712
1079
  ): Promise<void> {
713
- const { spec, k, wave, cwd, exec, log, result } = ctx;
1080
+ const { spec, k, wave, log, result } = ctx;
714
1081
  if (runs.length === 0) return;
715
- const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
716
- if (before.failed) {
717
- failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
718
- return;
719
- }
720
- const holds = [...earlierHolds, ...before.holds];
721
- if (holds.length > 0 && decision.status !== "approved") {
722
- decision.heldBy = [...(decision.heldBy ?? []), ...holds];
723
- await settleGate(options, ctx, decision);
724
- if (recordPath) writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
725
- if (decision.status === "waiting") {
726
- log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
727
- result.exitCode = 3;
1082
+ const byApply = decision.applyHooksRunBy === "apply";
1083
+ const holds = [...earlierHolds];
1084
+ if (!byApply) {
1085
+ const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
1086
+ if (before.failed) {
1087
+ failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
728
1088
  return;
729
1089
  }
730
- log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
1090
+ holds.push(...before.holds);
731
1091
  }
1092
+ if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
732
1093
  if (!(await spendApproval(options, ctx, decision))) return;
733
- if (recordPath && decision.spentAt) writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
1094
+ if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1095
+ const problems: string[] = [];
1096
+ let waiting = false;
734
1097
  if (spec.applyWave) {
735
1098
  const runsPath = writeRunsFile(ctx, runs, planned, decision.digest);
736
- let code: number;
737
- try {
738
- code = exec(opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) }), cwd);
739
- } catch (err) {
740
- log(`applying wave ${k} (${wave.name}) did not run: ${err instanceof Error ? err.message : String(err)}`);
741
- code = 127;
742
- }
743
- if (code === 0) result.applied.push(...runs.map((r) => r.target));
1099
+ const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
1100
+ const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
1101
+ if (got.outcome === "applied") {
1102
+ result.applied.push(...runs.map((r) => r.target));
1103
+ if (got.problem) problems.push(got.problem);
1104
+ } else if (got.outcome === "waiting") waiting = true;
744
1105
  else {
745
- log(`wave ${k} (${wave.name}): apply exited ${code}, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
1106
+ log(`wave ${k} (${wave.name}): apply failed, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
746
1107
  result.failed.push(...runs.map((r) => r.target));
747
1108
  }
748
1109
  } else {
749
- for (const run of runs) applyRun(spec, wave.name, k, run, cwd, exec, result, log);
1110
+ for (const run of runs) {
1111
+ const key = run.target.replace(/[^A-Za-z0-9._-]/g, "_");
1112
+ // Under runBy "apply" each run's apply runs its own apply-stage hooks, over a runs file of that run alone.
1113
+ const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
1114
+ const argv = opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op: run.op ?? spec.op, target: run.target, wave: wave.name, plan: opWavePlanPath(spec, k, run.target) });
1115
+ const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
1116
+ if (got.outcome === "waiting") {
1117
+ waiting = true;
1118
+ break;
1119
+ }
1120
+ if (got.outcome === "applied") {
1121
+ result.applied.push(run.target);
1122
+ if (got.problem) problems.push(got.problem);
1123
+ } else {
1124
+ log(`${run.target}: apply failed`);
1125
+ result.failed.push(run.target);
1126
+ }
1127
+ }
1128
+ }
1129
+ if (recordPath && byApply) writeRecord(recordPath, decision, result);
1130
+ if (waiting) {
1131
+ result.exitCode = 3;
1132
+ return;
750
1133
  }
751
1134
  if (result.failed.length > 0) {
752
1135
  result.exitCode = 1;
753
1136
  return;
754
1137
  }
755
- const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
756
- if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
757
- }
758
-
759
- function applyRun(
760
- spec: OpWavesSpec,
761
- waveName: string,
762
- k: number,
763
- run: OpWaveRun,
764
- cwd: string,
765
- exec: OpWaveExec,
766
- result: RunOpWaveResult,
767
- log: (line: string) => void,
768
- ): void {
769
- const op = run.op ?? spec.op;
770
- const plan = opWavePlanPath(spec, k, run.target);
771
- const code = exec(opWaveArgv(spec.apply ?? OP_WAVE_DEFAULT_APPLY, { op, target: run.target, wave: waveName, plan }), cwd);
772
- if (code === 0) result.applied.push(run.target);
773
- else {
774
- log(`${run.target}: apply exited ${code}`);
775
- result.failed.push(run.target);
1138
+ if (problems.length > 0) {
1139
+ for (const p of problems) log(p);
1140
+ result.exitCode = 1;
1141
+ return;
1142
+ }
1143
+ if (!byApply) {
1144
+ const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
1145
+ if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
776
1146
  }
1147
+ if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
777
1148
  }
778
1149
 
779
1150
  /**