@intentius/chant 0.50.0 → 0.52.0

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 (75) hide show
  1. package/dist/cli/build-options.d.ts +68 -0
  2. package/dist/cli/build-options.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/op-progress.d.ts +57 -0
  6. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  7. package/dist/cli/handlers/run-client.d.ts +21 -1
  8. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  9. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  10. package/dist/cli/handlers/run.d.ts +0 -21
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/search.d.ts +22 -0
  13. package/dist/cli/handlers/search.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  16. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  17. package/dist/cli/registry.d.ts +33 -1
  18. package/dist/cli/registry.d.ts.map +1 -1
  19. package/dist/components/run-progress.d.ts +7 -5
  20. package/dist/components/run-progress.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +41 -0
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/assert-live.d.ts +77 -0
  24. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  25. package/dist/lifecycle/change-set.d.ts +40 -0
  26. package/dist/lifecycle/change-set.d.ts.map +1 -1
  27. package/dist/lifecycle/disruption.d.ts +96 -0
  28. package/dist/lifecycle/disruption.d.ts.map +1 -0
  29. package/dist/lifecycle/index.d.ts +2 -0
  30. package/dist/lifecycle/index.d.ts.map +1 -1
  31. package/dist/lifecycle/replay.d.ts +2 -0
  32. package/dist/lifecycle/replay.d.ts.map +1 -1
  33. package/dist/lint/policy.d.ts +16 -0
  34. package/dist/lint/policy.d.ts.map +1 -1
  35. package/dist/op/local-executor.d.ts +22 -2
  36. package/dist/op/local-executor.d.ts.map +1 -1
  37. package/dist/testing.d.ts +23 -2
  38. package/dist/testing.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/build-options.test.ts +101 -0
  41. package/src/cli/build-options.ts +109 -0
  42. package/src/cli/commands/build.ts +24 -53
  43. package/src/cli/handlers/lifecycle.test.ts +109 -0
  44. package/src/cli/handlers/lifecycle.ts +32 -8
  45. package/src/cli/handlers/op-progress.test.ts +202 -0
  46. package/src/cli/handlers/op-progress.ts +192 -0
  47. package/src/cli/handlers/run-client.test.ts +82 -0
  48. package/src/cli/handlers/run-client.ts +85 -2
  49. package/src/cli/handlers/run-report.test.ts +62 -0
  50. package/src/cli/handlers/run-report.ts +20 -58
  51. package/src/cli/handlers/run.test.ts +240 -0
  52. package/src/cli/handlers/run.ts +76 -18
  53. package/src/cli/handlers/search-drift.test.ts +263 -0
  54. package/src/cli/handlers/search.ts +150 -1
  55. package/src/cli/main.ts +11 -0
  56. package/src/cli/mcp/op-tools.ts +17 -6
  57. package/src/cli/mcp/resource-handlers.ts +13 -5
  58. package/src/cli/registry.ts +33 -1
  59. package/src/components/run-progress.ts +9 -5
  60. package/src/lexicon.ts +51 -0
  61. package/src/lifecycle/assert-live.test.ts +125 -0
  62. package/src/lifecycle/assert-live.ts +154 -0
  63. package/src/lifecycle/change-set.test.ts +144 -1
  64. package/src/lifecycle/change-set.ts +165 -11
  65. package/src/lifecycle/disruption.test.ts +186 -0
  66. package/src/lifecycle/disruption.ts +224 -0
  67. package/src/lifecycle/index.ts +2 -0
  68. package/src/lifecycle/replay.test.ts +25 -0
  69. package/src/lifecycle/replay.ts +11 -3
  70. package/src/lint/policy-build-parity.test.ts +232 -0
  71. package/src/lint/policy.ts +51 -6
  72. package/src/op/local-executor.ts +35 -1
  73. package/src/op/local-output.ts +1 -1
  74. package/src/testing.test.ts +89 -2
  75. package/src/testing.ts +63 -3
@@ -50,6 +50,9 @@ vi.mock("./run-client", () => ({
50
50
  connectionOptions: (profile: { address: string }) => ({ address: profile.address }),
51
51
  resolveProfile: (...args: unknown[]) => resolveProfileMock(...args),
52
52
  resolveWorkflowId: (name: string) => `chant-op-${name}`,
53
+ // Test fixtures already use the short event-type form fetchNormalizedHistory
54
+ // produces (see mock-temporal-client.ts) — a passthrough keeps them valid.
55
+ fetchNormalizedHistory: (handle: { fetchHistory(): Promise<unknown> }) => handle.fetchHistory(),
53
56
  }));
54
57
  vi.mock("node:fs", async () => {
55
58
  const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
@@ -297,6 +300,54 @@ describe("runOpStatus", () => {
297
300
  expect(out).toContain("COMPLETED");
298
301
  expect(out).toContain("1/2 completed");
299
302
  });
303
+
304
+ test("a pending gate prints a Gate line via the gateState query (#1676)", async () => {
305
+ setupTemporalClient(createMockTemporalClient({
306
+ describeByWorkflowId: {
307
+ "chant-op-alb-deploy": {
308
+ workflowId: "chant-op-alb-deploy", runId: "r1",
309
+ status: { name: "RUNNING" },
310
+ startTime: new Date("2026-05-01T00:00:00Z"),
311
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
312
+ },
313
+ },
314
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
315
+ queryResultByWorkflowId: {
316
+ "chant-op-alb-deploy:gateState": {
317
+ signalName: "gate-dns-delegation",
318
+ description: "Approve DNS delegation",
319
+ since: "2026-05-01T00:05:00.000Z",
320
+ },
321
+ },
322
+ }));
323
+ const stdout = makeStdoutSpy();
324
+ const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
325
+ expect(exit).toBe(0);
326
+ const out = stdout.join("\n");
327
+ expect(out).toContain("Gate");
328
+ expect(out).toContain("gate-dns-delegation");
329
+ expect(out).toContain("Approve DNS delegation");
330
+ expect(out).toContain("2026-05-01T00:05:00.000Z");
331
+ });
332
+
333
+ test("an Op with no pending gate (query unregistered or answers null) prints no Gate line", async () => {
334
+ setupTemporalClient(createMockTemporalClient({
335
+ describeByWorkflowId: {
336
+ "chant-op-alb-deploy": {
337
+ workflowId: "chant-op-alb-deploy", runId: "r1",
338
+ status: { name: "COMPLETED" },
339
+ startTime: new Date("2026-05-01T00:00:00Z"),
340
+ closeTime: new Date("2026-05-01T01:00:00Z"),
341
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
342
+ },
343
+ },
344
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
345
+ }));
346
+ const stdout = makeStdoutSpy();
347
+ const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
348
+ expect(exit).toBe(0);
349
+ expect(stdout.join("\n")).not.toContain("Gate");
350
+ });
300
351
  });
301
352
 
302
353
  // ── chant run status <name> --components (#599) ─────────────────────────────
@@ -731,6 +782,99 @@ describe("runOp", () => {
731
782
  }
732
783
  });
733
784
 
785
+ // ── --progress-json (#1676) ──────────────────────────────────────────────
786
+
787
+ test("--progress-json streams one NDJSON StepRecord per settled step, plus a final skipped record", async () => {
788
+ vi.useFakeTimers();
789
+ try {
790
+ discoverOpsMock.mockResolvedValue({
791
+ ops: new Map([[
792
+ "alb-deploy",
793
+ {
794
+ config: {
795
+ name: "alb-deploy", overview: "o",
796
+ phases: [
797
+ { name: "Build", steps: [{ kind: "activity", fn: "build" }] },
798
+ { name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
799
+ ],
800
+ },
801
+ },
802
+ ]]),
803
+ errors: [],
804
+ });
805
+ setupTemporalClient(createMockTemporalClient({
806
+ describeByWorkflowId: {
807
+ "chant-op-alb-deploy": {
808
+ workflowId: "chant-op-alb-deploy", runId: "r1",
809
+ status: { name: "COMPLETED" }, startTime: new Date(),
810
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
811
+ },
812
+ },
813
+ historyByWorkflowId: {
814
+ "chant-op-alb-deploy": [
815
+ { eventId: "1", eventType: "ActivityTaskScheduled", activityTaskScheduledEventAttributes: { activityId: "1", activityType: { name: "build" } } },
816
+ { eventType: "ActivityTaskCompleted", activityTaskCompletedEventAttributes: { scheduledEventId: "1" } },
817
+ ],
818
+ },
819
+ }));
820
+ existsSyncMock.mockReturnValue(true);
821
+ const { proc } = makeFakeChildProcess();
822
+ spawnChildMock.mockReturnValue(proc);
823
+ generateReportMock.mockReturnValue("# Report");
824
+ writeReportMock.mockReturnValue("/tmp/report.md");
825
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
826
+ const stdoutLines: string[] = [];
827
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { stdoutLines.push(String(chunk)); return true; });
828
+
829
+ const promise = runOp({ args: makeArgs({ path: "alb-deploy", progressJson: true }), plugins: [], serializers: [] });
830
+ await vi.advanceTimersByTimeAsync(5000);
831
+ const exit = await promise;
832
+
833
+ expect(exit).toBe(0);
834
+ const records = stdoutLines.filter((l) => l.trim()).map((l) => JSON.parse(l));
835
+ expect(records).toEqual([
836
+ { phase: "Build", fn: "build", status: "ok", durationMs: 0 },
837
+ { phase: "Deploy", fn: "deploy", status: "skipped", durationMs: 0 },
838
+ ]);
839
+ stdoutSpy.mockRestore();
840
+ } finally {
841
+ vi.useRealTimers();
842
+ }
843
+ }, 10_000);
844
+
845
+ test("without --progress-json, nothing is written to stdout during the poll loop", async () => {
846
+ vi.useFakeTimers();
847
+ try {
848
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
849
+ setupTemporalClient(createMockTemporalClient({
850
+ describeByWorkflowId: {
851
+ "chant-op-alb-deploy": {
852
+ workflowId: "chant-op-alb-deploy", runId: "r1",
853
+ status: { name: "COMPLETED" }, startTime: new Date(),
854
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
855
+ },
856
+ },
857
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
858
+ }));
859
+ existsSyncMock.mockReturnValue(true);
860
+ const { proc } = makeFakeChildProcess();
861
+ spawnChildMock.mockReturnValue(proc);
862
+ generateReportMock.mockReturnValue("# Report");
863
+ writeReportMock.mockReturnValue("/tmp/report.md");
864
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
865
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
866
+
867
+ const promise = runOp({ args: makeArgs({ path: "alb-deploy" }), plugins: [], serializers: [] });
868
+ await vi.advanceTimersByTimeAsync(5000);
869
+ await promise;
870
+
871
+ expect(stdoutSpy).not.toHaveBeenCalled();
872
+ stdoutSpy.mockRestore();
873
+ } finally {
874
+ vi.useRealTimers();
875
+ }
876
+ });
877
+
734
878
  test("workflow ends in FAILED → exit 1, worker still killed", async () => {
735
879
  vi.useFakeTimers();
736
880
  try {
@@ -843,6 +987,102 @@ describe("runOp dispatcher", () => {
843
987
  });
844
988
  });
845
989
 
990
+ /**
991
+ * chant #2003 — `--sandbox` is a global flag and `../main.ts` arms the
992
+ * process-wide policy latch off it for every command, so `chant run <op>
993
+ * --sandbox` on an Op with a `policyGate` step used to reach `loadPolicyChecks`
994
+ * mid-run and show the user a message written for a chant maintainer ("This is
995
+ * a chant bug"). At the CLI level, not on `loadPolicyChecks`: the defect was
996
+ * that the combination got that far, not what the refusal says.
997
+ */
998
+ describe("runOp: --sandbox with a policyGate step (chant #2003)", () => {
999
+ const policyGateOp = (name: string) =>
1000
+ localOp(name, [{ kind: "activity", fn: "policyGate", args: { path: "." } }]);
1001
+
1002
+ beforeEach(() => {
1003
+ discoverOpsMock.mockReset();
1004
+ loadTemporalClientMock.mockReset();
1005
+ loadChantConfigMock.mockReset();
1006
+ resolveProfileMock.mockReset();
1007
+ existsSyncMock.mockReset();
1008
+ spawnChildMock.mockReset();
1009
+ });
1010
+
1011
+ test("local mode → refuses before any phase runs, naming the combination", async () => {
1012
+ discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
1013
+ const stderr = makeStderrSpy();
1014
+
1015
+ const exit = await runOp({ args: makeArgs({ path: "gate", temporal: false, sandbox: true }), plugins: [], serializers: [] });
1016
+
1017
+ expect(exit).toBe(1);
1018
+ const out = stderr.join("\n");
1019
+ expect(out).toContain("policyGate");
1020
+ expect(out).toContain("--sandbox");
1021
+ // The message the user used to get instead.
1022
+ expect(out).not.toContain("This is a chant bug");
1023
+ });
1024
+
1025
+ test("--temporal → the same refusal, as a CLI error rather than an activity failure", async () => {
1026
+ discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
1027
+ const stderr = makeStderrSpy();
1028
+
1029
+ const exit = await runOp({ args: makeArgs({ path: "gate", temporal: true, sandbox: true }), plugins: [], serializers: [] });
1030
+
1031
+ expect(exit).toBe(1);
1032
+ expect(stderr.join("\n")).toContain("policyGate");
1033
+ // Refused before the project config is even loaded — nothing is scheduled.
1034
+ expect(loadChantConfigMock).not.toHaveBeenCalled();
1035
+ expect(loadTemporalClientMock).not.toHaveBeenCalled();
1036
+ });
1037
+
1038
+ test("a policyGate nested in an effect step is found too", async () => {
1039
+ discoverOpsMock.mockResolvedValue({
1040
+ ops: new Map([
1041
+ localOp("gate", [
1042
+ { kind: "effect", steps: [{ kind: "activity", fn: "policyGate", args: { path: "." } }] },
1043
+ ]),
1044
+ ]),
1045
+ errors: [],
1046
+ });
1047
+ const stderr = makeStderrSpy();
1048
+
1049
+ const exit = await runOp({ args: makeArgs({ path: "gate", temporal: false, sandbox: true }), plugins: [], serializers: [] });
1050
+
1051
+ expect(exit).toBe(1);
1052
+ expect(stderr.join("\n")).toContain("policyGate");
1053
+ });
1054
+
1055
+ test("without --sandbox the same Op is not refused", async () => {
1056
+ discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
1057
+ loadChantConfigMock.mockResolvedValue({ config: {} });
1058
+ resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
1059
+ existsSyncMock.mockReturnValue(false);
1060
+ const stderr = makeStderrSpy();
1061
+
1062
+ // The Temporal path, so the run stops at the missing worker.ts rather than
1063
+ // actually building a project: reaching that point proves the pre-flight
1064
+ // let it through.
1065
+ const exit = await runOp({ args: makeArgs({ path: "gate", temporal: true }), plugins: [], serializers: [] });
1066
+
1067
+ expect(exit).toBe(1);
1068
+ expect(stderr.join("\n")).toContain("worker.ts not found");
1069
+ expect(loadChantConfigMock).toHaveBeenCalled();
1070
+ });
1071
+
1072
+ test("--sandbox on an Op with no policyGate step is untouched", async () => {
1073
+ discoverOpsMock.mockResolvedValue({
1074
+ ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
1075
+ errors: [],
1076
+ });
1077
+ const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1078
+
1079
+ const exit = await runOp({ args: makeArgs({ path: "hello", temporal: false, sandbox: true }), plugins: [], serializers: [] });
1080
+
1081
+ expect(exit).toBe(0);
1082
+ stderrWrite.mockRestore();
1083
+ });
1084
+ });
1085
+
846
1086
  describe("Temporal-only subcommand guards", () => {
847
1087
  const cases: Array<[string, (ctx: { args: ParsedArgs; plugins: never[]; serializers: never[] }) => Promise<number>]> = [
848
1088
  ["list", runOpList],
@@ -4,8 +4,9 @@ import { createConnection } from "node:net";
4
4
  import { spawn as spawnChild, type ChildProcess } from "node:child_process";
5
5
  import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
6
6
  import { discoverOps } from "../../op/discover";
7
+ import type { OpConfig } from "../../op/types";
7
8
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
8
- import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "../../op/local-executor";
9
+ import { runOpLocally, findGate, findPolicyGateStep, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
9
10
  import { renderHuman, renderJson } from "../../op/local-output";
10
11
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
11
12
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
@@ -15,11 +16,13 @@ import {
15
16
  connectionOptions,
16
17
  resolveWorkflowId,
17
18
  resolveProfile,
19
+ fetchNormalizedHistory,
18
20
  type WorkflowHandleRaw,
19
21
  type WorkflowExecutionDescription,
20
22
  type WorkflowHistoryRaw,
21
23
  } from "./run-client";
22
24
  import { generateReport, writeReport } from "./run-report";
25
+ import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
23
26
  import { runComponents, resolveComponentTargets, findComponentGate, listComponents } from "../../components/cli-support";
24
27
  import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
25
28
  import { ndjsonProgressSink } from "../../components/run-progress";
@@ -250,12 +253,12 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
250
253
  if (ctx.args.components) return runComponentStatus(ctx, name);
251
254
 
252
255
  const projectPath = resolve(".");
253
- let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw;
256
+ let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw, handle: WorkflowHandleRaw;
254
257
  try {
255
258
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
256
- const handle = client.workflow.getHandle(resolveWorkflowId(name));
259
+ handle = client.workflow.getHandle(resolveWorkflowId(name));
257
260
  desc = await handle.describe();
258
- history = await handle.fetchHistory();
261
+ history = await fetchNormalizedHistory(handle);
259
262
  } catch (err) {
260
263
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
261
264
  return 1;
@@ -269,13 +272,19 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
269
272
  console.log(` Started : ${desc.startTime.toISOString()}`);
270
273
  if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
271
274
 
272
- const events = history.events ?? [];
273
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
274
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
275
+ const { completed, scheduled } = countActivities(history);
275
276
  if (scheduled > 0) {
276
277
  console.log(` Activities : ${completed}/${scheduled} completed`);
277
278
  }
278
279
 
280
+ // Queryable gate state (#1676) — undefined means the query itself failed
281
+ // (an ungated Op never registers a "gateState" handler), so print nothing;
282
+ // null means the query answered "no gate pending" — also nothing to print.
283
+ const gate = await queryGateState(handle);
284
+ if (gate) {
285
+ console.log(` Gate : ${gate.signalName}${gate.description ? ` — ${gate.description}` : ""} (waiting since ${gate.since})`);
286
+ }
287
+
279
288
  return 0;
280
289
  }
281
290
 
@@ -293,7 +302,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
293
302
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
294
303
  const handle = client.workflow.getHandle(componentWorkflowId(name));
295
304
  desc = await handle.describe();
296
- history = await handle.fetchHistory();
305
+ history = await fetchNormalizedHistory(handle);
297
306
  } catch (err) {
298
307
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
299
308
  return 1;
@@ -307,9 +316,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
307
316
  console.log(` Started : ${desc.startTime.toISOString()}`);
308
317
  if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
309
318
 
310
- const events = history.events ?? [];
311
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
312
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
319
+ const { completed, scheduled } = countActivities(history);
313
320
  if (scheduled > 0) {
314
321
  console.log(` Activities : ${completed}/${scheduled} completed`);
315
322
  }
@@ -528,9 +535,7 @@ async function waitForTemporalServer(address: string, maxWaitMs = 30_000): Promi
528
535
  }
529
536
 
530
537
  function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
531
- const events = history.events ?? [];
532
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
533
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
538
+ const { completed, scheduled } = countActivities(history);
534
539
  process.stderr.write(
535
540
  `\r${formatInfo(`[${opName}]`)} ${completed}/${scheduled} activities completed`,
536
541
  );
@@ -557,6 +562,32 @@ function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
557
562
  * live reaching an actual cloud shell-out. Erroring here is the safe minimum
558
563
  * called out on the issue; a real preview is future work.
559
564
  */
565
+ /**
566
+ * Pre-flight for `chant run <op> --sandbox` on an Op containing a `policyGate`
567
+ * step (chant #2003). `--sandbox` is a global flag, and `../main.ts` arms the
568
+ * process-wide policy latch off it for every command — so the gate's
569
+ * `loadPolicyChecks` refuses mid-run with a message addressed to a chant
570
+ * maintainer ("This is a chant bug"), or, under `--temporal`, as a
571
+ * non-retryable activity failure inside the workflow. Neither is an answer a
572
+ * user can act on. Refuse here instead, before anything runs, naming the
573
+ * combination and what to do about it.
574
+ *
575
+ * Keyed on the flag rather than on whether the project declares
576
+ * `lint.policies`: the flag is what arms the latch, and a gate that builds the
577
+ * project in this process is a divergence from what `--sandbox` promises
578
+ * whether or not a policy module happens to be declared. Returns true when the
579
+ * caller should stop.
580
+ */
581
+ function refusesPolicyGateUnderSandbox(ctx: CommandContext, config: OpConfig, opName: string): boolean {
582
+ if (!ctx.args.sandbox) return false;
583
+ if (!findPolicyGateStep(config)) return false;
584
+ console.error(formatError({
585
+ message: `Op "${opName}" has a policyGate step, which cannot run under --sandbox: the gate builds this project and imports its lint.policies in the chant process, which --sandbox forbids.`,
586
+ hint: "Re-run without --sandbox, or drop the policyGate step from this Op. Running the gate itself inside the sandbox boundary is tracked on chant#1157.",
587
+ }));
588
+ return true;
589
+ }
590
+
560
591
  export async function runOp(ctx: CommandContext): Promise<number> {
561
592
  if (ctx.args.components && ctx.args.report) {
562
593
  console.error(formatError({
@@ -954,7 +985,7 @@ async function runComponentTemporal(
954
985
  while (true) {
955
986
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
956
987
  const desc = await handle.describe();
957
- const history = await handle.fetchHistory();
988
+ const history = await fetchNormalizedHistory(handle);
958
989
  renderProgress(component.name, history);
959
990
  if (TERMINAL_STATUSES.has(desc.status.name)) {
960
991
  process.stderr.write("\n");
@@ -1057,6 +1088,9 @@ export async function runOpLocal(ctx: CommandContext): Promise<number> {
1057
1088
 
1058
1089
  const { config } = discovered;
1059
1090
 
1091
+ // Pre-flight: --sandbox cannot cover a policyGate step (#2003).
1092
+ if (refusesPolicyGateUnderSandbox(ctx, config, opName)) return 1;
1093
+
1060
1094
  // Pre-flight: gates/schedules need a durable runtime — fail before any step.
1061
1095
  const gate = findGate(config);
1062
1096
  if (gate) {
@@ -1141,6 +1175,12 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1141
1175
  }
1142
1176
 
1143
1177
  const { config } = discovered;
1178
+
1179
+ // Pre-flight: --sandbox cannot cover a policyGate step (#2003). Refused here
1180
+ // too, so the durable path fails as a CLI error rather than as a
1181
+ // non-retryable activity failure buried in workflow history.
1182
+ if (refusesPolicyGateUnderSandbox(ctx, config, opName)) return 1;
1183
+
1144
1184
  const projectPath = resolve(".");
1145
1185
 
1146
1186
  // Load config + profile
@@ -1160,7 +1200,7 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1160
1200
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
1161
1201
  const handle = client.workflow.getHandle(resolveWorkflowId(opName));
1162
1202
  desc = await handle.describe();
1163
- history = await handle.fetchHistory();
1203
+ history = await fetchNormalizedHistory(handle);
1164
1204
  } catch (err) {
1165
1205
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
1166
1206
  return 1;
@@ -1248,7 +1288,23 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1248
1288
  return 1;
1249
1289
  }
1250
1290
 
1251
- // Poll for progress until terminal state
1291
+ // Poll for progress until terminal state.
1292
+ //
1293
+ // `--progress-json` (chant #1676): stream one NDJSON StepRecord per line to
1294
+ // stdout as each declared step settles — the durable-path counterpart to
1295
+ // `chant run <name> --json`'s local-executor record shape (op-progress.ts).
1296
+ // Purely additive: when the flag is absent `emitProgress` stays undefined
1297
+ // and every `emitProgress?.(...)` call is a no-op, so poll-loop behavior
1298
+ // is unchanged. `extractStepRecords` is stable and append-only across
1299
+ // polls (same declared order, a growing history), so a plain emitted-count
1300
+ // watermark is enough to emit only what's new each round.
1301
+ const emitProgress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
1302
+ let emittedCount = 0;
1303
+ const emitNewRecords = (records: StepRecord[]) => {
1304
+ for (const record of records.slice(emittedCount)) emitProgress?.(record);
1305
+ emittedCount = records.length;
1306
+ };
1307
+
1252
1308
  let finalDesc: WorkflowExecutionDescription | undefined;
1253
1309
  let finalHistory: WorkflowHistoryRaw | undefined;
1254
1310
 
@@ -1256,12 +1312,14 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1256
1312
  while (true) {
1257
1313
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1258
1314
  const desc = await handle.describe();
1259
- const history = await handle.fetchHistory();
1315
+ const history = await fetchNormalizedHistory(handle);
1260
1316
 
1261
1317
  renderProgress(opName, history);
1318
+ if (emitProgress) emitNewRecords(extractStepRecords(config, history));
1262
1319
 
1263
1320
  if (TERMINAL_STATUSES.has(desc.status.name)) {
1264
1321
  process.stderr.write("\n");
1322
+ if (emitProgress) emitNewRecords(extractStepRecords(config, history, { final: true }));
1265
1323
  finalDesc = desc;
1266
1324
  finalHistory = history;
1267
1325
  break;