@intentius/chant 0.105.1 → 0.106.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 (111) hide show
  1. package/dist/change-set.d.ts +30 -8
  2. package/dist/change-set.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts +11 -0
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/lint.d.ts.map +1 -1
  6. package/dist/cli/handlers/build.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.d.ts +16 -2
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.d.ts.map +1 -1
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  15. package/dist/cli/registry.d.ts +7 -0
  16. package/dist/cli/registry.d.ts.map +1 -1
  17. package/dist/components/changed-files.d.ts +8 -2
  18. package/dist/components/changed-files.d.ts.map +1 -1
  19. package/dist/components/fan-out-record.d.ts +24 -0
  20. package/dist/components/fan-out-record.d.ts.map +1 -1
  21. package/dist/components/pr-pipeline.d.ts +67 -4
  22. package/dist/components/pr-pipeline.d.ts.map +1 -1
  23. package/dist/components/pr-run.d.ts +94 -3
  24. package/dist/components/pr-run.d.ts.map +1 -1
  25. package/dist/gated-waves.d.ts +4 -1
  26. package/dist/gated-waves.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +13 -2
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  30. package/dist/op/gate-name.d.ts +7 -0
  31. package/dist/op/gate-name.d.ts.map +1 -1
  32. package/dist/op/gate-summary.d.ts +2 -0
  33. package/dist/op/gate-summary.d.ts.map +1 -1
  34. package/dist/op/gate.d.ts +10 -2
  35. package/dist/op/gate.d.ts.map +1 -1
  36. package/dist/op/index.d.ts +2 -2
  37. package/dist/op/index.d.ts.map +1 -1
  38. package/dist/op/local-executor.d.ts.map +1 -1
  39. package/dist/op/local-output.d.ts +2 -2
  40. package/dist/op/local-output.d.ts.map +1 -1
  41. package/dist/op/runtime.d.ts +29 -4
  42. package/dist/op/runtime.d.ts.map +1 -1
  43. package/dist/op/runtimes/local.d.ts.map +1 -1
  44. package/dist/pr-loop.d.ts +32 -6
  45. package/dist/pr-loop.d.ts.map +1 -1
  46. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  47. package/dist/workspace/member-pipeline.d.ts +15 -1
  48. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
  51. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  52. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  53. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  54. package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
  55. package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
  56. package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
  57. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
  58. package/src/build.test.ts +1 -1
  59. package/src/build.ts +1 -1
  60. package/src/change-set.test.ts +42 -12
  61. package/src/change-set.ts +50 -11
  62. package/src/cli/commands/build.ts +33 -14
  63. package/src/cli/commands/lint.ts +34 -1
  64. package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
  65. package/src/cli/handlers/build.ts +68 -0
  66. package/src/cli/handlers/fan-out.test.ts +12 -1
  67. package/src/cli/handlers/fan-out.ts +7 -3
  68. package/src/cli/handlers/operator.ts +1 -2
  69. package/src/cli/handlers/pr.ts +223 -35
  70. package/src/cli/handlers/promote.test.ts +15 -1
  71. package/src/cli/handlers/promote.ts +5 -3
  72. package/src/cli/handlers/run.test.ts +215 -1
  73. package/src/cli/handlers/run.ts +36 -15
  74. package/src/cli/main.ts +13 -1
  75. package/src/cli/mcp/never-over-mcp.test.ts +120 -0
  76. package/src/cli/mcp/op-approve-origin.test.ts +1 -1
  77. package/src/cli/mcp/op-tools.ts +14 -2
  78. package/src/cli/mcp/server.test.ts +2 -1
  79. package/src/cli/registry.ts +7 -0
  80. package/src/components/changed-files.test.ts +58 -0
  81. package/src/components/changed-files.ts +19 -4
  82. package/src/components/fan-out-record.test.ts +47 -2
  83. package/src/components/fan-out-record.ts +53 -0
  84. package/src/components/pr-pipeline.ts +96 -6
  85. package/src/components/pr-run.test.ts +311 -4
  86. package/src/components/pr-run.ts +198 -5
  87. package/src/gated-waves.test.ts +11 -0
  88. package/src/gated-waves.ts +7 -2
  89. package/src/lexicon.ts +13 -1
  90. package/src/lifecycle/gate-ledger.ts +4 -3
  91. package/src/op/gate-name.ts +17 -0
  92. package/src/op/gate-summary.test.ts +12 -0
  93. package/src/op/gate-summary.ts +3 -1
  94. package/src/op/gate.test.ts +15 -1
  95. package/src/op/gate.ts +21 -4
  96. package/src/op/index.ts +2 -2
  97. package/src/op/local-executor.test.ts +35 -0
  98. package/src/op/local-executor.ts +2 -2
  99. package/src/op/local-output.test.ts +20 -0
  100. package/src/op/local-output.ts +4 -4
  101. package/src/op/runtime.ts +31 -4
  102. package/src/op/runtimes/local.test.ts +15 -0
  103. package/src/op/runtimes/local.ts +3 -1
  104. package/src/pr-loop.test.ts +41 -1
  105. package/src/pr-loop.ts +41 -10
  106. package/src/workspace/change-set.schema.json +1 -1
  107. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  108. package/src/workspace/lineage-upgrade.test.ts +40 -0
  109. package/src/workspace/member-pipeline.test.ts +28 -0
  110. package/src/workspace/member-pipeline.ts +21 -3
  111. 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: [{
@@ -448,6 +468,57 @@ describe("runOp: a steward's turn (#2750)", () => {
448
468
  expect(acquireStewardTurnMock).not.toHaveBeenCalled();
449
469
  });
450
470
 
471
+ // #3555: a runtime that carries the work item (fountain) is handed what the
472
+ // caller named, and nothing else; one that does not refuses --work by name.
473
+ function leasedOp(name: string) {
474
+ return [name, { config: { name, overview: `${name} overview`, phases: [], workLease: {} } }] as const;
475
+ }
476
+
477
+ test("--work on a runtime that carries work: start gets the item and holder the caller named (#3555)", async () => {
478
+ const runtime = {
479
+ name: "fountain",
480
+ carriesWork: true,
481
+ start: vi.fn(async (op: { name: string }, _opts: { work?: { item?: string; holder?: string } }) => ({
482
+ op: op.name,
483
+ runId: "f-1",
484
+ result: async () => ({ op: op.name, runId: "f-1", state: "completed" as const, startedAt: "t", endedAt: "t" }),
485
+ })),
486
+ status: vi.fn(), log: vi.fn(), list: vi.fn(), cancel: vi.fn(),
487
+ };
488
+ discoverOpsMock.mockResolvedValue({ ops: new Map([leasedOp("leased")]), errors: [] });
489
+ discoverStewardsMock.mockResolvedValueOnce(stewardOwning("leased", "box-steward", "fountain"));
490
+ loadChantConfigMock.mockResolvedValue({ config: { lexicons: ["fountain"] } });
491
+ makeStdoutSpy();
492
+ const exit = await runOp({
493
+ args: makeArgs({ path: "leased", on: "fountain", work: "ISSUE-7" }),
494
+ plugins: [{ name: "fountain", opRuntime: runtime } as never], serializers: [],
495
+ });
496
+
497
+ expect(exit).toBe(0);
498
+ expect(runtime.start).toHaveBeenCalledTimes(1);
499
+ expect(runtime.start.mock.calls[0][1].work).toEqual({ item: "ISSUE-7" });
500
+ });
501
+
502
+ test("--work on a runtime that does not carry work: exit 1 naming the runtime, nothing started (#3555)", async () => {
503
+ const runtime = {
504
+ name: "fountain",
505
+ start: vi.fn(),
506
+ status: vi.fn(), log: vi.fn(), list: vi.fn(), cancel: vi.fn(),
507
+ };
508
+ discoverOpsMock.mockResolvedValue({ ops: new Map([leasedOp("leased")]), errors: [] });
509
+ discoverStewardsMock.mockResolvedValueOnce(stewardOwning("leased", "box-steward", "fountain"));
510
+ loadChantConfigMock.mockResolvedValue({ config: { lexicons: ["fountain"] } });
511
+ const stderr = makeStderrSpy();
512
+ const exit = await runOp({
513
+ args: makeArgs({ path: "leased", on: "fountain", work: "ISSUE-7" }),
514
+ plugins: [{ name: "fountain", opRuntime: runtime } as never], serializers: [],
515
+ });
516
+
517
+ expect(exit).toBe(1);
518
+ expect(runtime.start).not.toHaveBeenCalled();
519
+ expect(stderr.join("\n")).toContain('--work and --holder run on the local runtime; "fountain" takes the work lease');
520
+ });
521
+
451
522
  test("local form, the turn lease's own acquire throws (a stale .lock): refused as a lease error, not an uncaught exception, and nothing runs", async () => {
452
523
  discoverOpsMock.mockResolvedValue({
453
524
  ops: new Map([localOp("release", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
@@ -616,6 +687,25 @@ describe("runOp: --gated-exit (#2243)", () => {
616
687
  expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod");
617
688
  });
618
689
 
690
+ test("a sealed gate: the --json approve field and the summary carry --sign (#3521)", async () => {
691
+ const file = summaryFile();
692
+ process.env.GITHUB_STEP_SUMMARY = file;
693
+ sealedGates.add("approve-prod");
694
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
695
+ const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
696
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
697
+ makeStderrSpy();
698
+
699
+ try {
700
+ await runOp({ args: makeArgs({ path: "gated", gatedExit: 0, json: true }), plugins: [], serializers: [] });
701
+ const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => String(c[0])).join("").trim());
702
+ expect(parsed.approve).toBe("chant approve gated approve-prod --sign");
703
+ expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod --sign --approver <you>");
704
+ } finally {
705
+ sealedGates.clear();
706
+ }
707
+ });
708
+
619
709
  test("a run that fails for any other reason is still red under the flag", async () => {
620
710
  discoverOpsMock.mockResolvedValue({
621
711
  // atMostOnce: one attempt. The default profile retries twice with 5s and
@@ -776,6 +866,45 @@ describe("runOp: --sandbox with a policyGate step (chant #2003)", () => {
776
866
  expect(seen).not.toHaveProperty("profile");
777
867
  });
778
868
 
869
+ // #3521: a hosted runtime that reports only a gate name gets a hint whose
870
+ // approve command names `--sign` when the workspace seals that gate.
871
+ describe("a runtime that reports a gated state with only the gate name (#3521)", () => {
872
+ async function runGatedOnStub(): Promise<string> {
873
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("prod-apply")]), errors: [] });
874
+ const start = vi.fn(async () => ({
875
+ op: "prod-apply",
876
+ runId: "stub-1",
877
+ result: async () => ({
878
+ op: "prod-apply", runId: "stub-1", state: "gated", startedAt: "2026-01-01T00:00:00.000Z",
879
+ gate: { name: "approve-prod", since: "2026-01-01T00:00:00.000Z" },
880
+ }),
881
+ }));
882
+ makeStdoutSpy();
883
+ const stderr = makeStderrSpy();
884
+ const exit = await runOp({
885
+ args: makeArgs({ path: "prod-apply", on: "stub" }),
886
+ plugins: [{ name: "stub", opRuntime: { name: "stub", start } } as never], serializers: [],
887
+ });
888
+ expect(exit).toBe(3);
889
+ return stderr.join("\n");
890
+ }
891
+
892
+ test("unsealed gate: the hint's approve command has no --sign", async () => {
893
+ const out = await runGatedOnStub();
894
+ expect(out).toContain("Record the resolution with: chant approve prod-apply approve-prod");
895
+ expect(out).not.toContain("--sign");
896
+ });
897
+
898
+ test("sealed gate: the hint's approve command ends in --sign", async () => {
899
+ sealedGates.add("approve-prod");
900
+ try {
901
+ expect(await runGatedOnStub()).toContain("Record the resolution with: chant approve prod-apply approve-prod --sign");
902
+ } finally {
903
+ sealedGates.clear();
904
+ }
905
+ });
906
+ });
907
+
779
908
  test("--sandbox on an Op with no policyGate step is untouched", async () => {
780
909
  discoverOpsMock.mockResolvedValue({
781
910
  ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
@@ -878,6 +1007,29 @@ describe("run subcommands on the resolved runtime", () => {
878
1007
  expect((runtime.start.mock.calls[0][0] as { name: string }).name).toBe("hello");
879
1008
  });
880
1009
 
1010
+ test("--param reaches the runtime's start as params (#3539)", async () => {
1011
+ const runtime = makeStubRuntime();
1012
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
1013
+ makeStdoutSpy();
1014
+ const exit = await runOp({
1015
+ args: makeArgs({ path: "hello", on: "stub", env: "staging", param: ["agent=ops-bot", "tier=gold plus"] }),
1016
+ plugins: [stubPlugin(runtime)], serializers: [],
1017
+ });
1018
+ expect(exit).toBe(0);
1019
+ expect((runtime.start.mock.calls[0] as unknown[])[1]).toMatchObject({
1020
+ env: "staging",
1021
+ params: { agent: "ops-bot", tier: "gold plus" },
1022
+ });
1023
+ });
1024
+
1025
+ test("no --param hands start no params", async () => {
1026
+ const runtime = makeStubRuntime();
1027
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
1028
+ makeStdoutSpy();
1029
+ await runOp({ args: makeArgs({ path: "hello", on: "stub" }), plugins: [stubPlugin(runtime)], serializers: [] });
1030
+ expect((runtime.start.mock.calls[0] as unknown[])[1]).not.toHaveProperty("params");
1031
+ });
1032
+
881
1033
  test("run status/log/list/cancel --on stub reach their methods", async () => {
882
1034
  const runtime = makeStubRuntime();
883
1035
  discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
@@ -1060,6 +1212,21 @@ describe("runOpApprove", () => {
1060
1212
  expect(stderr.join("\n")).toContain('Runtime "stub" was notified');
1061
1213
  });
1062
1214
 
1215
+ test("--env on the approve line reaches resolveGate (#3539)", async () => {
1216
+ const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" };
1217
+ recordGateApprovalMock.mockResolvedValue({ ok: true, record });
1218
+ const resolveGate = vi.fn(async () => undefined);
1219
+ makeStderrSpy();
1220
+
1221
+ const exit = await runOpApprove({
1222
+ args: makeArgs({ on: "stub", extraPositional: "alb-deploy", extraPositional2: "release", approver: "alex", env: "prod" }),
1223
+ plugins: [{ name: "stub", opRuntime: { name: "stub", resolveGate } } as never], serializers: [],
1224
+ });
1225
+
1226
+ expect(exit).toBe(0);
1227
+ expect(resolveGate).toHaveBeenCalledWith("alb-deploy", "release", record, { env: "prod" });
1228
+ });
1229
+
1063
1230
  test("a provider with no resolveGate still records the fact and exits 0", async () => {
1064
1231
  const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "ci", timestamp: "2026-01-01T00:00:00.000Z" };
1065
1232
  recordGateApprovalMock.mockResolvedValue({ ok: true, record });
@@ -1141,6 +1308,53 @@ describe("runOp dispatcher: --components routes to runOpComponents", () => {
1141
1308
  });
1142
1309
  });
1143
1310
 
1311
+ // #3521: a gated component's approve line and mismatch line name `--sign` on
1312
+ // a sealed gate and leave it off otherwise.
1313
+ describe("runOp --components gate hints (#3521)", () => {
1314
+ beforeEach(() => {
1315
+ runComponentsMock.mockReset();
1316
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
1317
+ maybeRecordAutoReleaseMock.mockReset().mockResolvedValue({ recorded: false, reason: "no-digest" });
1318
+ maybePersistBuildManifestMock.mockReset().mockResolvedValue({ persisted: false, reason: "no-manifest" });
1319
+ });
1320
+
1321
+ async function runGatedComponent(): Promise<string> {
1322
+ runComponentsMock.mockResolvedValue({
1323
+ success: false,
1324
+ selected: ["svc"],
1325
+ gated: {
1326
+ component: "svc",
1327
+ gate: {
1328
+ version: 1, kind: "pending", op: "svc", gate: "approve-prod", environment: "prod",
1329
+ timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
1330
+ },
1331
+ mismatch: { approved: "sha256:aaa", planned: "sha256:bbb", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z", environment: "prod" },
1332
+ },
1333
+ });
1334
+ const stderr = makeStderrSpy();
1335
+ expect(await runOp({ args: makeArgs({ path: "svc", components: true }), plugins: [], serializers: [] })).toBe(3);
1336
+ return stderr.join("\n");
1337
+ }
1338
+
1339
+ test("unsealed gate: the approve line and the mismatch line carry no --sign", async () => {
1340
+ const out = await runGatedComponent();
1341
+ expect(out).toContain("approve : chant approve svc approve-prod --env prod");
1342
+ expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod");
1343
+ expect(out).not.toContain("--sign");
1344
+ });
1345
+
1346
+ test("sealed gate: the approve line and the mismatch line end in --sign", async () => {
1347
+ sealedGates.add("approve-prod");
1348
+ try {
1349
+ const out = await runGatedComponent();
1350
+ expect(out).toContain("approve : chant approve svc approve-prod --env prod --sign");
1351
+ expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod --sign");
1352
+ } finally {
1353
+ sealedGates.clear();
1354
+ }
1355
+ });
1356
+ });
1357
+
1144
1358
  describe("runOpComponents", () => {
1145
1359
  beforeEach(() => {
1146
1360
  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,
@@ -1074,8 +1078,8 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1074
1078
 
1075
1079
  // The work lease (#2748). `--work` names the item an Op with a work lease
1076
1080
  // runs under; it is taken where the run executes, which for a hosted
1077
- // runtime is not this process.
1078
- const work = await resolveRunWork(ctx, config, runtime.name);
1081
+ // runtime that carries it (fountain, #3555) is not this process.
1082
+ const work = await resolveRunWork(ctx, config, runtime);
1079
1083
  if (work === null) {
1080
1084
  await stewardGate?.release?.();
1081
1085
  return 1;
@@ -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) }));
@@ -1197,12 +1208,17 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1197
1208
  * `null` after printing why the run is refused. The holder is `--holder`, or,
1198
1209
  * for an Op a steward lists, `<steward>/<op>@<process>` so `workspace status`
1199
1210
  * shows the lease beside the steward's Ops, or this process's id.
1211
+ *
1212
+ * A non-local runtime takes `--work` only when it declares `carriesWork`
1213
+ * (#3555), and then gets just what the caller named: the holder is chosen
1214
+ * where the run executes, not after this process.
1200
1215
  */
1201
1216
  async function resolveRunWork(
1202
1217
  ctx: CommandContext,
1203
1218
  config: OpConfig,
1204
- runtime: string,
1219
+ provider: Pick<OpRuntimeProvider, "name" | "carriesWork">,
1205
1220
  ): Promise<{ item?: string; holder?: string } | undefined | null> {
1221
+ const runtime = provider.name;
1206
1222
  const item = ctx.args.work;
1207
1223
  if (!config.workLease) {
1208
1224
  if (item === undefined) return undefined;
@@ -1212,13 +1228,18 @@ async function resolveRunWork(
1212
1228
  }));
1213
1229
  return null;
1214
1230
  }
1215
- if (runtime !== "local" && (item !== undefined || ctx.args.holder !== undefined)) {
1231
+ if (runtime !== "local" && provider.carriesWork !== true && (item !== undefined || ctx.args.holder !== undefined)) {
1216
1232
  console.error(formatError({
1217
1233
  message: `--work and --holder run on the local runtime; "${runtime}" takes the work lease where it executes the run`,
1218
1234
  hint: "Omit --on, or name the item in the Op's workLease.",
1219
1235
  }));
1220
1236
  return null;
1221
1237
  }
1238
+ if (runtime !== "local" && provider.carriesWork === true) {
1239
+ const holderArg = ctx.args.holder;
1240
+ if (item === undefined && holderArg === undefined) return undefined;
1241
+ return { ...(item !== undefined ? { item } : {}), ...(holderArg !== undefined ? { holder: holderArg } : {}) };
1242
+ }
1222
1243
  let holder = ctx.args.holder;
1223
1244
  if (holder === undefined) {
1224
1245
  try {
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
@@ -100,6 +100,52 @@ describe("neverOverMcp (chant#3447)", () => {
100
100
  await expect(createOpReportTool().handler({ name: "tf-apply" })).resolves.toContain("# tf-apply");
101
101
  });
102
102
 
103
+ // chant#3555: op-approve hands the runtime the env it was given, as `chant
104
+ // run approve --env` does, and nothing when it was given none.
105
+ async function repoWithPlainGate(dir: string): Promise<void> {
106
+ git(["init", "-q", "-b", "main"], dir);
107
+ git(["config", "user.email", "t@chant.dev"], dir);
108
+ git(["config", "user.name", "T"], dir);
109
+ writeFileSync(join(dir, "README.md"), "x\n");
110
+ git(["add", "."], dir);
111
+ git(["commit", "-q", "-m", "init"], dir);
112
+ await appendPendingGate(
113
+ {
114
+ op: "plain-op",
115
+ gate: "release",
116
+ timestamp: "2026-01-01T00:00:00.000Z",
117
+ origin: "cli",
118
+ expiresAt: "2999-01-01T00:00:00.000Z",
119
+ planDigest: `sha256:${"d".repeat(64)}`,
120
+ },
121
+ { cwd: dir },
122
+ );
123
+ }
124
+
125
+ test("op-approve passes env to the runtime's resolveGate (chant#3555)", async () => {
126
+ await withTestDir(async (dir) => {
127
+ await repoWithPlainGate(dir);
128
+ process.chdir(dir);
129
+ const result = (await createOpApproveTool().handler({ name: "plain-op", gate: "release", env: "prod" })) as {
130
+ runtimeNotified: boolean;
131
+ };
132
+ expect(result.runtimeNotified).toBe(true);
133
+ expect(resolveGate).toHaveBeenCalledWith("plain-op", "release", expect.objectContaining({ gate: "release" }), {
134
+ env: "prod",
135
+ });
136
+ });
137
+ });
138
+
139
+ test("op-approve with no env leaves the runtime to keep the gated run's (chant#3555)", async () => {
140
+ await withTestDir(async (dir) => {
141
+ await repoWithPlainGate(dir);
142
+ process.chdir(dir);
143
+ await createOpApproveTool().handler({ name: "plain-op", gate: "release" });
144
+ expect(resolveGate).toHaveBeenCalledTimes(1);
145
+ expect(resolveGate.mock.calls[0]).toHaveLength(3);
146
+ });
147
+ });
148
+
103
149
  async function repoWithFanOutGate(dir: string): Promise<void> {
104
150
  git(["init", "-q", "-b", "main"], dir);
105
151
  git(["config", "user.email", "t@chant.dev"], dir);
@@ -166,4 +212,78 @@ describe("neverOverMcp (chant#3447)", () => {
166
212
  expect(outcome.ok).toBe(true);
167
213
  });
168
214
  });
215
+
216
+ // chant#3513: workspace-upgrade and the pull-request gate are undiscovered too.
217
+ const gateOnlyOps: Array<{ op: string; gate: string }> = [
218
+ { op: "workspace-upgrade", gate: "." },
219
+ { op: "pr-12", gate: "pr-apply" },
220
+ ];
221
+
222
+ for (const { op, gate } of gateOnlyOps) {
223
+ async function repoWithGate(dir: string): Promise<void> {
224
+ git(["init", "-q", "-b", "main"], dir);
225
+ git(["config", "user.email", "t@chant.dev"], dir);
226
+ git(["config", "user.name", "T"], dir);
227
+ writeFileSync(join(dir, "README.md"), "x\n");
228
+ git(["add", "."], dir);
229
+ git(["commit", "-q", "-m", "init"], dir);
230
+ await appendPendingGate(
231
+ {
232
+ op,
233
+ gate,
234
+ timestamp: "2026-01-01T00:00:00.000Z",
235
+ origin: "cli",
236
+ expiresAt: "2999-01-01T00:00:00.000Z",
237
+ planDigest: `sha256:${"c".repeat(64)}`,
238
+ },
239
+ { cwd: dir },
240
+ );
241
+ }
242
+
243
+ test(`${op}: the gate record carries neverOverMcp (chant#3513)`, async () => {
244
+ await withTestDir(async (dir) => {
245
+ await repoWithGate(dir);
246
+ const { pending } = await readGateLedger(op, { cwd: dir });
247
+ expect(pending[0].neverOverMcp).toBe(true);
248
+ });
249
+ });
250
+
251
+ test(`${op}: op-approve over MCP is refused and the ledger is unchanged (chant#3513)`, async () => {
252
+ await withTestDir(async (dir) => {
253
+ await repoWithGate(dir);
254
+ const before = await readGateLedger(op, { cwd: dir });
255
+ process.chdir(dir);
256
+ await expect(createOpApproveTool().handler({ name: op, gate })).rejects.toThrow(
257
+ /neverOverMcp.*whichever channel/s,
258
+ );
259
+ expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
260
+ expect(resolveGate).not.toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ test(`${op}: ACP is refused even with the same-origin override (chant#3513)`, async () => {
265
+ await withTestDir(async (dir) => {
266
+ await repoWithGate(dir);
267
+ const before = await readGateLedger(op, { cwd: dir });
268
+ process.chdir(dir);
269
+ const outcome = await recordGateApproval(op, gate, {
270
+ origin: "acp",
271
+ actor: "agent",
272
+ allowSameOrigin: true,
273
+ cwd: dir,
274
+ });
275
+ expect(outcome.ok).toBe(false);
276
+ expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
277
+ });
278
+ });
279
+
280
+ test(`${op}: a person at a shell can still approve (chant#3513)`, async () => {
281
+ await withTestDir(async (dir) => {
282
+ await repoWithGate(dir);
283
+ process.chdir(dir);
284
+ const outcome = await recordGateApproval(op, gate, { origin: "cli", actor: "alex", cwd: dir });
285
+ expect(outcome.ok).toBe(true);
286
+ });
287
+ });
288
+ }
169
289
  });
@@ -37,7 +37,7 @@ describe("op-approve on the MCP channel (chant#2384)", () => {
37
37
  // The rest of the tool is unchanged — this narrows one field, it does not
38
38
  // remove the tool. The narrower alternative in the issue was to drop
39
39
  // op-approve entirely; this keeps it useful for the cross-channel case.
40
- expect(Object.keys(schema.properties).sort()).toEqual(["gate", "name", "note", "runtime", "url"]);
40
+ expect(Object.keys(schema.properties).sort()).toEqual(["env", "gate", "name", "note", "runtime", "url"]);
41
41
  expect(schema.required.sort()).toEqual(["gate", "name"]);
42
42
  });
43
43
 
@@ -136,6 +136,9 @@ export function createOpRunTool(): ToolRegistration {
136
136
  type: "object",
137
137
  properties: {
138
138
  name: { type: "string", description: "Op name (e.g. alb-deploy)" },
139
+ // chant#3560 — what `chant run --env` passes: the environment the
140
+ // run targets. Omitted, the runtime uses its own default.
141
+ env: { type: "string", description: "Environment the run targets (defaults to the runtime's own)" },
139
142
  runtime: RUNTIME_PARAM,
140
143
  },
141
144
  required: ["name"],
@@ -153,7 +156,8 @@ export function createOpRunTool(): ToolRegistration {
153
156
  }
154
157
 
155
158
  const runtime = await runtimeFor(params.runtime);
156
- const handle = await runtime.start(ops.get(name)!.config, {});
159
+ const env = typeof params.env === "string" && params.env !== "" ? params.env : undefined;
160
+ const handle = await runtime.start(ops.get(name)!.config, env !== undefined ? { env } : {});
157
161
  const status = await handle.result();
158
162
 
159
163
  return {
@@ -227,6 +231,10 @@ export function createOpApproveTool(): ToolRegistration {
227
231
  // the ledger recorded it indistinguishably from a name a person gave.
228
232
  note: { type: "string", description: "Free-text context recorded on the resolution" },
229
233
  url: { type: "string", description: "Absolute http/https URL this resolution happened at" },
234
+ // chant#3555 — what `chant run approve --env` passes: the
235
+ // environment the woken run runs in. Omitted, the runtime keeps the
236
+ // gated run's.
237
+ env: { type: "string", description: "Environment the woken run runs in (defaults to the gated run's)" },
230
238
  runtime: RUNTIME_PARAM,
231
239
  },
232
240
  required: ["name", "gate"],
@@ -256,7 +264,11 @@ export function createOpApproveTool(): ToolRegistration {
256
264
  );
257
265
  }
258
266
 
259
- if (runtime.resolveGate) await runtime.resolveGate(name, gate, outcome.record);
267
+ if (runtime.resolveGate) {
268
+ const env = typeof params.env === "string" && params.env !== "" ? params.env : undefined;
269
+ if (env !== undefined) await runtime.resolveGate(name, gate, outcome.record, { env });
270
+ else await runtime.resolveGate(name, gate, outcome.record);
271
+ }
260
272
 
261
273
  return {
262
274
  op: name,
@@ -499,12 +499,13 @@ describe("McpServer", () => {
499
499
  expect(props.runtime).toBeDefined();
500
500
  });
501
501
 
502
- test("op-run has name (required) and runtime", async () => {
502
+ test("op-run has name (required), env and runtime", async () => {
503
503
  const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
504
504
  const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
505
505
  const tool = result.tools.find((t) => t.name === "op-run")!;
506
506
  const props = tool.inputSchema.properties as Record<string, unknown>;
507
507
  expect(props.name).toBeDefined();
508
+ expect(props.env).toBeDefined();
508
509
  expect(props.runtime).toBeDefined();
509
510
  expect(tool.inputSchema.required).toContain("name");
510
511
  });