@intentius/chant 0.123.0 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +128 -1
  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 +444 -82
  29. package/src/op/op-waves.test.ts +161 -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). */
@@ -146,6 +160,77 @@ export interface OpWaveHookOutcome {
146
160
  outcome: "passed" | "failed" | "held";
147
161
  }
148
162
 
163
+ /** One run as a hook sees it (#3804). */
164
+ export interface OpWaveHookRun {
165
+ target: string;
166
+ op: string;
167
+ /** The run's plan file, once planned. */
168
+ plan?: string;
169
+ }
170
+
171
+ /**
172
+ * What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
173
+ * {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
174
+ * relative to the job's working directory.
175
+ */
176
+ export interface OpWaveHooksFile {
177
+ version: 1;
178
+ /** The Op waves spec's name. */
179
+ opWaves: string;
180
+ /** The spec's `op`: a hook's `{op}` when it runs once for the wave. */
181
+ op: string;
182
+ wave: string;
183
+ /** 1-based; 0 when the working tree's spec has no such wave. */
184
+ number: number;
185
+ /** The commit the hooks were read at. */
186
+ base?: string;
187
+ /** The wave's set digest, once decided. */
188
+ digest?: string;
189
+ /** An approval of the digest stands, so a held hook does not stop the apply. */
190
+ approved: boolean;
191
+ /** The labels of the hooks that held the wave, approved or not. */
192
+ heldBy?: string[];
193
+ /** Who runs the apply-stage hooks. With `chant`, `hooks` is empty: chant runs them around the apply. */
194
+ runBy: OpWaveApplyHooksRunner;
195
+ /** The runs this apply call applies. */
196
+ runs: OpWaveHookRun[];
197
+ /** The runs file, a hook's `{runs}` and `CHANT_RUNS`. */
198
+ runsFile: string;
199
+ /** The hooks the apply runs, by moment, as read at base. */
200
+ hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
201
+ /** The hooks that already ran for this wave in this job, and how each came out. */
202
+ outcomes: OpWaveHookOutcome[];
203
+ /** Where {@link runOpWaveApplyHooks} writes its outcomes (`CHANT_HOOKS_RESULT`), which chant reads back. */
204
+ result?: string;
205
+ /** Set when the hooks at base could not be read: none run, and the runner refuses to proceed. */
206
+ refused?: string;
207
+ }
208
+
209
+ /** What {@link runOpWaveApplyHooks} writes to `CHANT_HOOKS_RESULT` (#3804). */
210
+ export interface OpWaveHooksResult {
211
+ version: 1;
212
+ /** The moments the apply ran, empty ones included. */
213
+ moments: OpWaveHookMoment[];
214
+ outcomes: OpWaveHookOutcome[];
215
+ }
216
+
217
+ /** One call of {@link runOpWaveApplyHooks}. */
218
+ export interface OpWaveApplyHooksRun {
219
+ moment: OpWaveHookMoment;
220
+ outcomes: OpWaveHookOutcome[];
221
+ /** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
222
+ failed?: string;
223
+ /** The `onFailure: approve` hooks that failed, or why the hooks could not be read at base. */
224
+ held: string[];
225
+ /**
226
+ * Whether the apply may go on: no hook failed, and nothing held it or an
227
+ * approval of the digest stands. When false after a hold, the apply applies
228
+ * nothing and exits non-zero; chant then waits for, or finds, an approval
229
+ * and calls it again.
230
+ */
231
+ proceed: boolean;
232
+ }
233
+
149
234
  /** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
150
235
  export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
151
236
 
@@ -204,6 +289,8 @@ export interface OpWaveBaseRules {
204
289
  hooksRefused?: string;
205
290
  /** The spec at base sets `approvals.spend` (#3784). */
206
291
  spend: boolean;
292
+ /** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
293
+ applyHooksRunBy: OpWaveApplyHooksRunner;
207
294
  }
208
295
 
209
296
  const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
@@ -224,7 +311,7 @@ export function readOpWavePolicy(
224
311
  show: OpWaveShowAtBase = gitShowAtBase,
225
312
  ): OpWaveBaseRules {
226
313
  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 };
314
+ 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
315
  if (text === null) return absent;
229
316
  let spec: OpWavesSpec;
230
317
  try {
@@ -239,11 +326,12 @@ export function readOpWavePolicy(
239
326
  const policy = wave.gate ?? "always";
240
327
  // An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
241
328
  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 };
329
+ const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
243
330
  try {
244
331
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
245
332
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
246
333
  rules.hooks = opWaveHooksAt(spec, name);
334
+ rules.applyHooksRunBy = opWaveApplyHooksRunBy(spec, name);
247
335
  } catch (err) {
248
336
  // A hook at base no runner can run: none run, and the wave waits for a person rather than going out without them.
249
337
  rules.hooksRefused = `the hooks at base cannot be run (${err instanceof Error ? err.message : String(err)})`;
@@ -366,8 +454,8 @@ function writeRunsFile(
366
454
  runs: readonly OpWaveRun[],
367
455
  members?: ReadonlyMap<string, OpWaveMember>,
368
456
  digest?: string,
457
+ path: string = opWaveRunsPath(at.spec, at.k),
369
458
  ): string {
370
- const path = opWaveRunsPath(at.spec, at.k);
371
459
  const file: OpWaveRunsFile = {
372
460
  wave: at.wave.name,
373
461
  number: at.k,
@@ -436,36 +524,43 @@ interface HooksOutcome {
436
524
  holds: string[];
437
525
  }
438
526
 
527
+ /** Where hooks run: the wave they belong to and the job's tools. */
528
+ interface HookPlace {
529
+ /** The spec's name and `op`. */
530
+ name: string;
531
+ op: string;
532
+ waveName: string;
533
+ k: number;
534
+ cwd: string;
535
+ exec: OpWaveExec;
536
+ log: (line: string) => void;
537
+ }
538
+
439
539
  /**
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
540
+ * Run one moment's hooks, in order: each once for the wave, or once per run
541
+ * for a `perRun` hook. A hook that fails stops the rest, unless its
442
542
  * `onFailure` is `approve`: then it holds the wave and the hooks after it run.
443
543
  */
444
- function runHooks(
445
- ctx: WaveContext,
544
+ function runHookList(
545
+ at: HookPlace,
446
546
  moment: OpWaveHookMoment,
447
547
  hooks: readonly OpWaveHook[] | undefined,
448
- runs: readonly OpWaveRun[],
449
- members?: ReadonlyMap<string, OpWaveMember>,
450
- digest?: string,
451
- ): HooksOutcome {
452
- const out: HooksOutcome = { holds: [] };
548
+ runs: readonly OpWaveHookRun[],
549
+ runsPath: string,
550
+ ): HooksOutcome & { outcomes: OpWaveHookOutcome[] } {
551
+ const out: HooksOutcome & { outcomes: OpWaveHookOutcome[] } = { holds: [], outcomes: [] };
453
552
  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 };
553
+ const { name: specName, op, waveName, k, cwd, exec, log } = at;
554
+ const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath };
457
555
  for (const hook of hooks) {
458
556
  const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
459
557
  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> }];
558
+ ? runs.map((run) => ({
559
+ target: run.target as string | undefined,
560
+ argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
561
+ env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
562
+ }))
563
+ : [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
469
564
  for (const call of calls) {
470
565
  const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
471
566
  let code: number;
@@ -477,16 +572,16 @@ function runHooks(
477
572
  }
478
573
  const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
479
574
  if (code === 0) {
480
- ctx.result.hooks.push({ ...base, outcome: "passed" });
575
+ out.outcomes.push({ ...base, outcome: "passed" });
481
576
  continue;
482
577
  }
483
578
  if (hook.onFailure === "approve") {
484
- ctx.result.hooks.push({ ...base, outcome: "held" });
579
+ out.outcomes.push({ ...base, outcome: "held" });
485
580
  out.holds.push(label);
486
- log(`${label} exited ${code}, so wave ${k} (${wave.name}) waits for an approval of its set digest`);
581
+ log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
487
582
  continue;
488
583
  }
489
- ctx.result.hooks.push({ ...base, outcome: "failed" });
584
+ out.outcomes.push({ ...base, outcome: "failed" });
490
585
  out.failed = `${label} exited ${code}`;
491
586
  log(out.failed);
492
587
  return out;
@@ -495,6 +590,143 @@ function runHooks(
495
590
  return out;
496
591
  }
497
592
 
593
+ 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 });
594
+
595
+ const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
596
+ runs.map((run) => ({ target: run.target, op: run.op ?? spec.op, ...(members?.has(run.target) ? { plan: opWavePlanPath(spec, k, run.target) } : {}) }));
597
+
598
+ /** Run one moment's hooks in the wave job (#3762), recording each outcome on the result. */
599
+ function runHooks(
600
+ ctx: WaveContext,
601
+ moment: OpWaveHookMoment,
602
+ hooks: readonly OpWaveHook[] | undefined,
603
+ runs: readonly OpWaveRun[],
604
+ members?: ReadonlyMap<string, OpWaveMember>,
605
+ digest?: string,
606
+ ): HooksOutcome {
607
+ if (!hooks || hooks.length === 0) return { holds: [] };
608
+ const runsPath = writeRunsFile(ctx, runs, members, digest);
609
+ const out = runHookList(placeOf(ctx), moment, hooks, hookRuns(ctx.spec, ctx.k, runs, members), runsPath);
610
+ ctx.result.hooks.push(...out.outcomes);
611
+ return { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
612
+ }
613
+
614
+ /**
615
+ * Read the hooks file chant hands an apply command (#3804): the one at
616
+ * `path`, by default `CHANT_HOOKS`. Null when there is none, as for an apply
617
+ * that a wave job did not start.
618
+ */
619
+ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT_HOOKS, cwd: string = process.cwd()): OpWaveHooksFile | null {
620
+ if (!path) return null;
621
+ const at = isAbsolute(path) ? path : join(cwd, path);
622
+ let parsed: OpWaveHooksFile;
623
+ try {
624
+ parsed = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksFile;
625
+ } catch (err) {
626
+ throw new Error(`the hooks file ${path} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
627
+ }
628
+ 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)`);
629
+ return parsed;
630
+ }
631
+
632
+ /**
633
+ * Run one moment's hooks from a hooks file (#3804): what an apply command
634
+ * calls under its own lock, `before-apply` right before it changes anything
635
+ * and `after-apply` once it has, when the spec's `hooks.apply.runBy` at base
636
+ * is `apply`. Also what a product's apply outside the waves calls, with a
637
+ * file from {@link opWaveHooksAtBase}. A moment the file has no hooks for
638
+ * runs nothing and proceeds, so an apply can call it whoever runs the hooks.
639
+ *
640
+ * Each call adds its outcomes to the file's `result` (`CHANT_HOOKS_RESULT`),
641
+ * which chant reads back after the apply exits: the outcomes join the wave's
642
+ * result and decision record, and a hold makes the wave wait for, or find,
643
+ * an approval of its digest. `exec` runs one hook; inject it to run a hook
644
+ * in process or to keep its output.
645
+ */
646
+ export function runOpWaveApplyHooks(
647
+ file: OpWaveHooksFile,
648
+ moment: OpWaveHookMoment,
649
+ options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void } = {},
650
+ ): OpWaveApplyHooksRun {
651
+ const cwd = options.cwd ?? process.cwd();
652
+ const log = options.log ?? ((line: string) => console.error(line));
653
+ let run: OpWaveApplyHooksRun;
654
+ if (file.refused) {
655
+ log(`${file.refused}, so none of the ${moment} hooks run and the apply does not go on`);
656
+ run = { moment, outcomes: [], held: [file.refused], proceed: false };
657
+ } else {
658
+ const place: HookPlace = { name: file.opWaves, op: file.op, waveName: file.wave, k: file.number, cwd, exec: options.exec ?? spawnExec, log };
659
+ const out = runHookList(place, moment, file.hooks[moment], file.runs, file.runsFile);
660
+ run = {
661
+ moment,
662
+ outcomes: out.outcomes,
663
+ ...(out.failed ? { failed: out.failed } : {}),
664
+ held: out.holds,
665
+ proceed: !out.failed && (out.holds.length === 0 || file.approved),
666
+ };
667
+ }
668
+ if (file.result) {
669
+ const at = isAbsolute(file.result) ? file.result : join(cwd, file.result);
670
+ let prior: OpWaveHooksResult = { version: 1, moments: [], outcomes: [] };
671
+ if (existsSync(at)) {
672
+ try {
673
+ prior = JSON.parse(readFileSync(at, "utf-8")) as OpWaveHooksResult;
674
+ } catch {
675
+ // A result file that cannot be read is started again.
676
+ }
677
+ }
678
+ const next: OpWaveHooksResult = { version: 1, moments: [...prior.moments, moment], outcomes: [...prior.outcomes, ...run.outcomes] };
679
+ mkdirSync(dirname(at), { recursive: true });
680
+ writeFileSync(at, JSON.stringify(next, null, 2) + "\n");
681
+ }
682
+ return run;
683
+ }
684
+
685
+ /**
686
+ * The hooks of wave `wave` read at base, for an apply outside the waves
687
+ * (#3804): a product's own `apply <env>` that runs the same Op as the wave
688
+ * does, and the same hooks. All four moments are in the file; the apply runs
689
+ * the ones it has a stage for with {@link runOpWaveApplyHooks}. Nothing is
690
+ * approved: a held hook stops the apply. The wave or the file missing at
691
+ * base has no hooks, as in a wave job.
692
+ */
693
+ export function opWaveHooksAtBase(options: {
694
+ specFile: string;
695
+ wave: string;
696
+ /** The runs this apply applies. Default: the wave's runs in the working tree's spec. */
697
+ runs?: ReadonlyArray<{ target: string; op?: string }>;
698
+ /** Default: the spec's `base`, else `HEAD^1`. */
699
+ base?: string;
700
+ cwd?: string;
701
+ show?: OpWaveShowAtBase;
702
+ }): OpWaveHooksFile {
703
+ const cwd = options.cwd ?? process.cwd();
704
+ const spec = parseOpWavesSpec(readFileSync(isAbsolute(options.specFile) ? options.specFile : join(cwd, options.specFile), "utf-8"), options.specFile);
705
+ const k = spec.waves.findIndex((w) => w.name === options.wave) + 1;
706
+ const rules = readOpWavePolicy(options.specFile, options.wave, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
707
+ const runs = (options.runs ?? spec.waves[k - 1]?.runs ?? []).map((r) => ({ target: r.target, op: r.op ?? spec.op }));
708
+ const runsFile = `.chant/op-waves/${spec.name}/${options.wave.replace(/[^A-Za-z0-9._-]/g, "_")}.runs.json`;
709
+ const runsBody: OpWaveRunsFile = { wave: options.wave, number: k, runs };
710
+ mkdirSync(dirname(join(cwd, runsFile)), { recursive: true });
711
+ writeFileSync(join(cwd, runsFile), JSON.stringify(runsBody, null, 2) + "\n");
712
+ const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
713
+ return {
714
+ version: 1,
715
+ opWaves: spec.name,
716
+ op: spec.op,
717
+ wave: options.wave,
718
+ number: k,
719
+ base: rules.base,
720
+ approved: false,
721
+ runBy: "apply",
722
+ runs,
723
+ runsFile,
724
+ hooks,
725
+ outcomes: [],
726
+ ...(rules.hooksRefused ? { refused: rules.hooksRefused } : {}),
727
+ };
728
+ }
729
+
498
730
  /** Whether `policy` lets a wave with these members through without an approval. */
499
731
  export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly OpWaveMember[]): boolean {
500
732
  if (policy === "never") return false;
@@ -504,9 +736,10 @@ export function opWaveNeedsApproval(policy: OpWaveGatePolicy, members: readonly
504
736
  }
505
737
 
506
738
  /** 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 {
739
+ function noteHooksAtBase(ctx: WaveContext, rules: OpWaveBaseRules): void {
740
+ const { hooks: atBase, base } = rules;
508
741
  const own = opWaveHooksAt(ctx.spec, ctx.wave.name);
509
- if (JSON.stringify(own) !== JSON.stringify(atBase)) {
742
+ if (JSON.stringify(own) !== JSON.stringify(atBase) || opWaveApplyHooksRunBy(ctx.spec, ctx.wave.name) !== rules.applyHooksRunBy) {
510
743
  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
744
  }
512
745
  }
@@ -570,7 +803,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
570
803
  // The policy, the approval mode and the hooks, read at base before anything runs.
571
804
  const rules = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
572
805
  const { policy, approval, source, base } = rules;
573
- noteHooksAtBase(ctx, rules.hooks, base);
806
+ noteHooksAtBase(ctx, rules);
574
807
  const holds: string[] = [];
575
808
  if (rules.hooksRefused) {
576
809
  holds.push(rules.hooksRefused);
@@ -601,12 +834,13 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
601
834
  ...(Object.keys(hooks).length > 0 ? { hooks } : {}),
602
835
  ...(holds.length > 0 ? { heldBy: holds } : {}),
603
836
  ...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
837
+ ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
604
838
  };
605
839
  if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
606
840
  // A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
607
841
  if (options.decide && !(await spendApproval(options, ctx, decision))) return ctx.result;
608
842
  mkdirSync(dirname(recordPath), { recursive: true });
609
- writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
843
+ writeRecord(recordPath, decision, result);
610
844
  result.decision = decision;
611
845
  log(
612
846
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
@@ -695,11 +929,135 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
695
929
  decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
696
930
  }
697
931
 
932
+ /** Write the decision record, with every hook's outcome so far (#3804). */
933
+ function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
934
+ if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
935
+ writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
936
+ }
937
+
938
+ /**
939
+ * A hold waits only when nobody approved the decided digest: settle the gate
940
+ * once for `holds`. False when the wave now waits (exit 3).
941
+ */
942
+ async function settleHolds(
943
+ options: RunOpWaveOptions,
944
+ ctx: WaveContext,
945
+ decision: OpWaveDecision,
946
+ holds: readonly string[],
947
+ recordPath?: string,
948
+ ): Promise<boolean> {
949
+ const { k, wave, log, result } = ctx;
950
+ if (holds.length === 0 || decision.status === "approved") return true;
951
+ decision.heldBy = [...(decision.heldBy ?? []), ...holds];
952
+ await settleGate(options, ctx, decision);
953
+ if (recordPath) writeRecord(recordPath, decision, result);
954
+ if (decision.status === "waiting") {
955
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and nobody approved ${decision.digest}; nothing applied. Approve: ${decision.approve}`);
956
+ result.exitCode = 3;
957
+ return false;
958
+ }
959
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
960
+ return true;
961
+ }
962
+
963
+ /** How one apply call came out. `applied` with a `problem`: the runs applied, and the wave still fails. */
964
+ type ApplyCall = { outcome: "applied"; problem?: string } | { outcome: "failed" | "waiting" };
965
+
966
+ /**
967
+ * Call one apply command (#3804) with `CHANT_HOOKS` and `CHANT_HOOKS_RESULT`.
968
+ * When the apply runs the apply-stage hooks, read their outcomes back: a hold
969
+ * with no approval standing settles the gate and, once approved, calls the
970
+ * apply again; an `after-apply` failure means the runs applied and the wave
971
+ * fails; an apply that exited 0 without running its hooks fails the wave.
972
+ */
973
+ async function callApply(
974
+ options: RunOpWaveOptions,
975
+ ctx: WaveContext,
976
+ decision: OpWaveDecision,
977
+ runs: readonly OpWaveRun[],
978
+ planned: ReadonlyMap<string, OpWaveMember>,
979
+ argv: string[],
980
+ call: { label: string; key: string; runsPath: string },
981
+ recordPath?: string,
982
+ ): Promise<ApplyCall> {
983
+ const { spec, k, wave, cwd, exec, log, result } = ctx;
984
+ const byApply = decision.applyHooksRunBy === "apply";
985
+ const dir = `.chant/op-waves/${spec.name}/wave-${k}`;
986
+ const filePath = `${dir}/${call.key}.json`;
987
+ const resultPath = `${dir}/${call.key}-result.json`;
988
+ const handed: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
989
+ if (byApply) {
990
+ for (const m of ["before-apply", "after-apply"] as const) if (decision.hooks?.[m]?.length) handed[m] = decision.hooks[m];
991
+ }
992
+ for (let pass = 0; pass < 2; pass++) {
993
+ const approved = decision.status === "approved";
994
+ const file: OpWaveHooksFile = {
995
+ version: 1,
996
+ opWaves: spec.name,
997
+ op: spec.op,
998
+ wave: wave.name,
999
+ number: k,
1000
+ base: decision.base,
1001
+ digest: decision.digest,
1002
+ approved,
1003
+ ...(decision.heldBy?.length ? { heldBy: decision.heldBy } : {}),
1004
+ runBy: byApply ? "apply" : "chant",
1005
+ runs: hookRuns(spec, k, runs, planned),
1006
+ runsFile: call.runsPath,
1007
+ hooks: handed,
1008
+ outcomes: [...result.hooks],
1009
+ ...(byApply ? { result: resultPath } : {}),
1010
+ };
1011
+ mkdirSync(join(cwd, dir), { recursive: true });
1012
+ writeFileSync(join(cwd, filePath), JSON.stringify(file, null, 2) + "\n");
1013
+ rmSync(join(cwd, resultPath), { force: true });
1014
+ let code: number;
1015
+ try {
1016
+ code = exec(argv, cwd, { CHANT_HOOKS: filePath, ...(byApply ? { CHANT_HOOKS_RESULT: resultPath } : {}) });
1017
+ } catch (err) {
1018
+ log(`${call.label}: the apply did not run: ${err instanceof Error ? err.message : String(err)}`);
1019
+ code = 127;
1020
+ }
1021
+ if (!byApply) return { outcome: code === 0 ? "applied" : "failed" };
1022
+ let back: OpWaveHooksResult | null = null;
1023
+ try {
1024
+ back = JSON.parse(readFileSync(join(cwd, resultPath), "utf-8")) as OpWaveHooksResult;
1025
+ } catch {
1026
+ back = null;
1027
+ }
1028
+ const outcomes = Array.isArray(back?.outcomes) ? back!.outcomes : [];
1029
+ const moments = Array.isArray(back?.moments) ? back!.moments : [];
1030
+ result.hooks.push(...outcomes);
1031
+ const holds = outcomes.filter((o) => o.moment === "before-apply" && o.outcome === "held").map((o) => `${o.moment} hook ${o.name}${o.target ? ` (${o.target})` : ""}`);
1032
+ if (holds.length > 0 && !approved) {
1033
+ if (code === 0) {
1034
+ log(`${call.label}: the apply exited 0 although ${holds.join(", ")} held it and no approval stood; an apply applies nothing on a hold`);
1035
+ return { outcome: "failed" };
1036
+ }
1037
+ if (pass > 0 || !(await settleHolds(options, ctx, decision, holds, recordPath))) return { outcome: "waiting" };
1038
+ if (!(await spendApproval(options, ctx, decision))) return { outcome: "failed" };
1039
+ if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1040
+ continue;
1041
+ }
1042
+ const afterFailed = outcomes.find((o) => o.moment === "after-apply" && o.outcome === "failed");
1043
+ if (afterFailed) return { outcome: "applied", problem: `${call.label} applied, and then the after-apply hook ${afterFailed.name} failed` };
1044
+ if (code !== 0) return { outcome: "failed" };
1045
+ const skipped = (["before-apply", "after-apply"] as const).filter((m) => handed[m] && !moments.includes(m));
1046
+ if (skipped.length > 0) {
1047
+ 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` };
1048
+ }
1049
+ return { outcome: "applied" };
1050
+ }
1051
+ return { outcome: "failed" };
1052
+ }
1053
+
698
1054
  /**
699
1055
  * Apply `runs` of a decided wave: the before-apply hooks, the apply (one call
700
1056
  * per run, or one `applyWave` call for the set), then the after-apply hooks.
701
1057
  * 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.
1058
+ * for an approval of the decided digest unless one already stands. Under
1059
+ * `hooks.apply.runBy: "apply"` the apply call runs the apply-stage hooks
1060
+ * itself, under its lock (#3804).
703
1061
  */
704
1062
  async function applyStage(
705
1063
  options: RunOpWaveOptions,
@@ -710,70 +1068,74 @@ async function applyStage(
710
1068
  earlierHolds: readonly string[],
711
1069
  recordPath?: string,
712
1070
  ): Promise<void> {
713
- const { spec, k, wave, cwd, exec, log, result } = ctx;
1071
+ const { spec, k, wave, log, result } = ctx;
714
1072
  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;
1073
+ const byApply = decision.applyHooksRunBy === "apply";
1074
+ const holds = [...earlierHolds];
1075
+ if (!byApply) {
1076
+ const before = runHooks(ctx, "before-apply", decision.hooks?.["before-apply"], runs, planned, decision.digest);
1077
+ if (before.failed) {
1078
+ failed(ctx, `a hook before the apply failed, so nothing in wave ${k} was applied`);
728
1079
  return;
729
1080
  }
730
- log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}, and ${decision.approvedBy} approved its digest`);
1081
+ holds.push(...before.holds);
731
1082
  }
1083
+ if (!(await settleHolds(options, ctx, decision, holds, recordPath))) return;
732
1084
  if (!(await spendApproval(options, ctx, decision))) return;
733
- if (recordPath && decision.spentAt) writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
1085
+ if (recordPath && decision.spentAt) writeRecord(recordPath, decision, result);
1086
+ const problems: string[] = [];
1087
+ let waiting = false;
734
1088
  if (spec.applyWave) {
735
1089
  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));
1090
+ const argv = opWaveArgv(spec.applyWave, { op: spec.op, wave: wave.name, runs: runsPath, plans: opWavePlansPath(spec, k) });
1091
+ const got = await callApply(options, ctx, decision, runs, planned, argv, { label: `wave ${k} (${wave.name})`, key: "hooks", runsPath }, recordPath);
1092
+ if (got.outcome === "applied") {
1093
+ result.applied.push(...runs.map((r) => r.target));
1094
+ if (got.problem) problems.push(got.problem);
1095
+ } else if (got.outcome === "waiting") waiting = true;
744
1096
  else {
745
- log(`wave ${k} (${wave.name}): apply exited ${code}, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
1097
+ log(`wave ${k} (${wave.name}): apply failed, so none of ${runs.map((r) => r.target).join(", ")} counts as applied`);
746
1098
  result.failed.push(...runs.map((r) => r.target));
747
1099
  }
748
1100
  } else {
749
- for (const run of runs) applyRun(spec, wave.name, k, run, cwd, exec, result, log);
1101
+ for (const run of runs) {
1102
+ const key = run.target.replace(/[^A-Za-z0-9._-]/g, "_");
1103
+ // Under runBy "apply" each run's apply runs its own apply-stage hooks, over a runs file of that run alone.
1104
+ const runsPath = byApply ? writeRunsFile(ctx, [run], planned, decision.digest, `.chant/op-waves/${spec.name}/wave-${k}/${key}.runs.json`) : opWaveRunsPath(spec, k);
1105
+ 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) });
1106
+ const got = await callApply(options, ctx, decision, [run], planned, argv, { label: run.target, key: `${key}.hooks`, runsPath }, recordPath);
1107
+ if (got.outcome === "waiting") {
1108
+ waiting = true;
1109
+ break;
1110
+ }
1111
+ if (got.outcome === "applied") {
1112
+ result.applied.push(run.target);
1113
+ if (got.problem) problems.push(got.problem);
1114
+ } else {
1115
+ log(`${run.target}: apply failed`);
1116
+ result.failed.push(run.target);
1117
+ }
1118
+ }
1119
+ }
1120
+ if (recordPath && byApply) writeRecord(recordPath, decision, result);
1121
+ if (waiting) {
1122
+ result.exitCode = 3;
1123
+ return;
750
1124
  }
751
1125
  if (result.failed.length > 0) {
752
1126
  result.exitCode = 1;
753
1127
  return;
754
1128
  }
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);
1129
+ if (problems.length > 0) {
1130
+ for (const p of problems) log(p);
1131
+ result.exitCode = 1;
1132
+ return;
1133
+ }
1134
+ if (!byApply) {
1135
+ const after = runHooks(ctx, "after-apply", decision.hooks?.["after-apply"], runs, planned, decision.digest);
1136
+ if (after.failed) failed(ctx, `wave ${k} (${wave.name}) applied, and then a hook after the apply failed`);
776
1137
  }
1138
+ if (recordPath && result.hooks.length > 0) writeRecord(recordPath, decision, result);
777
1139
  }
778
1140
 
779
1141
  /**