@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
@@ -663,3 +663,99 @@ describe("apply-stage hooks run by the apply, under its lock (#3804)", () => {
663
663
  h.done();
664
664
  });
665
665
  });
666
+
667
+ describe("a product's wave gate moved onto the waves (#3821)", () => {
668
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
669
+ const tg: OpWavesSpec = {
670
+ name: "tf-apply",
671
+ op: "tf-apply",
672
+ gateName: "wave-{k}",
673
+ digest: { over: "changing" },
674
+ planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
675
+ applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
676
+ waves: [{ name: "net", runs: [{ target: "vpc" }, { target: "dns" }, { target: "nat" }] }],
677
+ };
678
+ function harness(plans: Record<string, { planDigest: string; empty?: boolean }>, extra: Record<string, unknown> = {}, atBase: OpWavesSpec = tg) {
679
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-3821-"));
680
+ const calls: string[] = [];
681
+ const exec: OpWaveExec = (argv, dir) => {
682
+ calls.push(argv.join(" "));
683
+ const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
684
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: runs.runs.map((r) => ({ target: r.target, ...plans[r.target] })), ...extra }));
685
+ return 0;
686
+ };
687
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(atBase) });
688
+ return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
689
+ }
690
+
691
+ test("the spec checks gateName, digest and neverOverMcp", () => {
692
+ expect(() => assertOpWavesSpec(tg)).not.toThrow();
693
+ expect(() => assertOpWavesSpec({ ...tg, gateName: "wave" })).toThrow(/pattern with \{k\}/);
694
+ expect(() => assertOpWavesSpec({ ...tg, gateName: "{op}-{k}" })).toThrow(/names \{op\}/);
695
+ expect(() => assertOpWavesSpec({ ...tg, digest: { over: "some" as "all" } })).toThrow(/digest.over/);
696
+ expect(() => assertOpWavesSpec({ ...tg, neverOverMcp: "yes" as unknown as boolean })).toThrow(/neverOverMcp/);
697
+ });
698
+
699
+ test("the gate keeps the product's name, and the digest covers only the runs that change something", async () => {
700
+ const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3") } };
701
+ const h = harness(plans);
702
+ const gates = memoryGateLedgerPort();
703
+ const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
704
+ const changing = waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "nat", planDigest: digest("3") }]);
705
+ expect(result).toMatchObject({ exitCode: 3, decision: { gate: "wave-1", digest: changing, digestOver: "changing", status: "waiting" } });
706
+ expect(gates.appended[0]).toMatchObject({ op: "tf-apply", gate: "wave-1", planDigest: changing });
707
+ h.done();
708
+ });
709
+
710
+ test("an approval a product recorded for its gate and digest before the move lets the wave apply", async () => {
711
+ const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3") } };
712
+ const h = harness(plans);
713
+ const changing = waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "nat", planDigest: digest("3") }]);
714
+ const gates = memoryGateLedgerPort({
715
+ pending: [{ version: 1, kind: "pending", op: "tf-apply", gate: "wave-1", timestamp: "2026-10-10T00:00:00Z", expiresAt: "2026-10-12T00:00:00Z", planDigest: changing, neverOverMcp: true }],
716
+ resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T01:00:00Z", planDigest: changing }],
717
+ });
718
+ const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-10T02:00:00Z" });
719
+ expect(result).toMatchObject({ exitCode: 0, decision: { status: "approved", approvedBy: "alice" } });
720
+ expect(gates.appended).toEqual([]);
721
+ h.done();
722
+ });
723
+
724
+ test("a wave whose runs are all empty digests every run, as before", async () => {
725
+ const plans = { vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3"), empty: true } };
726
+ const h = harness(plans);
727
+ const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
728
+ const all = waveSetDigest(["vpc", "dns", "nat"].map((t, i) => ({ member: t, planDigest: digest(String(i + 1)) })));
729
+ expect(result).toMatchObject({ exitCode: 0, decision: { digest: all, status: "not-required" } });
730
+ h.done();
731
+ });
732
+
733
+ test("the plans file's pending fields and the spec's neverOverMcp land on the pending fact; chant's own fields are refused", async () => {
734
+ const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") }, nat: { planDigest: digest("3") } };
735
+ const members = [{ member: "vpc", planDigest: digest("1") }];
736
+ const h = harness(plans, { pending: { commit: "c".repeat(40), members, description: "wave 1 of 3: vpc, dns, nat" } });
737
+ // neverOverMcp at base counts even when the change drops it.
738
+ const gates = memoryGateLedgerPort();
739
+ const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: () => ({ sha: "b".repeat(40), text: JSON.stringify({ ...tg, neverOverMcp: true }) }), gates });
740
+ expect(result.exitCode).toBe(3);
741
+ expect(gates.appended[0]).toMatchObject({ commit: "c".repeat(40), members, description: "wave 1 of 3: vpc, dns, nat", neverOverMcp: true, gate: "wave-1", op: "tf-apply" });
742
+ h.done();
743
+
744
+ const bad = harness(plans, { pending: { gate: "other" } });
745
+ await expect(runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: bad.cwd, exec: bad.exec, show: bad.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/sets gate, which chant writes itself/);
746
+ bad.done();
747
+ });
748
+
749
+ test("a spec without the new fields names its gates and digests as before", async () => {
750
+ const plain: OpWavesSpec = { ...tg, gateName: undefined, digest: undefined, name: "roots" };
751
+ const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3") } };
752
+ const h = harness(plans, {}, plain);
753
+ const gates = memoryGateLedgerPort();
754
+ const result = await runOpWave({ spec: plain, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
755
+ const all = waveSetDigest(["vpc", "dns", "nat"].map((t, i) => ({ member: t, planDigest: digest(String(i + 1)) })));
756
+ expect(result.decision).toMatchObject({ gate: "roots-wave-1", digest: all });
757
+ expect(result.decision).not.toHaveProperty("digestOver");
758
+ expect(gates.appended[0]).not.toHaveProperty("neverOverMcp");
759
+ h.done();
760
+ });
761
+ });
@@ -22,9 +22,11 @@
22
22
  *
23
23
  * ## The gate
24
24
  *
25
- * Wave k waits on `waveGateName(<gate>, k)`, recorded under the spec's name,
26
- * bound to {@link waveSetDigest} over its runs' plan digests (`../gated-waves.ts`,
27
- * the same digest a component fan-out's wave binds). Whether it waits at all
25
+ * Wave k waits on `waveGateName(<gate>, k)`, or the spec's `gateName` pattern
26
+ * ({@link opWaveGateName}, #3821), recorded under the spec's name, bound to
27
+ * {@link waveSetDigest} over its runs' plan digests (`../gated-waves.ts`, the
28
+ * same digest a component fan-out's wave binds), or with `digest.over:
29
+ * "changing"` over the runs whose plan changes something. Whether it waits at all
28
30
  * is the wave's gate policy, read from the base commit's copy of the spec
29
31
  * file, so a change cannot loosen the rule that gates its own merge.
30
32
  *
@@ -170,12 +172,28 @@ export interface OpWave {
170
172
  * `{plan}` is replaced per run.
171
173
  */
172
174
  export interface OpWavesSpec {
173
- /** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k>`). */
175
+ /** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k>`, or the `gateName` pattern). */
174
176
  name: string;
175
177
  /** The Op a run runs when it names none. */
176
178
  op: string;
177
179
  /** The base gate name. Default: `name`. */
178
180
  gate?: string;
181
+ /**
182
+ * The name of wave k's gate (#3821): `{gate}` is the base gate name and
183
+ * `{k}` the wave's number. Default `"{gate}-wave-{k}"`. A product moving
184
+ * its own wave gates onto these waves names its old pattern here (such as
185
+ * `"wave-{k}"`), so the approvals already on the ledger keep their gate.
186
+ */
187
+ gateName?: string;
188
+ /** Which runs the set digest covers (#3821). Default: every run. */
189
+ digest?: OpWavesDigest;
190
+ /**
191
+ * The waves' pending facts carry `neverOverMcp` (#3821, chant#3485): their
192
+ * gates are never resolved over MCP or ACP, whichever channel reached
193
+ * them. Read at the base commit too, and either one setting it is enough,
194
+ * so a change cannot drop the rule for its own gates.
195
+ */
196
+ neverOverMcp?: boolean;
179
197
  /** Branches whose pushes run the waves. Default `["main"]`. */
180
198
  branches?: string[];
181
199
  /**
@@ -230,6 +248,28 @@ export interface OpWavesSpec {
230
248
  notify?: NotifyConfig;
231
249
  }
232
250
 
251
+ /** Which runs a wave's set digest covers (#3821). */
252
+ export interface OpWavesDigest {
253
+ /**
254
+ * `all` (the default): every run's plan. `changing`: only the runs whose
255
+ * plan is not `empty`, or every run when all of them are empty. A product
256
+ * whose plan note shows a digest over the plans that change something
257
+ * names `changing`, so the digest the wave asks approval for is the one
258
+ * the note showed.
259
+ */
260
+ over?: "all" | "changing";
261
+ }
262
+
263
+ export const OP_WAVES_DIGEST_OVER = ["all", "changing"] as const;
264
+
265
+ /** The default gate name pattern. */
266
+ export const OP_WAVE_DEFAULT_GATE_NAME = "{gate}-wave-{k}";
267
+
268
+ /** The gate wave `k` waits on (#3821): the spec's `gateName` pattern, by default `<gate>-wave-<k>`. */
269
+ export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateName">, k: number): string {
270
+ return (spec.gateName ?? OP_WAVE_DEFAULT_GATE_NAME).replaceAll("{gate}", spec.gate ?? spec.name).replaceAll("{k}", String(k));
271
+ }
272
+
233
273
  /** What happens to an approval once a wave applies under it (#3784). */
234
274
  export interface OpWavesApprovals {
235
275
  /**
@@ -331,6 +371,22 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
331
371
  if (typeof value !== "boolean") throw new Error(`${where}: approvals.spend must be true or false.`);
332
372
  }
333
373
  }
374
+ if (spec.gateName !== undefined) {
375
+ if (typeof spec.gateName !== "string" || !spec.gateName.includes("{k}") || /\s/.test(spec.gateName)) {
376
+ throw new Error(`${where}: gateName must be a pattern with {k}, the wave's number, and no spaces, such as "${OP_WAVE_DEFAULT_GATE_NAME}" or "wave-{k}".`);
377
+ }
378
+ const left = spec.gateName.replaceAll("{gate}", "").replaceAll("{k}", "").match(/\{[^}]*\}/);
379
+ if (left) throw new Error(`${where}: gateName names ${left[0]}; the pattern fills in {gate} and {k} only.`);
380
+ }
381
+ if (spec.digest !== undefined) {
382
+ const d = spec.digest as unknown;
383
+ if (d === null || typeof d !== "object" || Array.isArray(d)) throw new Error(`${where}: \`digest\` must be { "over"?: "all" | "changing" }.`);
384
+ for (const [key, value] of Object.entries(d as Record<string, unknown>)) {
385
+ if (key !== "over") throw new Error(`${where}: digest.${key} is not a setting; the one there is digest.over.`);
386
+ if (!OP_WAVES_DIGEST_OVER.includes(value as "all")) throw new Error(`${where}: digest.over is ${JSON.stringify(value)}; use "all" or "changing".`);
387
+ }
388
+ }
389
+ if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
334
390
  if (spec.notify !== undefined) {
335
391
  const problem = notifyConfigProblem(spec.notify, "notify");
336
392
  if (problem) throw new Error(`${where}: ${problem}`);
@@ -121,6 +121,13 @@ export interface AskPointInRunOptions {
121
121
  on?: string;
122
122
  /** The steward turn, when the caller has it. Default: this process's (`currentStewardTurn`). */
123
123
  turn?: StewardTurn | null;
124
+ /**
125
+ * Where a new question goes (#3815): the working tree, or `chant/lifecycle`.
126
+ * Default: the ledger in a steward's turn and the tree otherwise
127
+ * (`askPoint`'s rule). A run whose checkout does not outlive it, such as a
128
+ * CI job, passes `"ledger"` so a person can answer after the job ends.
129
+ */
130
+ store?: "tree" | "ledger";
124
131
  }
125
132
 
126
133
  /** What a steward reaches through its broker, read from its declaration when the turn named only the steward. */
@@ -178,6 +185,7 @@ export async function askPointInRun(opts: AskPointInRunOptions): Promise<{ answe
178
185
  ...(opts.kind !== undefined ? { kind: opts.kind } : {}),
179
186
  ...(ask ? { ask } : {}),
180
187
  ...(opts.on !== undefined ? { on: opts.on } : {}),
188
+ ...(opts.store !== undefined ? { store: opts.store } : {}),
181
189
  ...(turn ? { steward: { name: turn.steward, ...(turn.run ? { run: turn.run } : {}) } } : {}),
182
190
  });
183
191
  if ("error" in doc) throw new Error(`decision point ${opts.point}: ${doc.error.code}: ${doc.error.message}`);
@@ -148,6 +148,11 @@ describe("decideOverride", () => {
148
148
  expect(parseAppliedOverrides(`${JSON.stringify(applied)}\nnot json\n{"version":1,"kind":"finished"}\n`)).toEqual([applied]);
149
149
  });
150
150
 
151
+ test("takes just the subject, digest, time and approver (#3823)", () => {
152
+ const slim = appliedOverride({ subject: ROOT, digest: DIGEST, at: first.timestamp, by: "github:alice" }, { timestamp: "2026-10-11T11:05:00.000Z", runId: "42", commit: "c".repeat(40) });
153
+ expect(slim).toEqual(applied);
154
+ });
155
+
151
156
  test("a spent override does not count for the same plan again", () => {
152
157
  const d = decideOverride(input([first], { applied: [applied] }));
153
158
  expect(d.status === "none" && d.refusals).toEqual([expect.stringMatching(/is spent: a run applied under it at 2026-10-11T11:05:00/)]);
@@ -359,8 +359,14 @@ export function decideOverride(input: OverrideInput): OverrideDecision {
359
359
  };
360
360
  }
361
361
 
362
- /** The applied record a run appends before it applies under `override`, which spends it. Appending it is the caller's. */
363
- export function appliedOverride(override: StandingOverride, at: { timestamp: string; runId?: string; commit?: string }): AppliedOverrideRecord {
362
+ /**
363
+ * The applied record a run appends before it applies under `override`, which
364
+ * spends it. Appending it is the caller's. It reads only the subject, the
365
+ * override digest and the override's time and approver, so a product that
366
+ * carries those through its own record can pass them without the ledger line
367
+ * (#3823); a whole {@link StandingOverride} still works.
368
+ */
369
+ export function appliedOverride(override: Pick<StandingOverride, "subject" | "digest" | "at" | "by">, at: { timestamp: string; runId?: string; commit?: string }): AppliedOverrideRecord {
364
370
  return {
365
371
  version: 1,
366
372
  kind: "applied",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The gate-point decision bundles small (#3816).
3
+ *
4
+ * terragucci's wave gate asks a decision point, and terragucci ships as one
5
+ * bundled file with no TypeScript compiler, chant CLI, lint or codegen code
6
+ * in it. `@intentius/chant/op/gate-point-run` reached all of those through
7
+ * the workspace asker and the decide activity. `@intentius/chant/point-decision`
8
+ * holds the decision with the asker and the ledger port injected. This
9
+ * bundles it the way terragucci would and reads esbuild's own list of what
10
+ * went in.
11
+ */
12
+
13
+ import { join } from "node:path";
14
+ import { build } from "esbuild";
15
+ import { describe, expect, test } from "vitest";
16
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
17
+
18
+ const FORBIDDEN = [
19
+ /node_modules\//,
20
+ /\/src\/cli\//,
21
+ /\/src\/workspace\//,
22
+ /\/src\/fold\//,
23
+ /\/src\/lint\//,
24
+ /\/src\/codegen\//,
25
+ /\/src\/op\/activities\//,
26
+ /\/src\/lifecycle\/git\.ts$/,
27
+ /\/src\/lifecycle\/gate-ledger\.ts$/,
28
+ ];
29
+
30
+ describe("the point-decision bundle", () => {
31
+ test("holds the gate-point decision and its pure helpers, and no workspace, CLI, package or git code", async () => {
32
+ const result = await build({
33
+ entryPoints: [join(REPO, "packages", "core", "src", "point-decision.ts")],
34
+ bundle: true,
35
+ platform: "node",
36
+ format: "esm",
37
+ write: false,
38
+ metafile: true,
39
+ outdir: "out",
40
+ logLevel: "silent",
41
+ });
42
+ const inputs = Object.keys(result.metafile.inputs);
43
+ for (const pattern of FORBIDDEN) {
44
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
45
+ }
46
+ // The plan digest is the one chant computes, which brings its canonical JSON with it, as in gate-decision.
47
+ expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
48
+ "packages/core/src/content-digest.ts",
49
+ "packages/core/src/declarable.ts",
50
+ "packages/core/src/effect-receipt.ts",
51
+ "packages/core/src/intrinsic.ts",
52
+ "packages/core/src/lifecycle/gate-ledger-select.ts",
53
+ "packages/core/src/lifecycle/gate-pending.ts",
54
+ "packages/core/src/lifecycle/plan-digest.ts",
55
+ "packages/core/src/op/duration.ts",
56
+ "packages/core/src/op/gate-point.ts",
57
+ "packages/core/src/point-decision.ts",
58
+ ]);
59
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
60
+ expect(bytes).toBeLessThan(24 * 1024);
61
+ });
62
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * #3816: a tool built into one file decides a gate that asks a decision point
3
+ * with `@intentius/chant/point-decision`, asking through the chant bin
4
+ * (`chantGatePointAsker`) and recording through its own gate ledger port.
5
+ * The question is open until a person answers it with `chant workspace points
6
+ * answer`; then the same call records the resolution that cites the answer,
7
+ * once per answer and plan. With `store: "ledger"` the question is held on
8
+ * `chant/lifecycle`, not in the working tree (#3815).
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
15
+ import { cleanScratch, workspace } from "./workspace/__fixtures__/decide-workspace";
16
+ import { defaultChantCommand } from "./workspace/conformance";
17
+ import { memoryGateLedgerPort } from "./op/gate";
18
+ import { chantGatePointAsker, evaluatePointGate, type PointGateCheck } from "./point-decision";
19
+
20
+ const PLAN = `jcs1-sha256:${"5".repeat(64)}`;
21
+
22
+ const POINTS = {
23
+ points: {
24
+ "prod-apply": {
25
+ title: "Apply this plan to prod",
26
+ question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
27
+ inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
28
+ deciders: [{ kind: "quorum", count: 1 }],
29
+ },
30
+ },
31
+ };
32
+
33
+ const command = defaultChantCommand();
34
+ let root: string;
35
+
36
+ const chant = (...args: string[]): Record<string, unknown> => {
37
+ let out: string;
38
+ try {
39
+ out = execFileSync(command[0], [...command.slice(1), ...args], { cwd: root, encoding: "utf-8", env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
40
+ } catch (e) {
41
+ out = String((e as { stdout?: string }).stdout ?? "");
42
+ }
43
+ return JSON.parse(out) as Record<string, unknown>;
44
+ };
45
+
46
+ const answersInTree = (): string[] => (existsSync(join(root, "answers")) ? readdirSync(join(root, "answers")).filter((f) => f.endsWith(".md")) : []);
47
+
48
+ beforeAll(() => {
49
+ root = workspace(POINTS);
50
+ vi.stubEnv("CI", "");
51
+ });
52
+
53
+ afterAll(() => {
54
+ vi.unstubAllEnvs();
55
+ cleanScratch();
56
+ });
57
+
58
+ describe("a gate-point decision through the chant bin (#3816)", () => {
59
+ test("waits on the open question, then passes on a person's answer, once per answer and plan", async () => {
60
+ const port = memoryGateLedgerPort();
61
+ const asker = chantGatePointAsker({ command, cwd: root });
62
+ const input = { op: "tf-apply", gate: "approve-apply", point: { name: "prod-apply" }, planDigest: PLAN, now: "2026-10-11T13:00:00.000Z" };
63
+
64
+ const first = await evaluatePointGate(port, asker, input);
65
+ expect(first).toMatchObject({ satisfied: false, recorded: true, waiting: { point: "prod-apply", state: "escalated", subject: "gate:tf-apply/approve-apply" } });
66
+ const waiting = first as Extract<PointGateCheck, { waiting: unknown }>;
67
+ expect(port.appended).toEqual([expect.objectContaining({ op: "tf-apply", gate: "approve-apply", planDigest: PLAN, answer: { point: "prod-apply", id: waiting.waiting.id, path: waiting.waiting.path } })]);
68
+
69
+ // Asked again while open, the standing pending fact is reused.
70
+ expect(await evaluatePointGate(port, asker, input)).toMatchObject({ satisfied: false, recorded: false });
71
+
72
+ expect(chant("workspace", "points", "answer", waiting.waiting.id, "--answer", "yes", "--by", "alice")).toMatchObject({ written: true, question: { state: "answered" } });
73
+
74
+ const passed = await evaluatePointGate(port, asker, input);
75
+ expect(passed).toMatchObject({ satisfied: true, recorded: true, resolution: { resolvedBy: "alice", planDigest: PLAN, answer: { id: waiting.waiting.id, answer: true, decider: "quorum", answeredBy: ["alice"] } } });
76
+ expect(await evaluatePointGate(port, asker, input)).toMatchObject({ satisfied: true, recorded: false });
77
+ expect(port.resolved).toHaveLength(1);
78
+ }, 120_000);
79
+
80
+ test("a point with store ledger holds the question on chant/lifecycle, and an answer it does not pass on refuses", async () => {
81
+ const port = memoryGateLedgerPort();
82
+ const asker = chantGatePointAsker({ command, cwd: root });
83
+ const before = answersInTree();
84
+ const input = { op: "ledger-apply", gate: "approve-apply", point: { name: "prod-apply", store: "ledger" as const }, planDigest: PLAN };
85
+
86
+ const first = (await evaluatePointGate(port, asker, input)) as Extract<PointGateCheck, { waiting: unknown }>;
87
+ expect(first.waiting.state).toBe("escalated");
88
+ expect(answersInTree()).toEqual(before);
89
+
90
+ chant("workspace", "points", "answer", first.waiting.id, "--answer", "no", "--by", "alice");
91
+ const refused = await evaluatePointGate(port, asker, input);
92
+ expect(refused).toMatchObject({ satisfied: false, answer: { id: first.waiting.id, answer: false } });
93
+ expect("refused" in refused && refused.refused).toContain("passes only on true");
94
+ }, 120_000);
95
+ });