@intentius/chant 0.105.0 → 0.106.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.d.ts.map +1 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/pr.d.ts +16 -2
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/promote.d.ts.map +1 -1
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +7 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/changed-files.d.ts +8 -2
  13. package/dist/components/changed-files.d.ts.map +1 -1
  14. package/dist/components/fan-out-record.d.ts +24 -0
  15. package/dist/components/fan-out-record.d.ts.map +1 -1
  16. package/dist/components/pr-pipeline.d.ts +60 -4
  17. package/dist/components/pr-pipeline.d.ts.map +1 -1
  18. package/dist/components/pr-run.d.ts +92 -3
  19. package/dist/components/pr-run.d.ts.map +1 -1
  20. package/dist/gated-waves.d.ts +4 -1
  21. package/dist/gated-waves.d.ts.map +1 -1
  22. package/dist/lexicon.d.ts +13 -2
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/op/activities/factory.d.ts.map +1 -1
  26. package/dist/op/gate-name.d.ts +7 -0
  27. package/dist/op/gate-name.d.ts.map +1 -1
  28. package/dist/op/gate-summary.d.ts +2 -0
  29. package/dist/op/gate-summary.d.ts.map +1 -1
  30. package/dist/op/gate.d.ts +10 -2
  31. package/dist/op/gate.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +2 -2
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-output.d.ts +2 -2
  36. package/dist/op/local-output.d.ts.map +1 -1
  37. package/dist/op/runtime.d.ts +19 -2
  38. package/dist/op/runtime.d.ts.map +1 -1
  39. package/dist/op/runtimes/local.d.ts.map +1 -1
  40. package/dist/pr-loop.d.ts +29 -4
  41. package/dist/pr-loop.d.ts.map +1 -1
  42. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  43. package/dist/workspace/member-pipeline.d.ts +14 -1
  44. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  47. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  48. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  49. package/src/change-set.test.ts +7 -8
  50. package/src/cli/commands/lint.ts +34 -1
  51. package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
  52. package/src/cli/handlers/build.ts +65 -0
  53. package/src/cli/handlers/fan-out.test.ts +12 -1
  54. package/src/cli/handlers/fan-out.ts +7 -3
  55. package/src/cli/handlers/operator.ts +1 -2
  56. package/src/cli/handlers/pr.ts +220 -35
  57. package/src/cli/handlers/promote.test.ts +15 -1
  58. package/src/cli/handlers/promote.ts +5 -3
  59. package/src/cli/handlers/run.test.ts +164 -1
  60. package/src/cli/handlers/run.ts +22 -11
  61. package/src/cli/main.ts +13 -1
  62. package/src/cli/mcp/never-over-mcp.test.ts +74 -0
  63. package/src/cli/registry.ts +7 -0
  64. package/src/components/changed-files.test.ts +58 -0
  65. package/src/components/changed-files.ts +19 -4
  66. package/src/components/fan-out-record.test.ts +47 -2
  67. package/src/components/fan-out-record.ts +53 -0
  68. package/src/components/pr-pipeline.ts +89 -6
  69. package/src/components/pr-run.test.ts +300 -3
  70. package/src/components/pr-run.ts +196 -5
  71. package/src/gated-waves.test.ts +11 -0
  72. package/src/gated-waves.ts +7 -2
  73. package/src/lexicon.ts +13 -1
  74. package/src/lifecycle/gate-ledger.ts +4 -3
  75. package/src/op/activities/factory.ts +9 -1
  76. package/src/op/gate-name.ts +17 -0
  77. package/src/op/gate-summary.test.ts +12 -0
  78. package/src/op/gate-summary.ts +3 -1
  79. package/src/op/gate.test.ts +15 -1
  80. package/src/op/gate.ts +21 -4
  81. package/src/op/index.ts +2 -2
  82. package/src/op/local-executor.test.ts +35 -0
  83. package/src/op/local-executor.ts +2 -2
  84. package/src/op/local-output.test.ts +20 -0
  85. package/src/op/local-output.ts +4 -4
  86. package/src/op/runtime.ts +20 -2
  87. package/src/op/runtimes/local.test.ts +15 -0
  88. package/src/op/runtimes/local.ts +3 -1
  89. package/src/pr-loop.test.ts +41 -1
  90. package/src/pr-loop.ts +38 -8
  91. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  92. package/src/workspace/lineage-upgrade.test.ts +40 -0
  93. package/src/workspace/member-pipeline.test.ts +28 -0
  94. package/src/workspace/member-pipeline.ts +20 -3
  95. package/src/workspace/pr-report.schema.json +14 -1
@@ -75,8 +75,9 @@ vi.mock("./operator", () => ({
75
75
  // gate handling, and a unit test must never write a fact to the real repo.
76
76
  vi.mock("../../op/gate", async () => {
77
77
  const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
78
- return { ...actual, gitGateLedgerPort: () => gateLedger };
78
+ return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
79
79
  });
80
+ const sealedGates = new Set<string>();
80
81
  // The local runtime appends each run's record to that same branch (#2118).
81
82
  // Real behavior in a project, and exactly what this suite must not do in
82
83
  // chant's own checkout, since the append is against `process.cwd()`. Only the
@@ -264,9 +265,28 @@ describe("runOp dispatcher", () => {
264
265
  stderrWrite.mockRestore();
265
266
  expect(out).toContain('is gated on "approve-prod"');
266
267
  expect(out).toContain("chant approve gated approve-prod");
268
+ expect(out).not.toContain("--sign");
267
269
  expect(gateLedger.appended).toHaveLength(1);
268
270
  });
269
271
 
272
+ test("gate in local mode on a sealed gate: the approve line carries --sign (#3521)", async () => {
273
+ gateLedger = memoryGateLedgerPort();
274
+ sealedGates.add("approve-prod");
275
+ discoverOpsMock.mockResolvedValue({
276
+ ops: new Map([localOp("gated", [{ kind: "gate", gate: "approve-prod" }])]),
277
+ errors: [],
278
+ });
279
+ const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
280
+ try {
281
+ expect(await runOp({ args: makeArgs({ path: "gated" }), plugins: [], serializers: [] })).toBe(3);
282
+ const out = stderrWrite.mock.calls.map((c) => String(c[0])).join("");
283
+ expect(out).toContain("approve : chant approve gated approve-prod --sign");
284
+ } finally {
285
+ stderrWrite.mockRestore();
286
+ sealedGates.clear();
287
+ }
288
+ });
289
+
270
290
  test("an approved gate lets the run through and exits 0", async () => {
271
291
  gateLedger = memoryGateLedgerPort({
272
292
  pending: [{
@@ -616,6 +636,25 @@ describe("runOp: --gated-exit (#2243)", () => {
616
636
  expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod");
617
637
  });
618
638
 
639
+ test("a sealed gate: the --json approve field and the summary carry --sign (#3521)", async () => {
640
+ const file = summaryFile();
641
+ process.env.GITHUB_STEP_SUMMARY = file;
642
+ sealedGates.add("approve-prod");
643
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
644
+ const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
645
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
646
+ makeStderrSpy();
647
+
648
+ try {
649
+ await runOp({ args: makeArgs({ path: "gated", gatedExit: 0, json: true }), plugins: [], serializers: [] });
650
+ const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => String(c[0])).join("").trim());
651
+ expect(parsed.approve).toBe("chant approve gated approve-prod --sign");
652
+ expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod --sign --approver <you>");
653
+ } finally {
654
+ sealedGates.clear();
655
+ }
656
+ });
657
+
619
658
  test("a run that fails for any other reason is still red under the flag", async () => {
620
659
  discoverOpsMock.mockResolvedValue({
621
660
  // atMostOnce: one attempt. The default profile retries twice with 5s and
@@ -776,6 +815,45 @@ describe("runOp: --sandbox with a policyGate step (chant #2003)", () => {
776
815
  expect(seen).not.toHaveProperty("profile");
777
816
  });
778
817
 
818
+ // #3521: a hosted runtime that reports only a gate name gets a hint whose
819
+ // approve command names `--sign` when the workspace seals that gate.
820
+ describe("a runtime that reports a gated state with only the gate name (#3521)", () => {
821
+ async function runGatedOnStub(): Promise<string> {
822
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("prod-apply")]), errors: [] });
823
+ const start = vi.fn(async () => ({
824
+ op: "prod-apply",
825
+ runId: "stub-1",
826
+ result: async () => ({
827
+ op: "prod-apply", runId: "stub-1", state: "gated", startedAt: "2026-01-01T00:00:00.000Z",
828
+ gate: { name: "approve-prod", since: "2026-01-01T00:00:00.000Z" },
829
+ }),
830
+ }));
831
+ makeStdoutSpy();
832
+ const stderr = makeStderrSpy();
833
+ const exit = await runOp({
834
+ args: makeArgs({ path: "prod-apply", on: "stub" }),
835
+ plugins: [{ name: "stub", opRuntime: { name: "stub", start } } as never], serializers: [],
836
+ });
837
+ expect(exit).toBe(3);
838
+ return stderr.join("\n");
839
+ }
840
+
841
+ test("unsealed gate: the hint's approve command has no --sign", async () => {
842
+ const out = await runGatedOnStub();
843
+ expect(out).toContain("Record the resolution with: chant approve prod-apply approve-prod");
844
+ expect(out).not.toContain("--sign");
845
+ });
846
+
847
+ test("sealed gate: the hint's approve command ends in --sign", async () => {
848
+ sealedGates.add("approve-prod");
849
+ try {
850
+ expect(await runGatedOnStub()).toContain("Record the resolution with: chant approve prod-apply approve-prod --sign");
851
+ } finally {
852
+ sealedGates.clear();
853
+ }
854
+ });
855
+ });
856
+
779
857
  test("--sandbox on an Op with no policyGate step is untouched", async () => {
780
858
  discoverOpsMock.mockResolvedValue({
781
859
  ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
@@ -878,6 +956,29 @@ describe("run subcommands on the resolved runtime", () => {
878
956
  expect((runtime.start.mock.calls[0][0] as { name: string }).name).toBe("hello");
879
957
  });
880
958
 
959
+ test("--param reaches the runtime's start as params (#3539)", async () => {
960
+ const runtime = makeStubRuntime();
961
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
962
+ makeStdoutSpy();
963
+ const exit = await runOp({
964
+ args: makeArgs({ path: "hello", on: "stub", env: "staging", param: ["agent=ops-bot", "tier=gold plus"] }),
965
+ plugins: [stubPlugin(runtime)], serializers: [],
966
+ });
967
+ expect(exit).toBe(0);
968
+ expect((runtime.start.mock.calls[0] as unknown[])[1]).toMatchObject({
969
+ env: "staging",
970
+ params: { agent: "ops-bot", tier: "gold plus" },
971
+ });
972
+ });
973
+
974
+ test("no --param hands start no params", async () => {
975
+ const runtime = makeStubRuntime();
976
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
977
+ makeStdoutSpy();
978
+ await runOp({ args: makeArgs({ path: "hello", on: "stub" }), plugins: [stubPlugin(runtime)], serializers: [] });
979
+ expect((runtime.start.mock.calls[0] as unknown[])[1]).not.toHaveProperty("params");
980
+ });
981
+
881
982
  test("run status/log/list/cancel --on stub reach their methods", async () => {
882
983
  const runtime = makeStubRuntime();
883
984
  discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
@@ -1060,6 +1161,21 @@ describe("runOpApprove", () => {
1060
1161
  expect(stderr.join("\n")).toContain('Runtime "stub" was notified');
1061
1162
  });
1062
1163
 
1164
+ test("--env on the approve line reaches resolveGate (#3539)", async () => {
1165
+ const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" };
1166
+ recordGateApprovalMock.mockResolvedValue({ ok: true, record });
1167
+ const resolveGate = vi.fn(async () => undefined);
1168
+ makeStderrSpy();
1169
+
1170
+ const exit = await runOpApprove({
1171
+ args: makeArgs({ on: "stub", extraPositional: "alb-deploy", extraPositional2: "release", approver: "alex", env: "prod" }),
1172
+ plugins: [{ name: "stub", opRuntime: { name: "stub", resolveGate } } as never], serializers: [],
1173
+ });
1174
+
1175
+ expect(exit).toBe(0);
1176
+ expect(resolveGate).toHaveBeenCalledWith("alb-deploy", "release", record, { env: "prod" });
1177
+ });
1178
+
1063
1179
  test("a provider with no resolveGate still records the fact and exits 0", async () => {
1064
1180
  const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "ci", timestamp: "2026-01-01T00:00:00.000Z" };
1065
1181
  recordGateApprovalMock.mockResolvedValue({ ok: true, record });
@@ -1141,6 +1257,53 @@ describe("runOp dispatcher: --components routes to runOpComponents", () => {
1141
1257
  });
1142
1258
  });
1143
1259
 
1260
+ // #3521: a gated component's approve line and mismatch line name `--sign` on
1261
+ // a sealed gate and leave it off otherwise.
1262
+ describe("runOp --components gate hints (#3521)", () => {
1263
+ beforeEach(() => {
1264
+ runComponentsMock.mockReset();
1265
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
1266
+ maybeRecordAutoReleaseMock.mockReset().mockResolvedValue({ recorded: false, reason: "no-digest" });
1267
+ maybePersistBuildManifestMock.mockReset().mockResolvedValue({ persisted: false, reason: "no-manifest" });
1268
+ });
1269
+
1270
+ async function runGatedComponent(): Promise<string> {
1271
+ runComponentsMock.mockResolvedValue({
1272
+ success: false,
1273
+ selected: ["svc"],
1274
+ gated: {
1275
+ component: "svc",
1276
+ gate: {
1277
+ version: 1, kind: "pending", op: "svc", gate: "approve-prod", environment: "prod",
1278
+ timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
1279
+ },
1280
+ mismatch: { approved: "sha256:aaa", planned: "sha256:bbb", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z", environment: "prod" },
1281
+ },
1282
+ });
1283
+ const stderr = makeStderrSpy();
1284
+ expect(await runOp({ args: makeArgs({ path: "svc", components: true }), plugins: [], serializers: [] })).toBe(3);
1285
+ return stderr.join("\n");
1286
+ }
1287
+
1288
+ test("unsealed gate: the approve line and the mismatch line carry no --sign", async () => {
1289
+ const out = await runGatedComponent();
1290
+ expect(out).toContain("approve : chant approve svc approve-prod --env prod");
1291
+ expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod");
1292
+ expect(out).not.toContain("--sign");
1293
+ });
1294
+
1295
+ test("sealed gate: the approve line and the mismatch line end in --sign", async () => {
1296
+ sealedGates.add("approve-prod");
1297
+ try {
1298
+ const out = await runGatedComponent();
1299
+ expect(out).toContain("approve : chant approve svc approve-prod --env prod --sign");
1300
+ expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod --sign");
1301
+ } finally {
1302
+ sealedGates.clear();
1303
+ }
1304
+ });
1305
+ });
1306
+
1144
1307
  describe("runOpComponents", () => {
1145
1308
  beforeEach(() => {
1146
1309
  runComponentsMock.mockReset();
@@ -7,7 +7,7 @@ import { stewardWorkHolder } from "../../op/work-lease-run";
7
7
  import type { OpConfig } from "../../op/types";
8
8
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
9
9
  import { runOpLocally, findPolicyGateStep, OpRunFailure, type StepRecord } from "../../op/local-executor";
10
- import { approveCommand, describeGateMismatch } from "../../op/gate";
10
+ import { approveCommand, describeGateMismatch, gateIsSealed } from "../../op/gate";
11
11
  import { summaryLedgerPrefix, writeGatedRunSummary, type GatedRunSummary } from "../../op/gate-summary";
12
12
  import { createLocalOpRuntime } from "../../op/runtimes/local";
13
13
  import type { OpRuntimeProvider, OpRunStatus } from "../../op/runtime";
@@ -77,8 +77,8 @@ function resolveGatedExitCode(ctx: CommandContext): number | undefined {
77
77
  * passes `--gated-exit 0` reports success, and the one line that explains why
78
78
  * a zero-exit run applied nothing belongs next to the gate itself.
79
79
  */
80
- function reportGatedRun(summary: GatedRunSummary, exitCode: number): void {
81
- writeGatedRunSummary(summary);
80
+ async function reportGatedRun(summary: GatedRunSummary, exitCode: number): Promise<void> {
81
+ writeGatedRunSummary({ ...summary, sealed: await gateIsSealed(summary.gate) });
82
82
  if (exitCode !== GATED_EXIT_CODE) {
83
83
  console.error(formatInfo(
84
84
  `gated: exiting ${exitCode} because --gated-exit asked for it. Nothing after the gate ran.`,
@@ -466,7 +466,10 @@ export async function runOpApprove(ctx: CommandContext): Promise<number> {
466
466
  }
467
467
 
468
468
  try {
469
- await runtime.resolveGate(opName, gate, outcome.record);
469
+ // `--env` on the approve line names the environment the woken run runs
470
+ // in (#3539); without it the runtime keeps the gated run's.
471
+ if (ctx.args.env !== undefined) await runtime.resolveGate(opName, gate, outcome.record, { env: ctx.args.env });
472
+ else await runtime.resolveGate(opName, gate, outcome.record);
470
473
  } catch (err) {
471
474
  console.error(formatWarning({
472
475
  message:
@@ -962,10 +965,11 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
962
965
  }));
963
966
  // #2574: an approval stands, but for another plan or for none. Say so,
964
967
  // with the command that replaces it.
968
+ const sealed = await gateIsSealed(gate.gate);
965
969
  if (result.gated.mismatch) {
966
- console.error(formatWarning({ message: describeGateMismatch(gate.op, gate.gate, result.gated.mismatch) }));
970
+ console.error(formatWarning({ message: describeGateMismatch(gate.op, gate.gate, result.gated.mismatch, sealed) }));
967
971
  }
968
- console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate, gate.environment)}`));
972
+ console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate, gate.environment, undefined, sealed)}`));
969
973
  if (gate.url) console.error(formatInfo(`approve at: ${gate.url}`));
970
974
  console.error(formatInfo(`expires : ${gate.expiresAt}`));
971
975
  // #2310: this run's own append reached only the local chant/lifecycle
@@ -978,7 +982,7 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
978
982
  hint: "an operator working from a clone of the remote cannot approve it until it does",
979
983
  }));
980
984
  }
981
- reportGatedRun(
985
+ await reportGatedRun(
982
986
  {
983
987
  op: gate.op,
984
988
  gate: gate.gate,
@@ -1107,8 +1111,13 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1107
1111
  process.on("SIGTERM", onSigint);
1108
1112
 
1109
1113
  try {
1114
+ // `--param name=value` reaches the runtime (#3539). The local runtime
1115
+ // has no use for it; fountain reads `agent` to pick the steward and posts
1116
+ // the rest on the command line it hands the sandbox.
1117
+ const params = parseParamFlags(ctx.args.param);
1110
1118
  const handle = await runtime.start(config, {
1111
1119
  env: ctx.args.env,
1120
+ ...(params ? { params } : {}),
1112
1121
  ...(ctx.args.profile !== undefined ? { profile: ctx.args.profile } : {}),
1113
1122
  progress,
1114
1123
  signal: controller.signal,
@@ -1119,13 +1128,14 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1119
1128
  if (status.result) {
1120
1129
  // The executor's own render already prints a gate, its approve line
1121
1130
  // and its expiry (#2119).
1122
- if (ctx.args.json) renderJson(status.result); else renderHuman(status.result);
1131
+ const sealed = status.result.gate ? await gateIsSealed(status.result.gate.gate) : false;
1132
+ if (ctx.args.json) renderJson(status.result, undefined, sealed); else renderHuman(status.result, undefined, sealed);
1123
1133
  } else {
1124
1134
  renderRuntimeStatus("Op", opName, runtime.name, status);
1125
1135
  if (status.state === "gated" && status.gate) {
1126
1136
  console.error(formatWarning({
1127
1137
  message: `Op "${opName}" is waiting on gate "${status.gate.name}"`,
1128
- hint: `Record the resolution with: ${approveCommand(opName, status.gate.name)}`,
1138
+ hint: `Record the resolution with: ${approveCommand(opName, status.gate.name, undefined, undefined, await gateIsSealed(status.gate.name))}`,
1129
1139
  }));
1130
1140
  }
1131
1141
  }
@@ -1140,7 +1150,7 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1140
1150
  const pending = status.result?.gate;
1141
1151
  const gate = pending?.gate ?? status.gate?.name;
1142
1152
  if (gate) {
1143
- reportGatedRun(
1153
+ await reportGatedRun(
1144
1154
  {
1145
1155
  op: opName,
1146
1156
  gate,
@@ -1175,7 +1185,8 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1175
1185
  return status.state === "completed" ? 0 : 1;
1176
1186
  } catch (err) {
1177
1187
  if (err instanceof OpRunFailure) {
1178
- if (ctx.args.json) renderJson(err.result); else renderHuman(err.result);
1188
+ const sealed = err.result.gate ? await gateIsSealed(err.result.gate.gate) : false;
1189
+ if (ctx.args.json) renderJson(err.result, undefined, sealed); else renderHuman(err.result, undefined, sealed);
1179
1190
  return 1;
1180
1191
  }
1181
1192
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
package/src/cli/main.ts CHANGED
@@ -606,6 +606,10 @@ export function parseArgs(args: string[]): ParsedArgs {
606
606
  // didn't support joined forms at all — now that it does, the joined
607
607
  // form is just as valid as the space-separated one).
608
608
  (result.param ??= []).push(args[++i]);
609
+ } else if (arg === "--lexicon-output") {
610
+ // `--lexicon-output <lexicon>=<path>` (repeatable); the joined form
611
+ // `--lexicon-output=otel=a.yml` arrives here already split at the first `=`.
612
+ (result.lexiconOutput ??= []).push(args[++i]);
609
613
  } else if (arg === "--params-file") {
610
614
  result.paramsFile = args[++i];
611
615
  } else if (arg === "--projection") {
@@ -1308,7 +1312,11 @@ Component release ledger + status:
1308
1312
  (chant approve pr-<n> pr-apply --plan <digest>);
1309
1313
  a moved plan refuses with exit 3
1310
1314
  (--require-review: the approver must have
1311
- approved the pull request on the forge)
1315
+ approved the pull request on the forge;
1316
+ --member <name>, both commands: a workspace
1317
+ member's loop, gate op pr-<n>-<member>;
1318
+ --resume <file> finishes an apply that failed
1319
+ partway under the same approval)
1312
1320
  components release <env> Append one immutable release record
1313
1321
  (--component <name> --digest <sha256:...>
1314
1322
  [--git-sha <sha>] [--run-id <id>] [--actor <name>]);
@@ -1376,6 +1384,10 @@ Options:
1376
1384
  - build: json (default) or yaml
1377
1385
  - list: text (default) or json
1378
1386
  - lint: stylish (default), json, or sarif
1387
+ --lexicon-output <lexicon>=<path>
1388
+ build: write that lexicon's output to its own file
1389
+ (repeatable). Lexicons not named go to --output when
1390
+ given, and are skipped otherwise
1379
1391
  -d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab);
1380
1392
  import: use this lexicon instead of detecting one
1381
1393
  --env <name> Active environment: sets CHANT_ENV so env-aware source
@@ -166,4 +166,78 @@ describe("neverOverMcp (chant#3447)", () => {
166
166
  expect(outcome.ok).toBe(true);
167
167
  });
168
168
  });
169
+
170
+ // chant#3513: workspace-upgrade and the pull-request gate are undiscovered too.
171
+ const gateOnlyOps: Array<{ op: string; gate: string }> = [
172
+ { op: "workspace-upgrade", gate: "." },
173
+ { op: "pr-12", gate: "pr-apply" },
174
+ ];
175
+
176
+ for (const { op, gate } of gateOnlyOps) {
177
+ async function repoWithGate(dir: string): Promise<void> {
178
+ git(["init", "-q", "-b", "main"], dir);
179
+ git(["config", "user.email", "t@chant.dev"], dir);
180
+ git(["config", "user.name", "T"], dir);
181
+ writeFileSync(join(dir, "README.md"), "x\n");
182
+ git(["add", "."], dir);
183
+ git(["commit", "-q", "-m", "init"], dir);
184
+ await appendPendingGate(
185
+ {
186
+ op,
187
+ gate,
188
+ timestamp: "2026-01-01T00:00:00.000Z",
189
+ origin: "cli",
190
+ expiresAt: "2999-01-01T00:00:00.000Z",
191
+ planDigest: `sha256:${"c".repeat(64)}`,
192
+ },
193
+ { cwd: dir },
194
+ );
195
+ }
196
+
197
+ test(`${op}: the gate record carries neverOverMcp (chant#3513)`, async () => {
198
+ await withTestDir(async (dir) => {
199
+ await repoWithGate(dir);
200
+ const { pending } = await readGateLedger(op, { cwd: dir });
201
+ expect(pending[0].neverOverMcp).toBe(true);
202
+ });
203
+ });
204
+
205
+ test(`${op}: op-approve over MCP is refused and the ledger is unchanged (chant#3513)`, async () => {
206
+ await withTestDir(async (dir) => {
207
+ await repoWithGate(dir);
208
+ const before = await readGateLedger(op, { cwd: dir });
209
+ process.chdir(dir);
210
+ await expect(createOpApproveTool().handler({ name: op, gate })).rejects.toThrow(
211
+ /neverOverMcp.*whichever channel/s,
212
+ );
213
+ expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
214
+ expect(resolveGate).not.toHaveBeenCalled();
215
+ });
216
+ });
217
+
218
+ test(`${op}: ACP is refused even with the same-origin override (chant#3513)`, async () => {
219
+ await withTestDir(async (dir) => {
220
+ await repoWithGate(dir);
221
+ const before = await readGateLedger(op, { cwd: dir });
222
+ process.chdir(dir);
223
+ const outcome = await recordGateApproval(op, gate, {
224
+ origin: "acp",
225
+ actor: "agent",
226
+ allowSameOrigin: true,
227
+ cwd: dir,
228
+ });
229
+ expect(outcome.ok).toBe(false);
230
+ expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
231
+ });
232
+ });
233
+
234
+ test(`${op}: a person at a shell can still approve (chant#3513)`, async () => {
235
+ await withTestDir(async (dir) => {
236
+ await repoWithGate(dir);
237
+ process.chdir(dir);
238
+ const outcome = await recordGateApproval(op, gate, { origin: "cli", actor: "alex", cwd: dir });
239
+ expect(outcome.ok).toBe(true);
240
+ });
241
+ });
242
+ }
169
243
  });
@@ -282,6 +282,9 @@ export interface ParsedArgs {
282
282
  * the identical command finishes what an interrupted attempt left, because
283
283
  * the plan is narrowed by this file before the gate is decided and the
284
284
  * digest is carried rather than recomputed, so the approval still stands.
285
+ * `chant components pr-apply --resume <file>` (#3464) keeps the same record
286
+ * with the approved change set in it, and finishes an apply that failed
287
+ * partway under that approval.
285
288
  */
286
289
  resume?: string;
287
290
  /** `chant audit --tier merge-worthy|all` */
@@ -446,6 +449,8 @@ export interface ParsedArgs {
446
449
  /**
447
450
  * `chant workspace build|lint|audit|graph --member <name>` (#2537): run only
448
451
  * the named members or example groups. Repeatable, and a comma list works too.
452
+ * `chant components pr-plan|pr-apply --member <name>` (#3465) takes exactly
453
+ * one: the workspace member whose pull-request loop runs.
449
454
  */
450
455
  members?: string[];
451
456
  /**
@@ -524,6 +529,8 @@ export interface ParsedArgs {
524
529
  foldRankCollapsedFile?: string;
525
530
  /** `chant build --param name=value` (#1064) — repeatable. Bound to `params.<name>` (`@intentius/chant/params`) for source to reference, after validation against `chant.config.ts`'s declared `buildParams`. Highest precedence over `--params-file`/a declared `env` mapping/the declared `default`. */
526
531
  param?: string[];
532
+ /** `chant build --lexicon-output <lexicon>=<path>` — repeatable. Writes that lexicon's output to its own file, so one project can declare several lexicons (otel and prometheus) without their outputs sharing a file. Lexicons not named are built into `--output` when given, and skipped otherwise. */
533
+ lexiconOutput?: string[];
527
534
  /** `chant build --params-file <path>` (#1064) — a JSON file of `{ "name": value }` build-time parameter values. Second precedence, after `--param`. */
528
535
  paramsFile?: string;
529
536
  /** `chant graph --components --format ir --projection <lexicon>` (#989) — add
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The files a change touched (#3183), as a project in a workspace member
3
+ * sees them (#3465): every changed file in the repository, relative to the
4
+ * member's directory, so one outside it reads `../...`.
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { changedFilesBetween } from "./changed-files";
13
+
14
+ const repo = mkdtempSync(join(tmpdir(), "chant-changed-files-"));
15
+ afterAll(() => rmSync(repo, { recursive: true, force: true }));
16
+
17
+ const git = (...args: string[]): string =>
18
+ execFileSync("git", args, {
19
+ cwd: repo,
20
+ encoding: "utf8",
21
+ env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" },
22
+ }).trim();
23
+
24
+ function put(path: string, text: string): void {
25
+ mkdirSync(dirname(join(repo, path)), { recursive: true });
26
+ writeFileSync(join(repo, path), text);
27
+ }
28
+
29
+ git("init", "-q", "-b", "main");
30
+ put("infra/network/roots/vpc/main.tf", "");
31
+ put("shared/modules/base/main.tf", "");
32
+ put("apps/web/main.tf", "");
33
+ git("add", "-A");
34
+ git("commit", "-q", "-m", "base");
35
+ const base = git("rev-parse", "HEAD");
36
+ put("infra/network/roots/vpc/main.tf", "# changed\n");
37
+ put("shared/modules/base/main.tf", "# changed\n");
38
+ put("apps/web/main.tf", "# changed\n");
39
+ git("add", "-A");
40
+ git("commit", "-q", "-m", "change");
41
+
42
+ describe("changedFilesBetween", () => {
43
+ test("at the repository root, every file by its repository path", async () => {
44
+ expect(await changedFilesBetween(repo, base)).toEqual([
45
+ "apps/web/main.tf",
46
+ "infra/network/roots/vpc/main.tf",
47
+ "shared/modules/base/main.tf",
48
+ ]);
49
+ });
50
+
51
+ test("in a member's directory, every file relative to the member, outside ones included", async () => {
52
+ expect(await changedFilesBetween(join(repo, "infra/network"), base)).toEqual([
53
+ "../../apps/web/main.tf",
54
+ "../../shared/modules/base/main.tf",
55
+ "roots/vpc/main.tf",
56
+ ]);
57
+ });
58
+ });
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { execFile } from "node:child_process";
12
+ import { posix } from "node:path";
12
13
  import { promisify } from "node:util";
13
14
  import type { LexiconPlugin } from "../lexicon";
14
15
 
@@ -43,12 +44,26 @@ export async function resolveCommit(cwd: string, ref: string): Promise<string> {
43
44
  }
44
45
 
45
46
  /**
46
- * Files changed between `base` and `head`, relative to `cwd` and limited to
47
- * it, with `/` separators. A rename counts as its old and its new path.
47
+ * Files changed between `base` and `head`, relative to `cwd`, with `/`
48
+ * separators. A rename counts as its old and its new path.
49
+ *
50
+ * Every changed file in the repository is listed, not only those under
51
+ * `cwd`: one outside it reads `../...`. A project in a workspace member
52
+ * (#3465) can call a module that lives in another member or beside the
53
+ * members, and a change there reaches it; `changedUnits` decides which of
54
+ * these paths matter.
48
55
  */
49
56
  export async function changedFilesBetween(cwd: string, base: string, head = "HEAD"): Promise<string[]> {
50
- const out = await git(cwd, ["diff", "--name-only", "-z", "--no-renames", "--relative", base, head]);
51
- return out.split("\0").filter(Boolean).sort();
57
+ const prefix = (await git(cwd, ["rev-parse", "--show-prefix"])).trim().replace(/\/+$/, "");
58
+ const out = await git(cwd, ["diff", "--name-only", "-z", "--no-renames", "--no-relative", base, head]);
59
+ return out
60
+ .split("\0")
61
+ .filter(Boolean)
62
+ .map((file) => {
63
+ const rel = posix.relative(prefix, file);
64
+ return rel === "" ? "." : rel;
65
+ })
66
+ .sort();
52
67
  }
53
68
 
54
69
  /** The union of every plugin's `changedUnits` answer, sorted. */
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * The attempt record keeps what steps carried per member (#3459), so the
3
- * next attempt, in this process or in the next CI job, hands it back.
3
+ * next attempt, in this process or in the next CI job, hands it back. It
4
+ * also keeps the change set a pull request's apply approved (#3464), so a
5
+ * re-run finishes it under that approval.
4
6
  */
5
7
 
6
8
  import { describe, expect, test } from "vitest";
7
9
  import { mkdtempSync, writeFileSync } from "node:fs";
8
10
  import { tmpdir } from "node:os";
9
11
  import { join } from "node:path";
10
- import { readFanOutAttempt, writeFanOutAttempt } from "./fan-out-record";
12
+ import { readFanOutAttempt, writeFanOutAttempt, type PrApplyRecord } from "./fan-out-record";
13
+ import { composeChangeSet } from "../change-set";
11
14
 
12
15
  describe("carried", () => {
13
16
  test("round-trips through the record", () => {
@@ -25,3 +28,45 @@ describe("carried", () => {
25
28
  expect(readFanOutAttempt(join(dir, "b.json"))?.carried).toBeUndefined();
26
29
  });
27
30
  });
31
+
32
+ describe("prApply (#3464)", () => {
33
+ const changeSet = composeChangeSet([
34
+ {
35
+ member: { member: "net", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${"1".repeat(64)}`, holes: [] },
36
+ entries: [],
37
+ },
38
+ ]);
39
+ const prApply: PrApplyRecord = {
40
+ op: "pr-12",
41
+ gate: "pr-apply",
42
+ head: "2".repeat(40),
43
+ digest: changeSet.digest,
44
+ approvedBy: ["github:alice"],
45
+ members: [
46
+ {
47
+ member: "net",
48
+ component: "net",
49
+ planDigest: `jcs1-sha256:${"1".repeat(64)}`,
50
+ status: "planned",
51
+ counts: { create: 0, update: 0, replace: 0, delete: 0 },
52
+ },
53
+ ],
54
+ changeSet,
55
+ planOutputs: { net: { cidr: "10.0.0.0/16" } },
56
+ };
57
+
58
+ test("round-trips through the record", () => {
59
+ const path = join(mkdtempSync(join(tmpdir(), "fan-out-record-")), "pr-apply.json");
60
+ writeFanOutAttempt(path, { digest: "d", completed: ["net"], failed: [], outputs: { net: { cidr: "10.0.0.0/16" } }, prApply });
61
+ expect(readFanOutAttempt(path)?.prApply).toEqual(prApply);
62
+ });
63
+
64
+ test("an entry whose change set does not carry its digest, or that misses a field, reads as none", () => {
65
+ const dir = mkdtempSync(join(tmpdir(), "fan-out-record-"));
66
+ const base = { digest: "d", completed: [], failed: [], outputs: {} };
67
+ writeFileSync(join(dir, "a.json"), JSON.stringify({ ...base, prApply: { ...prApply, digest: `jcs1-sha256:${"9".repeat(64)}` } }));
68
+ writeFileSync(join(dir, "b.json"), JSON.stringify({ ...base, prApply: { ...prApply, head: undefined } }));
69
+ writeFileSync(join(dir, "c.json"), JSON.stringify({ ...base, prApply: "pr-12" }));
70
+ for (const name of ["a.json", "b.json", "c.json"]) expect(readFanOutAttempt(join(dir, name))?.prApply).toBeUndefined();
71
+ });
72
+ });