@intentius/chant 0.124.22 → 0.124.24

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 (187) hide show
  1. package/dist/audit/rules-doc.js +1 -1
  2. package/dist/ci-jobs.d.ts +41 -1
  3. package/dist/ci-jobs.d.ts.map +1 -1
  4. package/dist/ci-jobs.js +48 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/build.js +14 -0
  7. package/dist/cli/handlers/approve.d.ts.map +1 -1
  8. package/dist/cli/handlers/approve.js +9 -0
  9. package/dist/cli/handlers/emulator.d.ts +13 -2
  10. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  11. package/dist/cli/handlers/emulator.js +63 -3
  12. package/dist/cli/handlers/graph.js +24 -1
  13. package/dist/cli/handlers/promote.d.ts.map +1 -1
  14. package/dist/cli/handlers/promote.js +7 -1
  15. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  16. package/dist/cli/handlers/run-wave.js +5 -4
  17. package/dist/cli/handlers/run.d.ts +4 -3
  18. package/dist/cli/handlers/run.d.ts.map +1 -1
  19. package/dist/cli/handlers/run.js +73 -9
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +17 -4
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +31 -0
  24. package/dist/cli/registry.d.ts +12 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/cli-support.d.ts +45 -6
  27. package/dist/components/cli-support.d.ts.map +1 -1
  28. package/dist/components/cli-support.js +97 -22
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +25 -10
  31. package/dist/graph-detail.d.ts.map +1 -1
  32. package/dist/graph-detail.js +24 -2
  33. package/dist/graph-ir.d.ts +43 -0
  34. package/dist/graph-ir.d.ts.map +1 -1
  35. package/dist/graph-ir.js +27 -0
  36. package/dist/lexicon.d.ts +25 -1
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/git.d.ts +11 -0
  39. package/dist/lifecycle/git.d.ts.map +1 -1
  40. package/dist/lifecycle/git.js +62 -6
  41. package/dist/lifecycle/no-record.d.ts +59 -0
  42. package/dist/lifecycle/no-record.d.ts.map +1 -0
  43. package/dist/lifecycle/no-record.js +88 -0
  44. package/dist/op/activities/shell.d.ts.map +1 -1
  45. package/dist/op/activities/shell.js +30 -9
  46. package/dist/op/emulator-lifecycle.d.ts +23 -0
  47. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  48. package/dist/op/emulator-lifecycle.js +39 -2
  49. package/dist/op/gate.d.ts.map +1 -1
  50. package/dist/op/gate.js +3 -0
  51. package/dist/op/index.d.ts +1 -1
  52. package/dist/op/index.d.ts.map +1 -1
  53. package/dist/op/index.js +1 -1
  54. package/dist/op/local-executor.d.ts +10 -0
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/local-executor.js +7 -2
  57. package/dist/op/op-waves-notify.d.ts +2 -0
  58. package/dist/op/op-waves-notify.d.ts.map +1 -1
  59. package/dist/op/op-waves-notify.js +9 -3
  60. package/dist/op/op-waves-run.d.ts +8 -2
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves-run.js +36 -11
  63. package/dist/op/op-waves.d.ts +85 -1
  64. package/dist/op/op-waves.d.ts.map +1 -1
  65. package/dist/op/op-waves.js +153 -1
  66. package/dist/op/runtimes/local.d.ts.map +1 -1
  67. package/dist/op/runtimes/local.js +6 -0
  68. package/dist/op/step-console.d.ts +65 -0
  69. package/dist/op/step-console.d.ts.map +1 -0
  70. package/dist/op/step-console.js +140 -0
  71. package/dist/point-decision.d.ts.map +1 -1
  72. package/dist/point-decision.js +3 -0
  73. package/dist/wave-runtime.mjs +240 -224
  74. package/dist/workspace/checks/links.d.ts +5 -0
  75. package/dist/workspace/checks/links.d.ts.map +1 -1
  76. package/dist/workspace/checks/links.js +54 -8
  77. package/dist/workspace/checks.d.ts +4 -1
  78. package/dist/workspace/checks.d.ts.map +1 -1
  79. package/dist/workspace/checks.js +6 -2
  80. package/dist/workspace/compose-graph.d.ts +26 -4
  81. package/dist/workspace/compose-graph.d.ts.map +1 -1
  82. package/dist/workspace/compose-graph.js +21 -7
  83. package/dist/workspace/dbt-project-reader.d.ts +36 -0
  84. package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
  85. package/dist/workspace/dbt-project-reader.js +290 -0
  86. package/dist/workspace/graph-cli.d.ts.map +1 -1
  87. package/dist/workspace/graph-cli.js +3 -1
  88. package/dist/workspace/graph.schema.json +109 -4
  89. package/dist/workspace/intent.js +7 -2
  90. package/dist/workspace/kind-link-readers.d.ts +84 -0
  91. package/dist/workspace/kind-link-readers.d.ts.map +1 -0
  92. package/dist/workspace/kind-link-readers.js +53 -0
  93. package/dist/workspace/kinds.d.ts +15 -0
  94. package/dist/workspace/kinds.d.ts.map +1 -1
  95. package/dist/workspace/kinds.js +11 -0
  96. package/dist/workspace/lexicon-outputs.d.ts +63 -0
  97. package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
  98. package/dist/workspace/lexicon-outputs.js +241 -0
  99. package/dist/workspace/links.d.ts +29 -7
  100. package/dist/workspace/links.d.ts.map +1 -1
  101. package/dist/workspace/links.js +109 -34
  102. package/dist/workspace/member-handles.d.ts +18 -1
  103. package/dist/workspace/member-handles.d.ts.map +1 -1
  104. package/dist/workspace/member-handles.js +31 -14
  105. package/dist/workspace/nested-graph.d.ts +1 -1
  106. package/dist/workspace/nested-graph.d.ts.map +1 -1
  107. package/dist/workspace/nested-graph.js +5 -1
  108. package/dist/workspace/records-checkout.d.ts +11 -1
  109. package/dist/workspace/records-checkout.d.ts.map +1 -1
  110. package/dist/workspace/records-checkout.js +38 -2
  111. package/dist/workspace/runs.d.ts.map +1 -1
  112. package/dist/workspace/runs.js +4 -2
  113. package/dist/workspace/status.d.ts.map +1 -1
  114. package/dist/workspace/status.js +3 -2
  115. package/dist/workspace/workspace-kinds.schema.json +5 -0
  116. package/package.json +1 -1
  117. package/src/audit/rules-doc.ts +1 -1
  118. package/src/ci-jobs.ts +58 -1
  119. package/src/cli/commands/build.ts +18 -0
  120. package/src/cli/handlers/approve.ts +9 -0
  121. package/src/cli/handlers/emulator.test.ts +61 -1
  122. package/src/cli/handlers/emulator.ts +65 -4
  123. package/src/cli/handlers/graph.ts +22 -1
  124. package/src/cli/handlers/promote.test.ts +7 -0
  125. package/src/cli/handlers/promote.ts +7 -1
  126. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  127. package/src/cli/handlers/run-wave.ts +5 -4
  128. package/src/cli/handlers/run.test.ts +126 -0
  129. package/src/cli/handlers/run.ts +69 -9
  130. package/src/cli/main.test.ts +24 -0
  131. package/src/cli/main.ts +17 -4
  132. package/src/cli/parse-args.ts +21 -0
  133. package/src/cli/registry.ts +12 -0
  134. package/src/components/cli-support.test.ts +75 -0
  135. package/src/components/cli-support.ts +130 -25
  136. package/src/components/driver.test.ts +28 -0
  137. package/src/components/driver.ts +23 -10
  138. package/src/components/generate-disabled.test.ts +56 -0
  139. package/src/graph-ci-edges.test.ts +50 -0
  140. package/src/graph-detail.ts +21 -2
  141. package/src/graph-ir.test.ts +23 -1
  142. package/src/graph-ir.ts +65 -0
  143. package/src/lexicon.ts +26 -1
  144. package/src/lifecycle/git.ts +61 -6
  145. package/src/lifecycle/no-record.test.ts +175 -0
  146. package/src/lifecycle/no-record.ts +108 -0
  147. package/src/op/activities/shell.ts +28 -6
  148. package/src/op/emulator-lifecycle.test.ts +24 -1
  149. package/src/op/emulator-lifecycle.ts +58 -2
  150. package/src/op/gate.ts +3 -0
  151. package/src/op/index.ts +1 -1
  152. package/src/op/local-executor.ts +20 -3
  153. package/src/op/op-waves-jobs.test.ts +213 -0
  154. package/src/op/op-waves-notify.ts +11 -5
  155. package/src/op/op-waves-run.ts +42 -11
  156. package/src/op/op-waves.ts +202 -2
  157. package/src/op/runtimes/local.ts +6 -0
  158. package/src/op/step-console.test.ts +150 -0
  159. package/src/op/step-console.ts +155 -0
  160. package/src/point-decision-bundle.test.ts +1 -0
  161. package/src/point-decision.ts +3 -0
  162. package/src/wave-loop-golden.test.ts +3 -0
  163. package/src/workspace/checks/links.ts +57 -8
  164. package/src/workspace/checks.e2e.test.ts +1 -1
  165. package/src/workspace/checks.ts +9 -2
  166. package/src/workspace/compose-graph.test.ts +55 -0
  167. package/src/workspace/compose-graph.ts +33 -9
  168. package/src/workspace/dbt-kind.e2e.test.ts +95 -0
  169. package/src/workspace/dbt-kind.test.ts +283 -0
  170. package/src/workspace/dbt-project-reader.ts +291 -0
  171. package/src/workspace/graph-cli.ts +3 -1
  172. package/src/workspace/graph-contract.test.ts +26 -1
  173. package/src/workspace/graph.schema.json +109 -4
  174. package/src/workspace/intent.ts +7 -2
  175. package/src/workspace/kind-link-readers.ts +109 -0
  176. package/src/workspace/kinds.ts +21 -1
  177. package/src/workspace/lexicon-outputs.ts +272 -0
  178. package/src/workspace/links.test.ts +22 -0
  179. package/src/workspace/links.ts +129 -34
  180. package/src/workspace/member-handles.ts +28 -8
  181. package/src/workspace/nested-graph.test.ts +14 -0
  182. package/src/workspace/nested-graph.ts +7 -2
  183. package/src/workspace/records-checkout.ts +36 -2
  184. package/src/workspace/records-uncommitted.test.ts +5 -1
  185. package/src/workspace/runs.ts +4 -2
  186. package/src/workspace/status.ts +3 -2
  187. package/src/workspace/workspace-kinds.schema.json +5 -0
package/src/op/gate.ts CHANGED
@@ -54,6 +54,7 @@ import { gateContextDigest, type GateApprover, type ResolvedGateApproval } from
54
54
  import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requireLifecycleLedger, writeBlobToPath, RefCASConflictError } from "../lifecycle/git";
55
55
  import { parseDuration } from "./duration";
56
56
  import { resolveGateRunLocator } from "./gate-run-locator";
57
+ import { refuseGateWithoutRecording } from "../lifecycle/no-record";
57
58
 
58
59
  /**
59
60
  * What appending a pending fact learned about reaching the remote.
@@ -531,6 +532,8 @@ const EPOCH = new Date(0).toISOString();
531
532
  * recorded" from "still standing".
532
533
  */
533
534
  export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput): Promise<GateCheck> {
535
+ // #3955: a run that records nothing cannot be approved or spend an approval.
536
+ refuseGateWithoutRecording(input.op, input.gate);
534
537
  const now = input.now ?? new Date().toISOString();
535
538
  const ledger = await port.read(input.op);
536
539
  // #2574: a component's ledger holds every environment's facts. This run
package/src/op/index.ts CHANGED
@@ -11,7 +11,7 @@ export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, he
11
11
  spritesUp, spritesDown } from "./builders";
12
12
  export { OpResource } from "./resource";
13
13
  export { sleep } from "./activity-runtime";
14
- export { clientHosts, emulatorLifecycle, emulatorsOf, endpointEnvVars, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } from "./emulator-lifecycle";
14
+ export { checkSettings, clientHosts, emulatorLifecycle, emulatorsOf, endpointEnvVars, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } from "./emulator-lifecycle";
15
15
  export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorIdentity, EmulatorUpArgs, EmulatorLifecycle, PublishedAddress } from "./emulator-lifecycle";
16
16
  export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
17
17
  export type { FreshnessResult } from "./emulator-freshness";
@@ -25,6 +25,7 @@ import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activi
25
25
  import type { ReceiptReadResult } from "./receipt-store";
26
26
  import { isStepOutputRef } from "./step-output-ref";
27
27
  import { parseDuration } from "./duration";
28
+ import { stepLabel, withRunConsole, withStepConsole } from "./step-console";
28
29
  import { stepTimeoutProblem } from "./activity-profiles";
29
30
  import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
30
31
  import { gateName } from "./gate-name";
@@ -409,7 +410,8 @@ async function runStep(
409
410
  // The in-flight record names the step while it runs.
410
411
  const ended = currentLiveRun()?.stepStarted(step.fn, step.id);
411
412
  try {
412
- return await attempts();
413
+ // #3956: its output printed as it arrives, and a line while it is quiet.
414
+ return await withStepConsole({ phase: phaseName, label: stepLabel(step.fn, args, step.id) }, attempts);
413
415
  } finally {
414
416
  ended?.();
415
417
  }
@@ -1109,6 +1111,16 @@ export interface RunOpOptions {
1109
1111
  work?: { item?: string; holder?: string };
1110
1112
  /** Called when a work-lease renewal fails without losing the lease; the next beat retries it. */
1111
1113
  onWorkLeaseWarning?: (message: string) => void;
1114
+ /**
1115
+ * Where a running step's output goes, line by line, as it arrives (#3956):
1116
+ * `[<phase>] <step> | <line>` for each line a `shellCmd` command writes, and
1117
+ * `[<phase>] <step>: still running after <n>s` for a step quiet for
1118
+ * `stepHeartbeatMs` (./step-console.ts). The local runtime passes a writer
1119
+ * to stderr. Omitted, nothing is printed while a step runs.
1120
+ */
1121
+ stepOutput?: (line: string) => void;
1122
+ /** How long a step may be quiet before the still-running line. Default 60s; `0` turns it off. */
1123
+ stepHeartbeatMs?: number;
1112
1124
  }
1113
1125
 
1114
1126
  /**
@@ -1165,9 +1177,14 @@ export async function runOpLocally(
1165
1177
  })
1166
1178
  : undefined;
1167
1179
  try {
1180
+ const stepConsole = options.stepOutput
1181
+ ? { write: options.stepOutput, ...(options.stepHeartbeatMs !== undefined ? { heartbeatMs: options.stepHeartbeatMs } : {}) }
1182
+ : undefined;
1168
1183
  return await withLiveRun(live, () =>
1169
- withOpRunContext(run, () =>
1170
- runOpInTurn(config, activities, profiles, signal, { ...options, runId, ...(steward ? { steward } : {}) }, run),
1184
+ withRunConsole(stepConsole, () =>
1185
+ withOpRunContext(run, () =>
1186
+ runOpInTurn(config, activities, profiles, signal, { ...options, runId, ...(steward ? { steward } : {}) }, run),
1187
+ ),
1171
1188
  ),
1172
1189
  );
1173
1190
  } finally {
@@ -0,0 +1,213 @@
1
+ /**
2
+ * A runner per wave and job groups per wave (#3914): the spec checks, the
3
+ * jobs a wave with `jobs` becomes, and `chant run wave --group` applying one
4
+ * group's runs under the deciding job's one decision.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import { assertOpWavesSpec, opWaveGroupRuns, opWaveJobs, opWaveRunMatches, type OpWave, type OpWavesSpec } from "./op-waves";
11
+ import { runOpWave, type OpWaveExec, type OpWaveRunsFile, type OpWaveShowAtBase } from "./op-waves-run";
12
+ import { memoryGateLedgerPort } from "./gate";
13
+ import { opWaveNotifyFields } from "./op-waves-notify";
14
+ import { parseArgs } from "../cli/parse-args";
15
+ import { waveSetDigest } from "../gated-waves";
16
+
17
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
18
+
19
+ const grouped: OpWave = {
20
+ name: "apply",
21
+ runs: [{ target: "live/dev/a" }, { target: "live/prod/a" }, { target: "live/prod/b" }, { target: "live/prod/c" }],
22
+ jobs: [
23
+ { name: "dev", runs: ["live/dev/*"], runner: ["self-hosted", "dev"], environment: { name: "dev" }, idTokens: { TG_TOKEN: "dev.example" } },
24
+ { name: "prod", runs: ["live/prod/**"], shares: 2, runner: "prod", idTokens: { TG_TOKEN: "prod.example" } },
25
+ ],
26
+ };
27
+
28
+ const spec: OpWavesSpec = {
29
+ name: "tf-apply",
30
+ op: "tf-apply",
31
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
32
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
33
+ runner: "plan",
34
+ waves: [{ name: "first", runs: [{ target: "live/dev/z" }], gate: "never" }, { ...grouped, gate: "always" }, { name: "last", runs: [{ target: "live/x" }], gate: "never" }],
35
+ };
36
+
37
+ describe("the spec (#3914)", () => {
38
+ test("runner and jobs are checked, and name the wave and the field", () => {
39
+ expect(() => assertOpWavesSpec(spec)).not.toThrow();
40
+ const wave = (w: Partial<OpWave>): OpWavesSpec => ({ ...spec, waves: [{ ...grouped, ...w }] });
41
+ const group = (g: Record<string, unknown>): OpWavesSpec => wave({ jobs: [{ ...grouped.jobs![0]!, ...g }, grouped.jobs![1]!] as OpWave["jobs"] });
42
+ expect(() => assertOpWavesSpec(wave({ jobs: [{ name: "all", runs: ["live/**"] }, { name: "prod", runs: ["live/prod/*"] }] }))).toThrow(/wave "apply" jobs: run "live\/prod\/a" is in job groups "all" and "prod"/);
43
+ expect(() => assertOpWavesSpec(wave({ jobs: [{ name: "prod", runs: ["live/prod/*"] }] }))).toThrow(/wave "apply" jobs: run "live\/dev\/a" is in no job group/);
44
+ expect(() => assertOpWavesSpec(wave({ jobs: [] }))).toThrow(/wave "apply" jobs must be a non-empty list/);
45
+ expect(() => assertOpWavesSpec(wave({ shares: 2 }))).toThrow(/wave "apply" has jobs and shares/);
46
+ expect(() => assertOpWavesSpec(group({ name: "decide" }))).toThrow(/jobs\[0\] \(decide\) is named like the wave's own jobs/);
47
+ expect(() => assertOpWavesSpec(group({ name: "prod" }))).toThrow(/names job group "prod" twice/);
48
+ expect(() => assertOpWavesSpec(group({ runs: [] }))).toThrow(/jobs\[0\] \(dev\) runs must be a non-empty list/);
49
+ expect(() => assertOpWavesSpec(group({ shares: 0 }))).toThrow(/jobs\[0\] \(dev\) has shares 0/);
50
+ expect(() => assertOpWavesSpec(group({ env: {} }))).toThrow(/jobs\[0\] \(dev\)\.env is not a setting/);
51
+ expect(() => assertOpWavesSpec(group({ idTokens: { "1X": "aud" } }))).toThrow(/jobs\[0\] \(dev\) idTokens names "1X"/);
52
+ expect(() => assertOpWavesSpec(group({ idTokens: { X: "" } }))).toThrow(/idTokens\.X must be an audience/);
53
+ expect(() => assertOpWavesSpec(group({ runner: [] }))).toThrow(/jobs\[0\] \(dev\) runner must be a runner label/);
54
+ expect(() => assertOpWavesSpec(group({ runner: { group: "apply", label: "x" } }))).toThrow(/runner\.label is not a setting/);
55
+ expect(() => assertOpWavesSpec(group({ runner: { group: "apply", labels: ["linux"] } }))).not.toThrow();
56
+ expect(() => assertOpWavesSpec({ ...spec, runner: "" })).toThrow(/Op waves "tf-apply": runner must be a runner label/);
57
+ expect(() => assertOpWavesSpec(wave({ runner: 3 as unknown as string }))).toThrow(/wave "apply" runner must be/);
58
+ expect(() => assertOpWavesSpec(wave({ idTokens: {} }))).toThrow(/wave "apply" idTokens must be/);
59
+ const discovering: OpWavesSpec = { ...spec, plan: undefined, planWave: ["tg", "{runs}", "{plans}"], waves: [{ ...grouped, runs: [], discover: true, rest: true }] };
60
+ expect(() => assertOpWavesSpec(discovering)).toThrow(/wave "apply" has jobs and rest/);
61
+ expect(() => assertOpWavesSpec({ ...discovering, waves: [{ ...grouped, runs: [], discover: true }] })).not.toThrow();
62
+ });
63
+
64
+ test("a group's runs are targets or patterns: * within a segment, ** across", () => {
65
+ expect(opWaveRunMatches("live/prod/a", "live/prod/a")).toBe(true);
66
+ expect(opWaveRunMatches("live/*/a", "live/prod/a")).toBe(true);
67
+ expect(opWaveRunMatches("live/*", "live/prod/a")).toBe(false);
68
+ expect(opWaveRunMatches("live/**", "live/prod/a")).toBe(true);
69
+ expect(opWaveRunMatches("live.prod", "live-prod")).toBe(false);
70
+ expect(opWaveGroupRuns(grouped.jobs![1]!, grouped.runs).map((r) => r.target)).toEqual(["live/prod/a", "live/prod/b", "live/prod/c"]);
71
+ });
72
+
73
+ test("a wave with jobs is a deciding job and a job per group, or per share of a group; the next wave needs them all", () => {
74
+ const jobs = opWaveJobs(spec, "waves.json");
75
+ expect(jobs.map((j) => [j.jobName, j.kind, j.group, j.share, j.needs])).toEqual([
76
+ ["wave-1-first", "wave", undefined, undefined, []],
77
+ ["wave-2-apply-decide", "decide", undefined, undefined, ["wave-1-first"]],
78
+ ["wave-2-apply-dev", "share", "dev", undefined, ["wave-2-apply-decide"]],
79
+ ["wave-2-apply-prod-share-1", "share", "prod", 1, ["wave-2-apply-decide"]],
80
+ ["wave-2-apply-prod-share-2", "share", "prod", 2, ["wave-2-apply-decide"]],
81
+ ["wave-3-last", "wave", undefined, undefined, ["wave-2-apply-dev", "wave-2-apply-prod-share-1", "wave-2-apply-prod-share-2"]],
82
+ ]);
83
+ expect(jobs[2]!.command.join(" ")).toBe("chant run wave --spec waves.json --wave 2 --group dev");
84
+ expect(jobs[4]!.command.join(" ")).toBe("chant run wave --spec waves.json --wave 2 --group prod --share 2");
85
+ expect(jobs[2]!.record).toBe(".chant/op-waves/tf-apply/wave-2.json");
86
+ });
87
+ });
88
+
89
+ describe("chant run wave --group (#3914)", () => {
90
+ function harness(plans: Record<string, { planDigest: string; destructive?: boolean }>, atBase: OpWavesSpec = spec) {
91
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-3914-"));
92
+ const calls: string[] = [];
93
+ const exec: OpWaveExec = (argv, dir) => {
94
+ calls.push(argv.join(" "));
95
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify(plans[argv[2]!]));
96
+ return 0;
97
+ };
98
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(atBase) });
99
+ return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
100
+ }
101
+ const plans = () => ({
102
+ "live/dev/a": { planDigest: digest("1") },
103
+ "live/prod/a": { planDigest: digest("2") },
104
+ "live/prod/b": { planDigest: digest("3") },
105
+ "live/prod/c": { planDigest: digest("4") },
106
+ });
107
+ const setDigest = (p: Record<string, { planDigest: string }>) => waveSetDigest(Object.entries(p).map(([member, { planDigest }]) => ({ member, planDigest })));
108
+
109
+ test("one approval of the wave's digest lets every group's job apply its own runs, and nothing else", async () => {
110
+ const p = plans();
111
+ const h = harness(p);
112
+ const gates = memoryGateLedgerPort();
113
+ const opts = { spec, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, log: () => {} };
114
+ expect(await runOpWave({ ...opts, decide: true })).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", gate: "tf-apply-wave-2" } });
115
+ // Before the approval, every group's job waits and applies nothing.
116
+ expect(await runOpWave({ ...opts, group: "dev" })).toMatchObject({ exitCode: 3, applied: [] });
117
+ expect(await runOpWave({ ...opts, group: "prod", share: 1 })).toMatchObject({ exitCode: 3, applied: [] });
118
+ expect(h.calls.some((c) => c.startsWith("tool apply"))).toBe(false);
119
+
120
+ const approved = memoryGateLedgerPort({
121
+ resolutions: [{ version: 1, op: "tf-apply", gate: "tf-apply-wave-2", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: setDigest(p) }],
122
+ });
123
+ expect(await runOpWave({ ...opts, gates: approved, decide: true })).toMatchObject({ exitCode: 0, applied: [], decision: { status: "approved", approvedBy: "alice" } });
124
+ expect(await runOpWave({ ...opts, group: "dev" })).toMatchObject({ exitCode: 0, applied: ["live/dev/a"] });
125
+ expect(await runOpWave({ ...opts, group: "prod", share: 1 })).toMatchObject({ exitCode: 0, applied: ["live/prod/a", "live/prod/b"] });
126
+ expect(await runOpWave({ ...opts, group: "prod", share: 2 })).toMatchObject({ exitCode: 0, applied: ["live/prod/c"] });
127
+ h.done();
128
+ });
129
+
130
+ test("a group's job refuses a run whose plan moved since the decision (exit 4), as a share does", async () => {
131
+ const p = plans();
132
+ const never: OpWavesSpec = { ...spec, waves: spec.waves.map((w) => ({ ...w, gate: "never" as const })) };
133
+ const h = harness(p, never);
134
+ const opts = { spec: never, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} };
135
+ expect(await runOpWave({ ...opts, decide: true })).toMatchObject({ exitCode: 0, decision: { status: "not-required" } });
136
+ p["live/prod/b"] = { planDigest: digest("9") };
137
+ expect(await runOpWave({ ...opts, group: "prod", share: 1 })).toMatchObject({ exitCode: 4, applied: ["live/prod/a"], moved: ["live/prod/b"] });
138
+ h.done();
139
+ });
140
+
141
+ test("a group's job needs its deciding job, a group the wave has, and --group with --share on a grouped wave", async () => {
142
+ const h = harness(plans());
143
+ const opts = { spec, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} };
144
+ await expect(runOpWave({ ...opts, group: "dev" })).rejects.toThrow(/job group "dev" of wave 2 \(apply\) found no decision/);
145
+ await expect(runOpWave({ ...opts, group: "eu" })).rejects.toThrow(/has the job groups dev, prod; there is no group "eu"/);
146
+ await expect(runOpWave({ ...opts, share: 1 })).rejects.toThrow(/is split into job groups; name the group a share belongs to with --group/);
147
+ await expect(runOpWave({ ...opts, group: "dev", share: 2 })).rejects.toThrow(/job group "dev" of wave 2 \(apply\) has 1 share; there is no share 2/);
148
+ h.done();
149
+ });
150
+
151
+ describe("a discovering wave", () => {
152
+ const tg: OpWavesSpec = {
153
+ name: "tf-apply",
154
+ op: "tf-apply",
155
+ planWave: ["tg", "plan", "{runs}", "{plans}"],
156
+ applyWave: ["tg", "apply", "{runs}"],
157
+ waves: [{ name: "layer", runs: [], discover: true, gate: "never", jobs: [{ name: "dev", runs: ["dev/**"] }, { name: "prod", runs: ["prod/**"] }] }],
158
+ };
159
+ function discover(found: Record<string, { planDigest: string }>) {
160
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-3914d-"));
161
+ const applied: string[][] = [];
162
+ const exec: OpWaveExec = (argv, dir) => {
163
+ const runs = JSON.parse(readFileSync(join(dir, argv[2]!), "utf-8")) as OpWaveRunsFile;
164
+ if (argv[1] === "plan") {
165
+ const entries = runs.discover ? Object.entries(found).map(([target, p]) => ({ target, ...p })) : runs.runs.map((r) => ({ target: r.target, ...found[r.target] }));
166
+ writeFileSync(join(dir, argv[3]!), JSON.stringify({ runs: entries }));
167
+ }
168
+ if (argv[1] === "apply") applied.push(runs.runs.map((r) => r.target));
169
+ return 0;
170
+ };
171
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(tg) });
172
+ const opts = { spec: tg, specFile: "waves.json", wave: 1, cwd, exec, show, gates: memoryGateLedgerPort(), log: () => {} };
173
+ return { opts, applied, done: () => rmSync(cwd, { recursive: true, force: true }) };
174
+ }
175
+
176
+ test("each group's job applies the runs found that it holds; a group that holds none applies nothing", async () => {
177
+ const d = discover({ "dev/a": { planDigest: digest("1") }, "dev/b": { planDigest: digest("2") } });
178
+ expect(await runOpWave({ ...d.opts, decide: true })).toMatchObject({ exitCode: 0, applied: [] });
179
+ expect(await runOpWave({ ...d.opts, group: "dev" })).toMatchObject({ exitCode: 0, applied: ["dev/a", "dev/b"] });
180
+ expect(await runOpWave({ ...d.opts, group: "prod" })).toMatchObject({ exitCode: 0, applied: [] });
181
+ expect(d.applied).toEqual([["dev/a", "dev/b"]]);
182
+ d.done();
183
+ });
184
+
185
+ test("a run found in no group fails the deciding job, which names it, before the gate", async () => {
186
+ const d = discover({ "dev/a": { planDigest: digest("1") }, "stage/a": { planDigest: digest("2") } });
187
+ const lines: string[] = [];
188
+ const result = await runOpWave({ ...d.opts, decide: true, log: (l) => lines.push(l) });
189
+ expect(result.exitCode).toBe(1);
190
+ expect(lines.join("\n")).toMatch(/wave "layer" jobs: run "stage\/a" is in no job group/);
191
+ d.done();
192
+ });
193
+ });
194
+ });
195
+
196
+ describe("a job group's notify (#3914)", () => {
197
+ const notifying: OpWavesSpec = { ...spec, notify: { webhook: "CHANT_WEBHOOK", key: "CHANT_WEBHOOK_KEY" } };
198
+ const decision = { wave: 2, name: "apply", op: "tf-apply", gate: "tf-apply-wave-2", digest: digest("a"), members: [] } as never;
199
+ test("a group's wait was announced by its deciding job; a moved run names the group", () => {
200
+ const waiting = { exitCode: 3, applied: [], failed: [], moved: [], hooks: [], decision };
201
+ expect(opWaveNotifyFields(notifying, 2, { result: waiting }, { group: "prod", share: 1 })).toBeUndefined();
202
+ expect(opWaveNotifyFields(notifying, 2, { result: waiting })).toMatchObject({ event: "waiting" });
203
+ const moved = { exitCode: 4, applied: [], failed: [], moved: ["live/prod/b"], hooks: [], decision };
204
+ expect(opWaveNotifyFields(notifying, 2, { result: moved }, { group: "prod", share: 1 })!.summary).toBe("wave 2 (apply) of tf-apply, share 1 of job group prod, refused 1 run whose plan moved since the wave was decided");
205
+ expect(opWaveNotifyFields(notifying, 2, { result: moved }, { group: "dev" })!.summary).toBe("wave 2 (apply) of tf-apply, job group dev, refused 1 run whose plan moved since the wave was decided");
206
+ expect(opWaveNotifyFields(notifying, 2, { result: moved }, { share: 2 })!.summary).toBe("wave 2 (apply) of tf-apply, share 2, refused 1 run whose plan moved since the wave was decided");
207
+ });
208
+ });
209
+
210
+ test("chant run wave parses --group <name> (#3914)", () => {
211
+ expect(parseArgs(["run", "wave", "--spec", "w.json", "--wave", "2", "--group", "prod", "--share", "1"])).toMatchObject({ wave: 2, group: "prod", share: 1 });
212
+ expect(() => parseArgs(["run", "wave", "--group"])).toThrow(/--group needs a job group's name/);
213
+ });
@@ -22,7 +22,7 @@ import type { RunOpWaveResult } from "./op-waves-run";
22
22
  export type OpWaveOutcome = { result: RunOpWaveResult; error?: undefined } | { result?: undefined; error: string };
23
23
 
24
24
  /** The event a wave job sends, or undefined when it sends none. */
25
- export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWaveOutcome, job: { share?: number } = {}): NotifyFields | undefined {
25
+ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWaveOutcome, job: { share?: number; group?: string } = {}): NotifyFields | undefined {
26
26
  const wave = spec.waves[k - 1];
27
27
  const name = wave?.name;
28
28
  const label = `wave ${k}${name ? ` (${name})` : ""} of ${spec.name}`;
@@ -39,9 +39,9 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
39
39
  };
40
40
  if (result.exitCode === 3) {
41
41
  if (!decision) return undefined;
42
- // A share job's wait was announced by its deciding job, unless a hook in this job held it (#3762).
42
+ // A share job's wait (or a job group's, #3914) was announced by its deciding job, unless a hook in this job held it (#3762).
43
43
  const heldHere = result.hooks.filter((h) => h.outcome === "held");
44
- if (job.share !== undefined && heldHere.length === 0) return undefined;
44
+ if ((job.share !== undefined || job.group !== undefined) && heldHere.length === 0) return undefined;
45
45
  const review = decision.review?.pr !== undefined && decision.review.url ? { review: { pr: decision.review.pr, url: decision.review.url } } : {};
46
46
  return {
47
47
  ...base,
@@ -102,7 +102,7 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
102
102
  ...base,
103
103
  event: "refused",
104
104
  status: "moved",
105
- summary: `${label}${job.share !== undefined ? `, share ${job.share},` : ""} refused ${result.moved.length} run${result.moved.length === 1 ? "" : "s"} whose plan moved since the wave was decided`,
105
+ summary: `${label}${jobPart(job)} refused ${result.moved.length} run${result.moved.length === 1 ? "" : "s"} whose plan moved since the wave was decided`,
106
106
  targets: result.moved,
107
107
  reason: "their plans moved since the wave was decided, so they were not applied",
108
108
  };
@@ -124,12 +124,18 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
124
124
  return undefined;
125
125
  }
126
126
 
127
+ /** `, share 2,`, `, job group prod,` or `, share 2 of job group prod,`; empty for a wave's own job. */
128
+ function jobPart(job: { share?: number; group?: string }): string {
129
+ if (job.group !== undefined) return `, ${job.share !== undefined ? `share ${job.share} of ` : ""}job group ${job.group},`;
130
+ return job.share !== undefined ? `, share ${job.share},` : "";
131
+ }
132
+
127
133
  /** Send what the job's outcome calls for to what `spec.notify` names. Never throws. */
128
134
  export async function notifyOpWave(
129
135
  spec: OpWavesSpec,
130
136
  k: number,
131
137
  outcome: OpWaveOutcome,
132
- job: { share?: number } = {},
138
+ job: { share?: number; group?: string } = {},
133
139
  options: SendNotifyOptions = {},
134
140
  ): Promise<NotifyDelivery[]> {
135
141
  // A spec whose notify the run refused (it may name a URL in place of a variable) sends nothing.
@@ -28,7 +28,8 @@
28
28
  * - Apply: each run's `apply` command, or one `applyWave` call for the
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
- * not the one the deciding job recorded (exit 4).
31
+ * not the one the deciding job recorded (exit 4). A job group's job
32
+ * (`--group <name>`, #3914) is a share job over the group's runs.
32
33
  * - Hooks file (#3804): every apply call gets `CHANT_HOOKS`, a JSON file
33
34
  * ({@link OpWaveHooksFile}) with the outcomes of the hooks that already ran
34
35
  * and whether an approval stands. Under `hooks.apply.runBy: "apply"` (read
@@ -83,6 +84,8 @@ import {
83
84
  opWavePointAt,
84
85
  opWavePointProblems,
85
86
  opWaveRecordPath,
87
+ opWaveGroupRuns,
88
+ opWaveGroupsProblem,
86
89
  opWaveShare,
87
90
  type GateApprovalSource,
88
91
  type OpWave,
@@ -582,8 +585,13 @@ export interface RunOpWaveOptions {
582
585
  wave: number;
583
586
  /** Plan and decide only (a wide wave's deciding job). */
584
587
  decide?: boolean;
585
- /** Apply this 1-based share of a decided wave. */
588
+ /** Apply this 1-based share of a decided wave, or with `group`, of that job group. */
586
589
  share?: number;
590
+ /**
591
+ * Apply this job group's runs of a decided wave (#3914), as a share job
592
+ * does: plan them again and apply those whose plan has not moved.
593
+ */
594
+ group?: string;
587
595
  /** Overrides the spec's base. */
588
596
  base?: string;
589
597
  cwd?: string;
@@ -1268,7 +1276,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
1268
1276
  applied.push(...outcome.applied);
1269
1277
  // #3821: a `rest` wave goes on through the waves its plan command found.
1270
1278
  const found = outcome.decision?.discovered?.waves;
1271
- const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && !options.decide && outcome.exitCode === 0 && found !== undefined && k < found;
1279
+ const onward = k >= spec.waves.length && last.rest === true && options.share === undefined && options.group === undefined && !options.decide && outcome.exitCode === 0 && found !== undefined && k < found;
1272
1280
  if (rest.length > 0 || onward) rest.push({ wave: k, exitCode: outcome.exitCode, ...(outcome.decision ? { digest: outcome.decision.digest } : {}) });
1273
1281
  if (onward) log(`wave ${k} applied; the plan found ${found} waves, so this job goes on to wave ${k + 1}`);
1274
1282
  return onward;
@@ -1277,6 +1285,12 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
1277
1285
  return { ...result, applied, ...(rest.length > 0 ? { rest } : {}) };
1278
1286
  }
1279
1287
 
1288
+ /** What a share or job group's job calls itself in the log: `share 2`, `job group "prod"`, `share 2 of job group "prod"`. */
1289
+ function shareLabel(options: Pick<RunOpWaveOptions, "share" | "group">): string {
1290
+ if (options.group === undefined) return `share ${options.share}`;
1291
+ return `${options.share !== undefined ? `share ${options.share} of ` : ""}job group "${options.group}"`;
1292
+ }
1293
+
1280
1294
  /** How many times one wave job plans again when its apply asks to (#3821). */
1281
1295
  export const OP_WAVE_MAX_REPLANS = 10;
1282
1296
 
@@ -1337,21 +1351,31 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
1337
1351
  // #3821: past the spec's waves, a `rest` wave stands for each wave its plan command found.
1338
1352
  let wave = spec.waves[k - 1] ?? (last.rest ? last : undefined);
1339
1353
  if (!wave) throw new Error(`Op waves "${spec.name}" has ${spec.waves.length} waves; there is no wave ${k}.`);
1340
- const shares = wave.shares ?? 1;
1354
+ // #3914: a job group's job is a share job over the group's runs, split by the group's own shares.
1355
+ const group = options.group === undefined ? undefined : wave.jobs?.find((g) => g.name === options.group);
1356
+ if (options.group !== undefined && !group) {
1357
+ const names = (wave.jobs ?? []).map((g) => g.name);
1358
+ throw new Error(`wave ${k} (${wave.name}) has ${names.length > 0 ? `the job groups ${names.join(", ")}` : "no job groups"}; there is no group "${options.group}".`);
1359
+ }
1360
+ if (options.share !== undefined && options.group === undefined && wave.jobs !== undefined) {
1361
+ throw new Error(`wave ${k} (${wave.name}) is split into job groups; name the group a share belongs to with --group.`);
1362
+ }
1363
+ const shares = group ? (group.shares ?? 1) : (wave.shares ?? 1);
1341
1364
  if (options.share !== undefined && (options.share < 1 || options.share > shares)) {
1342
- throw new Error(`wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
1365
+ throw new Error(`${group ? `job group "${group.name}" of ` : ""}wave ${k} (${wave.name}) has ${shares} share${shares === 1 ? "" : "s"}; there is no share ${options.share}.`);
1343
1366
  }
1367
+ const part = shareLabel(options);
1344
1368
  const recordPath = join(cwd, opWaveRecordPath(spec, k));
1345
1369
  const result: RunOpWaveResult = { exitCode: 0, applied: [], failed: [], moved: [], hooks: [] };
1346
1370
  const ctx: WaveContext = { spec, k, wave, cwd, exec, log, result, options, gateRule: { approval: "ledger", base: "HEAD", neverOverMcp: spec.neverOverMcp === true } };
1347
1371
  if (pass > 0) log(`wave ${k} (${wave.name}): plan ${pass + 1}`);
1348
1372
 
1349
- if (options.share !== undefined) {
1373
+ if (options.share !== undefined || group !== undefined) {
1350
1374
  let decision: OpWaveDecision;
1351
1375
  try {
1352
1376
  decision = JSON.parse(readFileSync(recordPath, "utf-8")) as OpWaveDecision;
1353
1377
  } catch {
1354
- throw new Error(`share ${options.share} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
1378
+ throw new Error(`${part} of wave ${k} (${wave.name}) found no decision at ${opWaveRecordPath(spec, k)}. Run the deciding job first.`);
1355
1379
  }
1356
1380
  result.decision = decision;
1357
1381
  ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
@@ -1365,14 +1389,18 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
1365
1389
  wave = { ...wave, runs: decision.members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
1366
1390
  ctx.wave = wave;
1367
1391
  }
1368
- const slice = opWaveShare(wave.runs, options.share, shares);
1392
+ const slice = opWaveShare(group ? opWaveGroupRuns(group, wave.runs) : wave.runs, options.share ?? 1, shares);
1393
+ if (group && slice.length === 0) {
1394
+ log(`${part} of wave ${k} (${wave.name}) holds none of the wave's runs; nothing to apply`);
1395
+ return { end: result };
1396
+ }
1369
1397
  const before = await runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
1370
- if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`) };
1398
+ if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in ${part} of wave ${k} was planned or applied`) };
1371
1399
  if (before.waitingOn) return { end: await waitOnOther(ctx, decision, before.waitingOn) };
1372
1400
  const { members } = planRuns(ctx, slice);
1373
1401
  const planned = new Map(members.map((m) => [m.member, m]));
1374
1402
  const after = await runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
1375
- if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`) };
1403
+ if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in ${part} of wave ${k} was applied`) };
1376
1404
  if (after.waitingOn) return { end: await waitOnOther(ctx, decision, after.waitingOn) };
1377
1405
  return { go: { kind: "share", ctx, decision, slice, planned, holds: [...before.holds, ...after.holds] } };
1378
1406
  }
@@ -1424,6 +1452,9 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
1424
1452
  ctx.wave = wave;
1425
1453
  decision.discovered = { ...(found !== undefined ? { waves: found } : {}) };
1426
1454
  log(`wave ${k} (${wave.name}): the plan found ${members.length} run${members.length === 1 ? "" : "s"}${found !== undefined ? ` of ${found} waves` : ""}${members.length ? `: ${members.map((m) => m.member).join(", ")}` : ""}`);
1455
+ // #3914: each run found is in exactly one job group, or no group's job could apply it.
1456
+ const problem = wave.jobs ? opWaveGroupsProblem(wave, members.map((m) => m.member)) : undefined;
1457
+ if (problem) return { end: failed(ctx, `wave ${k} (${wave.name}): ${problem}, so nothing in it was applied`) };
1427
1458
  }
1428
1459
  const planned = new Map(members.map((m) => [m.member, m]));
1429
1460
  return {
@@ -1580,7 +1611,7 @@ function markHeldOn(decision: OpWaveDecision, heldOn: OpWaveHeldOn): void {
1580
1611
  /** A share job held on another gate that waits (#3821). */
1581
1612
  async function waitOnOther(ctx: WaveContext, decision: OpWaveDecision, heldOn: OpWaveHeldOn): Promise<RunOpWaveResult> {
1582
1613
  markHeldOn(decision, heldOn);
1583
- 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}`);
1614
+ ctx.log(`${shareLabel(ctx.options)} of wave ${ctx.k} (${ctx.wave.name}) waits on ${heldOn.op} / ${heldOn.gate}; nothing in it applied. Approve: ${heldOn.approve}`);
1584
1615
  return { ...ctx.result, decision, exitCode: 3 };
1585
1616
  }
1586
1617