@intentius/chant 0.124.1 → 0.124.3

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 (80) hide show
  1. package/dist/cli/handlers/override-approve.d.ts +3 -1
  2. package/dist/cli/handlers/override-approve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/workspace-tools.d.ts +5 -1
  5. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +2 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/lifecycle/gate-audit.d.ts +37 -13
  9. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  10. package/dist/lifecycle/gate-ledger.d.ts +1 -33
  11. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  12. package/dist/lifecycle/gate-origin.d.ts +14 -2
  13. package/dist/lifecycle/gate-origin.d.ts.map +1 -1
  14. package/dist/lifecycle/gate-pending.d.ts +41 -0
  15. package/dist/lifecycle/gate-pending.d.ts.map +1 -0
  16. package/dist/op/activities/activity-contracts.d.ts +1 -0
  17. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  18. package/dist/op/activities/decide.d.ts +2 -0
  19. package/dist/op/activities/decide.d.ts.map +1 -1
  20. package/dist/op/gate-point-run.d.ts +5 -77
  21. package/dist/op/gate-point-run.d.ts.map +1 -1
  22. package/dist/op/gate-point.d.ts +18 -0
  23. package/dist/op/gate-point.d.ts.map +1 -1
  24. package/dist/op/gate.d.ts +8 -0
  25. package/dist/op/gate.d.ts.map +1 -1
  26. package/dist/op/index.d.ts +4 -4
  27. package/dist/op/index.d.ts.map +1 -1
  28. package/dist/op/op-waves-run.d.ts +37 -6
  29. package/dist/op/op-waves-run.d.ts.map +1 -1
  30. package/dist/op/op-waves.d.ts +38 -4
  31. package/dist/op/op-waves.d.ts.map +1 -1
  32. package/dist/op/steward-points.d.ts +7 -0
  33. package/dist/op/steward-points.d.ts.map +1 -1
  34. package/dist/override-gate.d.ts +8 -2
  35. package/dist/override-gate.d.ts.map +1 -1
  36. package/dist/point-decision.d.ts +134 -0
  37. package/dist/point-decision.d.ts.map +1 -0
  38. package/dist/pr-forge.d.ts +37 -2
  39. package/dist/pr-forge.d.ts.map +1 -1
  40. package/dist/workspace/decide.d.ts +14 -2
  41. package/dist/workspace/decide.d.ts.map +1 -1
  42. package/dist/workspace/reason-codes.d.ts +2 -1
  43. package/dist/workspace/reason-codes.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/handlers/override-approve.test.ts +16 -0
  46. package/src/cli/handlers/override-approve.ts +6 -2
  47. package/src/cli/main.test.ts +3 -0
  48. package/src/cli/main.ts +9 -2
  49. package/src/cli/mcp/workspace-tools.ts +7 -3
  50. package/src/cli/registry.ts +2 -0
  51. package/src/lifecycle/gate-audit.test.ts +42 -0
  52. package/src/lifecycle/gate-audit.ts +102 -34
  53. package/src/lifecycle/gate-ledger.ts +3 -53
  54. package/src/lifecycle/gate-origin.ts +20 -3
  55. package/src/lifecycle/gate-pending.ts +62 -0
  56. package/src/op/activities/activity-contracts.ts +1 -0
  57. package/src/op/activities/decide.ts +3 -0
  58. package/src/op/gate-point-ci.e2e.test.ts +171 -0
  59. package/src/op/gate-point-mcp.e2e.test.ts +163 -0
  60. package/src/op/gate-point-run.ts +18 -149
  61. package/src/op/gate-point.e2e.test.ts +2 -0
  62. package/src/op/gate-point.test.ts +1 -1
  63. package/src/op/gate-point.ts +27 -3
  64. package/src/op/gate.ts +10 -0
  65. package/src/op/index.ts +10 -1
  66. package/src/op/op-waves-run.ts +84 -19
  67. package/src/op/op-waves.test.ts +96 -0
  68. package/src/op/op-waves.ts +60 -4
  69. package/src/op/steward-points.ts +8 -0
  70. package/src/override-gate.test.ts +5 -0
  71. package/src/override-gate.ts +8 -2
  72. package/src/point-decision-bundle.test.ts +62 -0
  73. package/src/point-decision.e2e.test.ts +95 -0
  74. package/src/point-decision.ts +280 -0
  75. package/src/pr-forge.test.ts +62 -0
  76. package/src/pr-forge.ts +60 -9
  77. package/src/workspace/decide.ts +92 -8
  78. package/src/workspace/points-cli.ts +1 -1
  79. package/src/workspace/points-write.schema.json +1 -0
  80. package/src/workspace/reason-codes.ts +2 -1
@@ -25,51 +25,28 @@
25
25
  * and, when the point declares them, in its `gate.*` inputs.
26
26
  */
27
27
 
28
- import { isPendingGateExpired, latestPendingGate, DEFAULT_GATE_EXPIRY, resolveApprovalUrl, type GateAnswerRef, type GateResolutionRecord, type PendingGateRecord } from "../lifecycle/gate-ledger";
29
- import { samePlanDigest } from "../lifecycle/plan-digest";
30
- import { parseDuration } from "./duration";
31
- import type { GateLedgerPort } from "./gate";
32
- import { DEFAULT_GATE_POINT_PASS, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts } from "./gate-point";
33
- import { isPointWait, type WaitingPoint } from "./steward-points";
28
+ import { defaultGatePointStore, gatePointInputs } from "./gate-point";
29
+ import { isPointWait } from "./steward-points";
30
+ import type { GatePointAsker } from "../point-decision";
34
31
 
35
- /** The question a gate's point was asked, as the gate reads it. */
36
- export interface GatePointQuestion {
37
- /** The answer record's id: what `points answer` takes. */
38
- id: string;
39
- point: string;
40
- /** The record, from the repository root. */
41
- path: string;
42
- state: "escalated" | "proposed" | "answered";
43
- /** The answer, or null while the question is open. */
44
- answer: string | boolean | null;
45
- /** The decider that gave the recorded state: table, model or quorum. */
46
- decider: string;
47
- answeredBy: string[];
48
- subject: string | null;
49
- /** The steward whose turn asked it, or null. */
50
- steward: string | null;
51
- }
52
-
53
- /** What a gate asks its point with. */
54
- export interface GatePointRequest {
55
- point: string;
56
- /** The authored inputs, references resolved. */
57
- inputs: Record<string, unknown>;
58
- facts: GatePointFacts;
59
- /** What the answer record's `constrains` names: {@link gateSubject}. */
60
- subject: string;
61
- }
62
-
63
- /** How a gate asks its point. A test passes a stub; {@link workspaceGatePointAsker} is the real one. */
64
- export interface GatePointAsker {
65
- ask(request: GatePointRequest): Promise<GatePointQuestion>;
66
- }
32
+ // The decision itself bundles without the workspace (#3816): `@intentius/chant/point-decision`.
33
+ export {
34
+ evaluatePointGate,
35
+ type GatePointAsker,
36
+ type GatePointQuestion,
37
+ type GatePointRequest,
38
+ type PointGateCheck,
39
+ type PointGateInput,
40
+ type PointGateLedgerPort,
41
+ } from "../point-decision";
67
42
 
68
43
  /**
69
44
  * The real asker: the workspace that holds `cwd`. It reads the point's
70
45
  * declared inputs from `points`, adds the `gate.*` ones the point declares,
71
46
  * and asks through the decide activity. A point with no model decider is
72
- * asked with no backends, so the run needs no `decide.backends`.
47
+ * asked with no backends, so the run needs no `decide.backends`. A question
48
+ * goes where the gate's `point.store` says, and to the ledger in a CI job
49
+ * when it says nothing (`defaultGatePointStore`, #3815).
73
50
  */
74
51
  export function workspaceGatePointAsker(cwd: string): GatePointAsker {
75
52
  return {
@@ -86,7 +63,8 @@ export function workspaceGatePointAsker(cwd: string): GatePointAsker {
86
63
  const { runDecide } = await import("./activities/decide");
87
64
  const asksModel = point.deciders.some((d) => d.kind === "model");
88
65
  try {
89
- const r = await runDecide({ point: request.point, inputs, subject: request.subject, cwd, ...(asksModel ? {} : { backends: {} }) });
66
+ const store = request.store ?? defaultGatePointStore();
67
+ const r = await runDecide({ point: request.point, inputs, subject: request.subject, cwd, ...(store ? { store } : {}), ...(asksModel ? {} : { backends: {} }) });
90
68
  return { id: r.id, point: request.point, path: r.path, state: r.state, answer: r.answer, decider: r.decider, answeredBy: r.answeredBy, subject: request.subject, steward: null };
91
69
  } catch (err) {
92
70
  if (!isPointWait(err)) throw err;
@@ -96,112 +74,3 @@ export function workspaceGatePointAsker(cwd: string): GatePointAsker {
96
74
  },
97
75
  };
98
76
  }
99
-
100
- /** What the run needs to decide a gate that asks a point. */
101
- export interface PointGateInput {
102
- op: string;
103
- gate: string;
104
- point: GatePoint;
105
- /** The authored inputs, references resolved. */
106
- inputs?: Record<string, unknown>;
107
- description?: string;
108
- timeout?: string;
109
- runId?: string;
110
- planDigest?: string;
111
- /** The environment the run was started for: the `gate.env` input. Op gate facts on the ledger record none. */
112
- env?: string;
113
- now?: string;
114
- }
115
-
116
- /** What reaching a gate that asks a point decided. */
117
- export type PointGateCheck =
118
- | {
119
- satisfied: true;
120
- /** The resolution that cites the answer: this run's, or one an earlier run wrote for the same answer and plan. */
121
- resolution: GateResolutionRecord;
122
- answer: GateAnswerRef;
123
- /** Whether this run appended the resolution. */
124
- recorded: boolean;
125
- pushed?: boolean;
126
- pushWarning?: string;
127
- }
128
- | {
129
- satisfied: false;
130
- /** The question is answered, with an answer the gate does not pass on. */
131
- refused: string;
132
- answer: GateAnswerRef;
133
- }
134
- | {
135
- satisfied: false;
136
- /** The open question the run waits on. */
137
- waiting: WaitingPoint;
138
- /** The pending fact that cites it. */
139
- pending: PendingGateRecord;
140
- recorded: boolean;
141
- pushed?: boolean;
142
- pushWarning?: string;
143
- };
144
-
145
- /** Who a resolution written for an answer is by: the people who answered, or the decider that did. */
146
- function resolvedByOf(q: GatePointQuestion): string {
147
- return q.answeredBy.length > 0 ? q.answeredBy.join(", ") : q.decider;
148
- }
149
-
150
- /** Decide a gate that asks a point: ask it, then record the pending fact or the resolution that cites the answer. */
151
- export async function evaluatePointGate(port: GateLedgerPort, asker: GatePointAsker, input: PointGateInput): Promise<PointGateCheck> {
152
- const now = input.now ?? new Date().toISOString();
153
- const facts: GatePointFacts = { component: input.op, name: input.gate, env: input.env ?? null, planDigest: input.planDigest ?? null };
154
- const q = await asker.ask({ point: input.point.name, inputs: input.inputs ?? {}, facts, subject: gateSubject(input.op, input.gate, input.env) });
155
- const ref: GateAnswerRef = { point: q.point, id: q.id, path: q.path };
156
-
157
- if (q.state === "answered" && q.answer !== null) {
158
- const answer: GateAnswerRef = { ...ref, answer: q.answer, decider: q.decider, ...(q.answeredBy.length > 0 ? { answeredBy: q.answeredBy } : {}) };
159
- const pass = input.point.pass ?? DEFAULT_GATE_POINT_PASS;
160
- if (!pass.includes(q.answer)) {
161
- return {
162
- satisfied: false,
163
- answer,
164
- refused:
165
- `decision point ${q.point} answered ${JSON.stringify(q.answer)} (${q.id}, by ${resolvedByOf(q)}), and gate "${input.gate}" passes only on ${pass.map((a) => JSON.stringify(a)).join(" or ")}. ` +
166
- `To ask again, take the answer back: chant workspace points retract ${q.id} --by <name>`,
167
- };
168
- }
169
- const ledger = await port.read(input.op);
170
- const standing = ledger.resolutions.find(
171
- (r) => r.gate === input.gate && r.answer?.id === q.id && r.environment === undefined && samePlanDigest(r.planDigest, input.planDigest),
172
- );
173
- if (standing) return { satisfied: true, resolution: standing, answer, recorded: false };
174
- if (!port.appendResolution) throw new Error(`gate "${input.gate}" asks decision point ${q.point}, and this gate ledger cannot record the resolution that cites its answer`);
175
- const { record, pushed, pushWarning } = await port.appendResolution({
176
- op: input.op,
177
- gate: input.gate,
178
- resolvedBy: resolvedByOf(q),
179
- timestamp: now,
180
- note: `answered through decision point ${q.point} (${q.id})`,
181
- ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
182
- approver: { kind: q.decider === "quorum" || q.answeredBy.length > 0 ? "human" : "agent" },
183
- answer,
184
- });
185
- return { satisfied: true, resolution: record, answer, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
186
- }
187
-
188
- const waiting: WaitingPoint = { id: q.id, point: q.point, state: q.state === "proposed" ? "proposed" : "escalated", path: q.path, subject: q.subject, steward: q.steward };
189
- const ledger = await port.read(input.op);
190
- const standing = latestPendingGate(ledger.pending.filter((p) => p.environment === undefined), input.gate);
191
- if (standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) && standing.answer?.id === q.id) {
192
- return { satisfied: false, waiting, pending: standing, recorded: false };
193
- }
194
- const url = resolveApprovalUrl();
195
- const { record, pushed, pushWarning } = await port.appendPending({
196
- op: input.op,
197
- gate: input.gate,
198
- timestamp: now,
199
- expiresAt: new Date(new Date(now).getTime() + parseDuration(input.timeout ?? DEFAULT_GATE_EXPIRY)).toISOString(),
200
- ...(input.description ? { description: input.description } : {}),
201
- ...(input.runId ? { runId: input.runId } : {}),
202
- ...(url ? { url } : {}),
203
- ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
204
- answer: ref,
205
- });
206
- return { satisfied: false, waiting, pending: record, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
207
- }
@@ -75,6 +75,8 @@ async function pointsAnswer(id: string, answer: string, by: string): Promise<Rec
75
75
 
76
76
  beforeAll(() => {
77
77
  root = workspace(POINTS);
78
+ // The questions go to the working tree, as outside CI: a CI job asks into the ledger by default (#3815).
79
+ vi.stubEnv("CI", "");
78
80
  });
79
81
 
80
82
  afterAll(cleanScratch);
@@ -61,7 +61,7 @@ describe("a gate's point declaration (#3170)", () => {
61
61
  expect(() => gate("apply", { point: "Prod Apply" })).toThrow(/decision point's name/);
62
62
  expect(() => gate("apply", { point: { name: "prod-apply", inputs: { "gate.planDigest": PLAN } } })).toThrow(/which the gate passes itself/);
63
63
  expect(gatePointProblems({ name: "prod-apply", pass: [] })).toEqual(["`point.pass` is a non-empty list of the answers that pass the gate"]);
64
- expect(gatePointProblems({ name: "prod-apply", quorum: 2 })).toEqual(["`point` takes name, inputs and pass, not quorum"]);
64
+ expect(gatePointProblems({ name: "prod-apply", quorum: 2 })).toEqual(["`point` takes name, inputs, pass and store, not quorum"]);
65
65
  });
66
66
 
67
67
  test("the gate passes the gate.* inputs the point declares, beside the authored ones", () => {
@@ -31,6 +31,29 @@ export interface GatePoint {
31
31
  inputs?: Record<string, unknown>;
32
32
  /** The answers that pass the gate. Default `[true]`: a noul point's yes. Any other answer fails the step. */
33
33
  pass?: Array<string | boolean>;
34
+ /**
35
+ * Where the question goes when the run asks it (#3815): `"tree"`, the
36
+ * answer kind's directory in the working tree, or `"ledger"`,
37
+ * `chant/lifecycle`, pushed, so a person can answer it after a checkout
38
+ * that does not outlive the run is gone. Default: the ledger in a steward's
39
+ * turn or a CI job (`CI` set), and the tree otherwise.
40
+ */
41
+ store?: GatePointStore;
42
+ }
43
+
44
+ /** Where a gate's question is held: the working tree or `chant/lifecycle`. */
45
+ export type GatePointStore = "tree" | "ledger";
46
+
47
+ /**
48
+ * Where a gate's question goes when its `point` names no `store` (#3815): the
49
+ * ledger in a CI job, whose checkout is thrown away when the job ends, so the
50
+ * question outlives it; otherwise undefined, and `points ask`'s own rule
51
+ * holds (the ledger in a steward's turn, the tree otherwise). A CI job is one
52
+ * whose environment sets `CI`, as GitHub Actions and GitLab CI do.
53
+ */
54
+ export function defaultGatePointStore(env: Readonly<Record<string, string | undefined>> = process.env): GatePointStore | undefined {
55
+ const ci = env.CI?.trim().toLowerCase();
56
+ return ci && ci !== "false" && ci !== "0" ? "ledger" : undefined;
34
57
  }
35
58
 
36
59
  /** The answers a gate passes on when it names none: a noul point's yes. */
@@ -49,8 +72,8 @@ export function gatePointProblems(point: unknown, withApproval = false): string[
49
72
  const problems: string[] = [];
50
73
  if (withApproval) problems.push("a gate that asks a decision point takes its quorum from the point: give `approval` or `point`, not both");
51
74
  const p = typeof point === "string" ? { name: point } : point;
52
- if (p === null || typeof p !== "object" || Array.isArray(p)) return [...problems, "`point` is a decision point's name or { name, inputs?, pass? }"];
53
- const { name, inputs, pass, ...rest } = p as Record<string, unknown>;
75
+ if (p === null || typeof p !== "object" || Array.isArray(p)) return [...problems, "`point` is a decision point's name or { name, inputs?, pass?, store? }"];
76
+ const { name, inputs, pass, store, ...rest } = p as Record<string, unknown>;
54
77
  if (typeof name !== "string" || !POINT_NAME.test(name)) problems.push("`point.name` is a decision point's name: lower case letters, digits and hyphens, starting with a letter");
55
78
  if (inputs !== undefined && (inputs === null || typeof inputs !== "object" || Array.isArray(inputs))) problems.push("`point.inputs` is an object of input name to value");
56
79
  if (inputs && typeof inputs === "object") {
@@ -60,8 +83,9 @@ export function gatePointProblems(point: unknown, withApproval = false): string[
60
83
  if (pass !== undefined && (!Array.isArray(pass) || pass.length === 0 || pass.some((a) => typeof a !== "string" && typeof a !== "boolean"))) {
61
84
  problems.push("`point.pass` is a non-empty list of the answers that pass the gate");
62
85
  }
86
+ if (store !== undefined && store !== "tree" && store !== "ledger") problems.push("`point.store` is tree or ledger: where the question is held");
63
87
  const extra = Object.keys(rest);
64
- if (extra.length > 0) problems.push(`\`point\` takes name, inputs and pass, not ${extra.join(", ")}`);
88
+ if (extra.length > 0) problems.push(`\`point\` takes name, inputs, pass and store, not ${extra.join(", ")}`);
65
89
  return problems;
66
90
  }
67
91
 
package/src/op/gate.ts CHANGED
@@ -260,6 +260,14 @@ export interface GateCheckInput {
260
260
  * stands as a mismatch. Absent, nothing is spent.
261
261
  */
262
262
  spent?: readonly AppliedGateRecord[];
263
+ /**
264
+ * Fields a caller adds to the pending fact it records (#3821), such as an
265
+ * Op waves product's commit and members. Every field this function writes
266
+ * itself wins over one of the same name.
267
+ */
268
+ fields?: Record<string, unknown>;
269
+ /** The pending fact it records carries `neverOverMcp` (#3821, chant#3485). */
270
+ neverOverMcp?: boolean;
263
271
  }
264
272
 
265
273
  /**
@@ -592,6 +600,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
592
600
  // #3683: in CI, where this job runs, so an approval can start it again.
593
601
  const resume = resolveGateRunLocator();
594
602
  const { record, pushed, pushWarning } = await port.appendPending({
603
+ ...(input.fields as Partial<PendingGateInput> | undefined),
595
604
  op: input.op,
596
605
  gate: input.gate,
597
606
  timestamp: now,
@@ -605,6 +614,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
605
614
  ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
606
615
  ...(env !== undefined ? { environment: env } : {}),
607
616
  ...(input.approval ? { approval: input.approval } : {}),
617
+ ...(input.neverOverMcp ? { neverOverMcp: true as const } : {}),
608
618
  });
609
619
  return {
610
620
  satisfied: false,
package/src/op/index.ts CHANGED
@@ -54,23 +54,27 @@ export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from ".
54
54
  export {
55
55
  OP_WAVE_APPLY_HOOKS_RUNNERS,
56
56
  OP_WAVE_DEFAULT_APPLY,
57
+ OP_WAVE_DEFAULT_GATE_NAME,
57
58
  OP_WAVE_GATE_POLICIES,
58
59
  OP_WAVE_HOOK_MOMENTS,
59
60
  assertOpWaveHooks,
60
61
  assertOpWavesSpec,
61
62
  opWaveApplyHooksRunBy,
63
+ opWaveGateName,
62
64
  opWaveHooksAt,
63
65
  opWaveJobs,
64
66
  opWaveRecordPath,
65
67
  opWaveShare,
66
68
  } from "./op-waves";
67
- export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
69
+ export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesDigest, OpWavesSpec } from "./op-waves";
68
70
  export {
69
71
  opWaveHooksAtBase,
70
72
  opWaveNeedsApproval,
71
73
  opWavePlansPath,
72
74
  opWaveRunsPath,
75
+ opWaveSetDigest,
73
76
  parseOpWavesSpec,
77
+ recordOpWaveHeadPlans,
74
78
  readOpWaveHooksFile,
75
79
  readOpWavePolicy,
76
80
  runOpWave,
@@ -80,6 +84,7 @@ export type {
80
84
  OpWaveBaseRules,
81
85
  OpWaveDecision,
82
86
  OpWaveApplyHooksRun,
87
+ OpWaveExec,
83
88
  OpWaveHookOutcome,
84
89
  OpWaveHookRun,
85
90
  OpWaveHooksFile,
@@ -87,7 +92,11 @@ export type {
87
92
  OpWaveMember,
88
93
  OpWavePlanFile,
89
94
  OpWavePlansFile,
95
+ OpWavePolicySource,
90
96
  OpWaveRunsFile,
97
+ OpWaveShowAtBase,
98
+ RecordHeadPlansOptions,
99
+ RunOpWaveApplyHooksOptions,
91
100
  RunOpWaveOptions,
92
101
  RunOpWaveResult,
93
102
  } from "./op-waves-run";
@@ -42,7 +42,7 @@
42
42
  import { execFileSync, spawnSync } from "node:child_process";
43
43
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
44
  import { dirname, isAbsolute, join, posix } from "node:path";
45
- import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
45
+ import { waveSetDigest, type WaveMember } from "../gated-waves";
46
46
  import type { ReviewApproval } from "../gate-decision";
47
47
  import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
48
48
  import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
@@ -56,6 +56,7 @@ import {
56
56
  assertOpWaveHooks,
57
57
  assertOpWavesSpec,
58
58
  opWaveApplyHooksRunBy,
59
+ opWaveGateName,
59
60
  opWaveHooksAt,
60
61
  opWaveRecordPath,
61
62
  opWaveShare,
@@ -81,8 +82,21 @@ export interface OpWavePlanFile {
81
82
  /** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
82
83
  export interface OpWavePlansFile {
83
84
  runs: Array<OpWavePlanFile & { target: string }>;
85
+ /**
86
+ * Fields the product adds to the wave's pending fact (#3821), such as the
87
+ * commit it planned or the members its own readers expect. chant's own
88
+ * fields win: a key chant writes (`op`, `gate`, `planDigest`, `timestamp`,
89
+ * ...) is refused, except `description`.
90
+ */
91
+ pending?: Record<string, unknown>;
84
92
  }
85
93
 
94
+ /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
95
+ export const OP_WAVE_PENDING_RESERVED = [
96
+ "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
97
+ "environment", "origin", "neverOverMcp", "approval", "answer",
98
+ ] as const;
99
+
86
100
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
87
101
  export interface OpWaveRunsFile {
88
102
  /** The wave's name. */
@@ -148,6 +162,12 @@ export interface OpWaveDecision {
148
162
  applyHooksRunBy?: "apply";
149
163
  /** Every hook's outcome so far in the job that wrote the record (#3804). */
150
164
  outcomes?: OpWaveHookOutcome[];
165
+ /** `changing`: the digest covers only the runs whose plan is not empty, as the spec's `digest.over` says (#3821). Absent: every run. */
166
+ digestOver?: "changing";
167
+ /** The fields the plans file added to the pending fact (#3821). */
168
+ pending?: Record<string, unknown>;
169
+ /** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
170
+ neverOverMcp?: true;
151
171
  }
152
172
 
153
173
  /** One hook's run (#3762). */
@@ -216,6 +236,18 @@ export interface OpWaveHooksResult {
216
236
  outcomes: OpWaveHookOutcome[];
217
237
  }
218
238
 
239
+ /** The options of {@link runOpWaveApplyHooks} (#3825). */
240
+ export interface RunOpWaveApplyHooksOptions {
241
+ /** Where the hooks run. Default `process.cwd()`. */
242
+ cwd?: string;
243
+ /** Runs one hook. Default: spawn it with the job's environment. */
244
+ exec?: OpWaveExec;
245
+ /** Default: stderr. */
246
+ log?: (line: string) => void;
247
+ /** Run nothing and record the moment as skipped, with this reason (#3811). */
248
+ skip?: string;
249
+ }
250
+
219
251
  /** One call of {@link runOpWaveApplyHooks}. */
220
252
  export interface OpWaveApplyHooksRun {
221
253
  moment: OpWaveHookMoment;
@@ -293,6 +325,8 @@ export interface OpWaveBaseRules {
293
325
  spend: boolean;
294
326
  /** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
295
327
  applyHooksRunBy: OpWaveApplyHooksRunner;
328
+ /** The spec at base sets `neverOverMcp` (#3821). */
329
+ neverOverMcp: boolean;
296
330
  }
297
331
 
298
332
  const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
@@ -313,7 +347,7 @@ export function readOpWavePolicy(
313
347
  show: OpWaveShowAtBase = gitShowAtBase,
314
348
  ): OpWaveBaseRules {
315
349
  const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
316
- const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
350
+ const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const, neverOverMcp: false };
317
351
  if (text === null) return absent;
318
352
  let spec: OpWavesSpec;
319
353
  try {
@@ -323,12 +357,13 @@ export function readOpWavePolicy(
323
357
  }
324
358
  // The spend rule is the spec's, so it holds for a wave the base does not have yet.
325
359
  const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
360
+ const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
326
361
  const wave = spec.waves.find((w) => w.name === name);
327
- if (!wave) return { ...absent, spend };
362
+ if (!wave) return { ...absent, spend, neverOverMcp };
328
363
  const policy = wave.gate ?? "always";
329
364
  // An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
330
365
  const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
331
- const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
366
+ const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
332
367
  try {
333
368
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
334
369
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
@@ -481,9 +516,9 @@ function writeRunsFile(
481
516
  * for all of them (#3762). A wave plan is written out as each run's plan file
482
517
  * too, so a per-run `apply` or hook reads `{plan}` either way.
483
518
  */
484
- function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): OpWaveMember[] {
519
+ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
485
520
  const { spec, k, wave, cwd, exec } = at;
486
- if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
521
+ if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
487
522
  const runsPath = writeRunsFile(at, runs);
488
523
  const plans = opWavePlansPath(spec, k);
489
524
  rmSync(join(cwd, plans), { force: true });
@@ -510,12 +545,40 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
510
545
  }
511
546
  const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
512
547
  if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
513
- return runs.map((run) => {
548
+ const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
549
+ const members = runs.map((run) => {
514
550
  const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
515
551
  const path = join(cwd, opWavePlanPath(spec, k, run.target));
516
552
  writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
517
553
  return m;
518
554
  });
555
+ return { members, ...(pending ? { pending } : {}) };
556
+ }
557
+
558
+ /** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
559
+ interface PlannedRuns {
560
+ members: OpWaveMember[];
561
+ pending?: Record<string, unknown>;
562
+ }
563
+
564
+ /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
565
+ function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
566
+ if (value === undefined) return undefined;
567
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
568
+ const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
569
+ if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
570
+ if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
571
+ return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
572
+ }
573
+
574
+ /**
575
+ * A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
576
+ * or under `digest.over: "changing"` over the runs whose plan is not empty,
577
+ * falling back to every run when all of them are empty.
578
+ */
579
+ export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>): string {
580
+ const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
581
+ return waveSetDigest(changing.length > 0 ? changing : members);
519
582
  }
520
583
 
521
584
  /** What one moment's hooks came to. */
@@ -647,11 +710,7 @@ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT
647
710
  * chant counts the moment as handled. A skip without a reason throws. `exec` runs one hook; inject it to run a hook
648
711
  * in process or to keep its output.
649
712
  */
650
- export function runOpWaveApplyHooks(
651
- file: OpWaveHooksFile,
652
- moment: OpWaveHookMoment,
653
- options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void; skip?: string } = {},
654
- ): OpWaveApplyHooksRun {
713
+ export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMoment, options: RunOpWaveApplyHooksOptions = {}): OpWaveApplyHooksRun {
655
714
  const cwd = options.cwd ?? process.cwd();
656
715
  const log = options.log ?? ((line: string) => console.error(line));
657
716
  let run: OpWaveApplyHooksRun;
@@ -786,7 +845,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
786
845
  const slice = opWaveShare(wave.runs, options.share, shares);
787
846
  const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
788
847
  if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`);
789
- const members = planRuns(ctx, slice);
848
+ const { members } = planRuns(ctx, slice);
790
849
  const planned = new Map(members.map((m) => [m.member, m]));
791
850
  const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
792
851
  if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
@@ -820,13 +879,14 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
820
879
  }
821
880
  const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
822
881
  if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
823
- const members = planRuns(ctx, wave.runs);
882
+ const { members, pending } = planRuns(ctx, wave.runs);
824
883
  const planned = new Map(members.map((m) => [m.member, m]));
825
- const digest = waveSetDigest(members);
884
+ const digest = opWaveSetDigest(spec, members);
885
+ const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
826
886
  const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
827
887
  if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
828
888
  holds.push(...before.holds, ...after.holds);
829
- const gate = waveGateName(spec.gate ?? spec.name, k);
889
+ const gate = opWaveGateName(spec, k);
830
890
  const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
831
891
  const decision: OpWaveDecision = {
832
892
  wave: k,
@@ -844,6 +904,9 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
844
904
  ...(holds.length > 0 ? { heldBy: holds } : {}),
845
905
  ...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
846
906
  ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
907
+ ...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
908
+ ...(pending ? { pending } : {}),
909
+ ...(neverOverMcp ? { neverOverMcp: true as const } : {}),
847
910
  };
848
911
  if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
849
912
  // A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
@@ -905,9 +968,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
905
968
  op: spec.name,
906
969
  gate,
907
970
  planDigest: digest,
908
- description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
971
+ description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
909
972
  now,
910
973
  ...(spent ? { spent } : {}),
974
+ ...(decision.pending ? { fields: decision.pending } : {}),
975
+ ...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
911
976
  });
912
977
  if (check.satisfied) {
913
978
  decision.status = "approved";
@@ -1279,8 +1344,8 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
1279
1344
  head,
1280
1345
  timestamp: options.now ?? new Date().toISOString(),
1281
1346
  waves: spec.waves.map((wave, i) => {
1282
- const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
1283
- return { wave: i + 1, name: wave.name, digest: waveSetDigest(members) };
1347
+ const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
1348
+ return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
1284
1349
  }),
1285
1350
  };
1286
1351
  await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);