@intentius/chant 0.52.2 → 0.53.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 (125) hide show
  1. package/dist/cdk/advise.d.ts +29 -0
  2. package/dist/cdk/advise.d.ts.map +1 -0
  3. package/dist/cdk/assembly.d.ts +38 -0
  4. package/dist/cdk/assembly.d.ts.map +1 -0
  5. package/dist/cdk/graph.d.ts +68 -0
  6. package/dist/cdk/graph.d.ts.map +1 -0
  7. package/dist/cdk/tier-map.d.ts +44 -0
  8. package/dist/cdk/tier-map.d.ts.map +1 -0
  9. package/dist/cdk/types.d.ts +114 -0
  10. package/dist/cdk/types.d.ts.map +1 -0
  11. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  12. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  13. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  14. package/dist/cli/commands/carve.d.ts +48 -6
  15. package/dist/cli/commands/carve.d.ts.map +1 -1
  16. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  17. package/dist/cli/handlers/carve.d.ts +5 -4
  18. package/dist/cli/handlers/carve.d.ts.map +1 -1
  19. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  20. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  21. package/dist/cli/handlers/op-progress.d.ts +37 -4
  22. package/dist/cli/handlers/op-progress.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +61 -1
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/run.d.ts.map +1 -1
  26. package/dist/cli/main.d.ts.map +1 -1
  27. package/dist/cli/registry.d.ts +9 -1
  28. package/dist/cli/registry.d.ts.map +1 -1
  29. package/dist/identity.d.ts +196 -0
  30. package/dist/identity.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/lexicon.d.ts +41 -0
  34. package/dist/lexicon.d.ts.map +1 -1
  35. package/dist/lifecycle/converge-ledger.d.ts +81 -1
  36. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  37. package/dist/lifecycle/gate-ledger.d.ts +43 -1
  38. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  39. package/dist/terraform/adopt-state.d.ts +17 -66
  40. package/dist/terraform/adopt-state.d.ts.map +1 -1
  41. package/dist/terraform/aws-resources.d.ts +1 -1
  42. package/dist/terraform/carve-provider.d.ts +142 -0
  43. package/dist/terraform/carve-provider.d.ts.map +1 -0
  44. package/dist/terraform/carve.d.ts +36 -3
  45. package/dist/terraform/carve.d.ts.map +1 -1
  46. package/dist/terraform/emit-source.d.ts +25 -0
  47. package/dist/terraform/emit-source.d.ts.map +1 -0
  48. package/dist/terraform/graduate.d.ts +11 -1
  49. package/dist/terraform/graduate.d.ts.map +1 -1
  50. package/dist/terraform/providers/aws.d.ts +19 -0
  51. package/dist/terraform/providers/aws.d.ts.map +1 -0
  52. package/dist/terraform/providers/gcp.d.ts +41 -0
  53. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  54. package/dist/terraform/providers/index.d.ts +15 -0
  55. package/dist/terraform/providers/index.d.ts.map +1 -0
  56. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  57. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  58. package/dist/terraform/score.d.ts +70 -4
  59. package/dist/terraform/score.d.ts.map +1 -1
  60. package/dist/terraform/tier-map.d.ts +30 -35
  61. package/dist/terraform/tier-map.d.ts.map +1 -1
  62. package/dist/terraform/types.d.ts +6 -0
  63. package/dist/terraform/types.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  66. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  67. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  68. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  69. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  70. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  71. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  72. package/src/cdk/advise.test.ts +208 -0
  73. package/src/cdk/advise.ts +44 -0
  74. package/src/cdk/assembly.ts +133 -0
  75. package/src/cdk/graph.test.ts +206 -0
  76. package/src/cdk/graph.ts +525 -0
  77. package/src/cdk/tier-map.ts +71 -0
  78. package/src/cdk/types.ts +115 -0
  79. package/src/cli/commands/carve-apply.ts +20 -4
  80. package/src/cli/commands/carve-bridge.ts +9 -5
  81. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  82. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  83. package/src/cli/commands/carve-emit.ts +28 -24
  84. package/src/cli/commands/carve.ts +139 -36
  85. package/src/cli/handlers/carve-emit.ts +8 -3
  86. package/src/cli/handlers/carve.ts +8 -7
  87. package/src/cli/handlers/lifecycle.test.ts +187 -1
  88. package/src/cli/handlers/lifecycle.ts +125 -1
  89. package/src/cli/handlers/op-progress.test.ts +67 -1
  90. package/src/cli/handlers/op-progress.ts +70 -14
  91. package/src/cli/handlers/operator.test.ts +302 -1
  92. package/src/cli/handlers/operator.ts +193 -5
  93. package/src/cli/handlers/run.ts +11 -13
  94. package/src/cli/main.test.ts +19 -0
  95. package/src/cli/main.ts +38 -10
  96. package/src/cli/registry.ts +9 -1
  97. package/src/identity.test.ts +199 -0
  98. package/src/identity.ts +346 -0
  99. package/src/index.ts +1 -0
  100. package/src/lexicon.ts +53 -0
  101. package/src/lifecycle/converge-ledger.test.ts +110 -0
  102. package/src/lifecycle/converge-ledger.ts +110 -2
  103. package/src/lifecycle/gate-ledger.test.ts +81 -1
  104. package/src/lifecycle/gate-ledger.ts +64 -1
  105. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  106. package/src/terraform/adopt-state.test.ts +131 -0
  107. package/src/terraform/adopt-state.ts +19 -167
  108. package/src/terraform/aws-resources.test.ts +38 -22
  109. package/src/terraform/aws-resources.ts +1 -1
  110. package/src/terraform/carve-provider.test.ts +155 -0
  111. package/src/terraform/carve-provider.ts +237 -0
  112. package/src/terraform/carve.test.ts +55 -1
  113. package/src/terraform/carve.ts +0 -0
  114. package/src/terraform/emit-source.ts +39 -0
  115. package/src/terraform/graduate.test.ts +37 -0
  116. package/src/terraform/graduate.ts +55 -7
  117. package/src/terraform/graph.ts +3 -3
  118. package/src/terraform/providers/aws.ts +169 -0
  119. package/src/terraform/providers/gcp.test.ts +228 -0
  120. package/src/terraform/providers/gcp.ts +329 -0
  121. package/src/terraform/providers/index.ts +21 -0
  122. package/src/terraform/providers/kubernetes.ts +224 -0
  123. package/src/terraform/score.ts +111 -25
  124. package/src/terraform/tier-map.ts +45 -107
  125. package/src/terraform/types.ts +6 -0
@@ -54,7 +54,7 @@ vi.mock("../../lifecycle/git", async () => {
54
54
  });
55
55
 
56
56
  // Imported after the mocks above are registered.
57
- const { runOperator, runOperatorStatus, runApprove } = await import("./operator");
57
+ const { runOperator, runOperatorStatus, runOperatorLog, runApprove } = await import("./operator");
58
58
 
59
59
  function ctx(args: Partial<ParsedArgs>) {
60
60
  return {
@@ -180,6 +180,77 @@ describe("runOperatorStatus", () => {
180
180
  logSpy.mockRestore();
181
181
  });
182
182
 
183
+ // #2028 — the pending fact is the one a human has to act on, so it is the
184
+ // one that has to carry a link. Before this the row was {rule, op, gate}
185
+ // and the only affordance a renderer could offer was a shell command.
186
+ test("a gated outcome's approval url rides onto the pending-gate row", async () => {
187
+ discoverConvergeOpsMock.mockResolvedValue({
188
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
189
+ errors: [],
190
+ });
191
+ readConvergeLedgerMock.mockResolvedValue({
192
+ records: [
193
+ {
194
+ version: 1,
195
+ op: "staging-converge",
196
+ env: "staging",
197
+ timestamp: "2026-01-01T00:00:00.000Z",
198
+ firedRuleIds: ["drift-apply"],
199
+ outcomes: [{
200
+ ruleId: "drift-apply",
201
+ action: "gated",
202
+ op: "fountain-apply",
203
+ gateName: "rollout-gate",
204
+ url: "https://github.com/INTENTIUS/chant/pull/2028",
205
+ }],
206
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
207
+ log: "converge(staging): ...",
208
+ },
209
+ ],
210
+ malformed: 0,
211
+ });
212
+ readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
213
+ readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
214
+
215
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
216
+ await runOperatorStatus(ctx({ json: true }));
217
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
218
+ expect(printed[0].pendingGates).toEqual([
219
+ { rule: "drift-apply", op: "fountain-apply", gate: "rollout-gate", url: "https://github.com/INTENTIUS/chant/pull/2028" },
220
+ ]);
221
+ logSpy.mockRestore();
222
+ });
223
+
224
+ test("the human render prints the address under the pending gate", async () => {
225
+ discoverConvergeOpsMock.mockResolvedValue({
226
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
227
+ errors: [],
228
+ });
229
+ readConvergeLedgerMock.mockResolvedValue({
230
+ records: [
231
+ {
232
+ version: 1,
233
+ op: "staging-converge",
234
+ env: "staging",
235
+ timestamp: "2026-01-01T00:00:00.000Z",
236
+ firedRuleIds: ["drift-apply"],
237
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate", url: "https://pr.example/1" }],
238
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
239
+ log: "converge(staging): ...",
240
+ },
241
+ ],
242
+ malformed: 0,
243
+ });
244
+ readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
245
+ readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
246
+
247
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
248
+ await runOperatorStatus(ctx({}));
249
+ const out = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
250
+ expect(out).toContain("approve at: https://pr.example/1");
251
+ logSpy.mockRestore();
252
+ });
253
+
183
254
  test("a gate resolved after the tick that recorded it is no longer pending", async () => {
184
255
  discoverConvergeOpsMock.mockResolvedValue({
185
256
  ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
@@ -239,6 +310,57 @@ describe("runApprove", () => {
239
310
  errSpy.mockRestore();
240
311
  });
241
312
 
313
+ // #2028 — the resolution's link is typed, so a reader is not sniffing
314
+ // free-text `note` for something that looks like a URL.
315
+ test("--url is recorded typed, alongside --note's prose", async () => {
316
+ vi.stubEnv("GITHUB_REF_NAME", "");
317
+ vi.stubEnv("GITHUB_REPOSITORY", "");
318
+ vi.stubEnv("CI_MERGE_REQUEST_PROJECT_URL", "");
319
+ appendGateResolutionMock.mockResolvedValue({
320
+ commit: "sha",
321
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", url: "https://github.com/org/repo/pull/9" },
322
+ });
323
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
324
+
325
+ const code = await runApprove(ctx({
326
+ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex",
327
+ note: "rolled staging first", url: "https://github.com/org/repo/pull/9",
328
+ }));
329
+
330
+ expect(code).toBe(0);
331
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(
332
+ expect.objectContaining({ note: "rolled staging first", url: "https://github.com/org/repo/pull/9" }),
333
+ );
334
+ errSpy.mockRestore();
335
+ vi.unstubAllEnvs();
336
+ });
337
+
338
+ test("without --url, the surrounding PR/MR job is itself the address", async () => {
339
+ vi.stubEnv("GITHUB_SERVER_URL", "https://github.com");
340
+ vi.stubEnv("GITHUB_REPOSITORY", "INTENTIUS/chant");
341
+ vi.stubEnv("GITHUB_REF_NAME", "2028/merge");
342
+ appendGateResolutionMock.mockResolvedValue({
343
+ commit: "sha",
344
+ record: { version: 1, op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
345
+ });
346
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
347
+
348
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "g", actor: "alex" }))).toBe(0);
349
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(
350
+ expect.objectContaining({ url: "https://github.com/INTENTIUS/chant/pull/2028" }),
351
+ );
352
+ errSpy.mockRestore();
353
+ vi.unstubAllEnvs();
354
+ });
355
+
356
+ test("a --url that is not an absolute http/https link is refused, and nothing is written", async () => {
357
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
358
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "g", url: "org/repo/pull/1" }));
359
+ expect(code).toBe(1);
360
+ expect(appendGateResolutionMock).not.toHaveBeenCalled();
361
+ errSpy.mockRestore();
362
+ });
363
+
242
364
  test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
243
365
  discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
244
366
  appendGateResolutionMock.mockResolvedValue({
@@ -253,3 +375,182 @@ describe("runApprove", () => {
253
375
  errSpy.mockRestore();
254
376
  });
255
377
  });
378
+
379
+ // ── chant operator log (#2029) ────────────────────────────────────────────────
380
+
381
+ describe("runOperatorLog", () => {
382
+ function tick(over: Record<string, unknown> = {}) {
383
+ return {
384
+ version: 1,
385
+ id: "11111111-2222-3333-4444-555555555555",
386
+ op: "staging-converge",
387
+ env: "staging",
388
+ timestamp: "2026-01-01T00:00:00.000Z",
389
+ firedRuleIds: [],
390
+ outcomes: [],
391
+ summary: { drifted: 0, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0 },
392
+ log: "converge(staging): drifted=0",
393
+ ...over,
394
+ };
395
+ }
396
+
397
+ beforeEach(() => {
398
+ discoverConvergeOpsMock.mockResolvedValue({
399
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
400
+ errors: [],
401
+ });
402
+ readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
403
+ });
404
+
405
+ test("--json emits the whole tick history, not just the newest row", async () => {
406
+ readConvergeLedgerMock.mockResolvedValue({
407
+ records: [
408
+ tick({ id: "t1", timestamp: "2026-01-01T00:00:00.000Z" }),
409
+ tick({ id: "t2", timestamp: "2026-01-01T01:00:00.000Z" }),
410
+ tick({ id: "t3", timestamp: "2026-01-01T02:00:00.000Z" }),
411
+ ],
412
+ malformed: 0,
413
+ });
414
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
415
+ expect(await runOperatorLog(ctx({ json: true }))).toBe(0);
416
+
417
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
418
+ expect(printed.entries.map((e: { record: { id: string } }) => e.record.id)).toEqual(["t1", "t2", "t3"]);
419
+ expect(printed.malformed).toEqual({ converge: 0, gates: 0 });
420
+ logSpy.mockRestore();
421
+ });
422
+
423
+ test("carries the malformed-line count, so a short timeline is never silently short", async () => {
424
+ readConvergeLedgerMock.mockResolvedValue({ records: [tick()], malformed: 3 });
425
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
426
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
427
+
428
+ await runOperatorLog(ctx({ json: true }));
429
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string).malformed.converge).toBe(3);
430
+
431
+ logSpy.mockClear();
432
+ await runOperatorLog(ctx({}));
433
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain("3 ledger line(s) were unreadable");
434
+
435
+ logSpy.mockRestore();
436
+ errSpy.mockRestore();
437
+ });
438
+
439
+ test("gate resolutions are merged into the timeline, in timestamp order after the tick that gated", async () => {
440
+ readConvergeLedgerMock.mockResolvedValue({
441
+ records: [
442
+ tick({
443
+ id: "t1",
444
+ timestamp: "2026-01-01T00:00:00.000Z",
445
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate", url: "https://pr.example/1" }],
446
+ }),
447
+ tick({ id: "t2", timestamp: "2026-01-03T00:00:00.000Z" }),
448
+ ],
449
+ malformed: 0,
450
+ });
451
+ readGateResolutionsMock.mockResolvedValue({
452
+ records: [{ version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z", url: "https://pr.example/1" }],
453
+ malformed: 0,
454
+ });
455
+
456
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
457
+ await runOperatorLog(ctx({ json: true }));
458
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
459
+ expect(printed.entries.map((e: { kind: string }) => e.kind)).toEqual(["tick", "gate-resolution", "tick"]);
460
+ expect(readGateResolutionsMock).toHaveBeenCalledWith("fountain-apply", expect.anything());
461
+ logSpy.mockRestore();
462
+ });
463
+
464
+ test("--since drops everything older, --limit keeps the newest n (still oldest-first)", async () => {
465
+ readConvergeLedgerMock.mockResolvedValue({
466
+ records: [
467
+ tick({ id: "t1", timestamp: "2026-01-01T00:00:00.000Z" }),
468
+ tick({ id: "t2", timestamp: "2026-01-02T00:00:00.000Z" }),
469
+ tick({ id: "t3", timestamp: "2026-01-03T00:00:00.000Z" }),
470
+ tick({ id: "t4", timestamp: "2026-01-04T00:00:00.000Z" }),
471
+ ],
472
+ malformed: 0,
473
+ });
474
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
475
+
476
+ await runOperatorLog(ctx({ json: true, since: "2026-01-02T00:00:00.000Z" }));
477
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string).entries.map((e: { record: { id: string } }) => e.record.id))
478
+ .toEqual(["t2", "t3", "t4"]);
479
+
480
+ logSpy.mockClear();
481
+ await runOperatorLog(ctx({ json: true, limit: 2 }));
482
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string).entries.map((e: { record: { id: string } }) => e.record.id))
483
+ .toEqual(["t3", "t4"]);
484
+
485
+ logSpy.mockRestore();
486
+ });
487
+
488
+ test("--op restricts to one ConvergeOp", async () => {
489
+ discoverConvergeOpsMock.mockResolvedValue({
490
+ ops: [
491
+ { config: { name: "staging-converge", searchAttributes: { Env: "staging" } } },
492
+ { config: { name: "other-converge", searchAttributes: { Env: "staging" } } },
493
+ ],
494
+ errors: [],
495
+ });
496
+ readConvergeLedgerMock.mockResolvedValue({
497
+ records: [tick({ id: "t1" }), tick({ id: "t2", op: "other-converge" })],
498
+ malformed: 0,
499
+ });
500
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
501
+ await runOperatorLog(ctx({ json: true, op: "other-converge" }));
502
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string).entries.map((e: { record: { id: string } }) => e.record.id))
503
+ .toEqual(["t2"]);
504
+ logSpy.mockRestore();
505
+ });
506
+
507
+ test("two ConvergeOps sharing one environment read that ledger once", async () => {
508
+ discoverConvergeOpsMock.mockResolvedValue({
509
+ ops: [
510
+ { config: { name: "staging-converge", searchAttributes: { Env: "staging" } } },
511
+ { config: { name: "other-converge", searchAttributes: { Env: "staging" } } },
512
+ ],
513
+ errors: [],
514
+ });
515
+ readConvergeLedgerMock.mockResolvedValue({ records: [tick()], malformed: 2 });
516
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
517
+ await runOperatorLog(ctx({ json: true }));
518
+ expect(readConvergeLedgerMock).toHaveBeenCalledTimes(1);
519
+ // ...and its malformed count is not double-counted.
520
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string).malformed.converge).toBe(2);
521
+ logSpy.mockRestore();
522
+ });
523
+
524
+ test("refuses an unparseable --since and a non-positive --limit without reading anything", async () => {
525
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
526
+ expect(await runOperatorLog(ctx({ since: "last tuesday" }))).toBe(1);
527
+ expect(await runOperatorLog(ctx({ limit: 0 }))).toBe(1);
528
+ expect(await runOperatorLog(ctx({ limit: 1.5 }))).toBe(1);
529
+ expect(readConvergeLedgerMock).not.toHaveBeenCalled();
530
+ errSpy.mockRestore();
531
+ });
532
+
533
+ test("prints the tick id, log line and gated outcomes in the human render", async () => {
534
+ readConvergeLedgerMock.mockResolvedValue({
535
+ records: [tick({
536
+ id: "abcdef01-2222-3333-4444-555555555555",
537
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate", url: "https://pr.example/1" }],
538
+ })],
539
+ malformed: 0,
540
+ });
541
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
542
+ await runOperatorLog(ctx({}));
543
+ const out = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
544
+ expect(out).toContain("[abcdef01]");
545
+ expect(out).toContain("converge(staging): drifted=0");
546
+ expect(out).toContain('gated drift-apply → fountain-apply gate "rollout-gate" https://pr.example/1');
547
+ logSpy.mockRestore();
548
+ });
549
+
550
+ test("exits 0 with a warning when no ConvergeOps are discovered", async () => {
551
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
552
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
553
+ expect(await runOperatorLog(ctx({}))).toBe(0);
554
+ errSpy.mockRestore();
555
+ });
556
+ });
@@ -20,7 +20,8 @@ import {
20
20
  } from "../../op/operator";
21
21
  import { readLease, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
22
22
  import { readConvergeLedger, type ConvergeTickRecord } from "../../lifecycle/converge-ledger";
23
- import { appendGateResolution, readGateResolutions, latestResolutionSince } from "../../lifecycle/gate-ledger";
23
+ import type { GateResolutionRecord } from "../../lifecycle/gate-ledger";
24
+ import { appendGateResolution, readGateResolutions, latestResolutionSince, resolveApprovalUrl, isApprovalUrl } from "../../lifecycle/gate-ledger";
24
25
  import { pushLifecycle } from "../../lifecycle/git";
25
26
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
26
27
  import type { CommandContext } from "../registry";
@@ -112,7 +113,8 @@ interface OpStatusLine {
112
113
  op: string;
113
114
  env: string;
114
115
  lastTick?: ConvergeTickRecord;
115
- pendingGates: { rule: string; op?: string; gate: string }[];
116
+ /** `url` (#2028) is the gate's approval surface, when the tick that recorded it knew one — what a renderer points its approve affordance at instead of a shell command. */
117
+ pendingGates: { rule: string; op?: string; gate: string; url?: string }[];
116
118
  lease?: { holder: string; expiresAt: string };
117
119
  }
118
120
 
@@ -130,7 +132,14 @@ async function statusFor(opName: string, env: string, cwd?: string): Promise<OpS
130
132
  if (outcome.action !== "gated" || !outcome.gateName || !outcome.op) continue;
131
133
  const { records: resolutions } = await readGateResolutions(outcome.op, { cwd });
132
134
  const resolved = latestResolutionSince(resolutions, outcome.gateName, lastTick.timestamp);
133
- if (!resolved) pendingGates.push({ rule: outcome.ruleId, op: outcome.op, gate: outcome.gateName });
135
+ if (!resolved) {
136
+ pendingGates.push({
137
+ rule: outcome.ruleId,
138
+ op: outcome.op,
139
+ gate: outcome.gateName,
140
+ ...(outcome.url ? { url: outcome.url } : {}),
141
+ });
142
+ }
134
143
  }
135
144
  }
136
145
 
@@ -182,6 +191,7 @@ export async function runOperatorStatus(ctx: CommandContext): Promise<number> {
182
191
  console.log(` pending gates:`);
183
192
  for (const g of row.pendingGates) {
184
193
  console.log(` - ${g.op ?? row.op} gate "${g.gate}" (rule ${g.rule}) — resolve: chant approve ${g.op ?? row.op} ${g.gate}`);
194
+ if (g.url) console.log(` approve at: ${g.url}`);
185
195
  }
186
196
  }
187
197
  console.log("");
@@ -190,10 +200,174 @@ export async function runOperatorStatus(ctx: CommandContext): Promise<number> {
190
200
  return 0;
191
201
  }
192
202
 
203
+ // ── chant operator log ──────────────────────────────────────────────────────
204
+
205
+ /** One entry of the merged tick/gate timeline `chant operator log` prints. */
206
+ export type OperatorLogEntry =
207
+ | { kind: "tick"; timestamp: string; record: ConvergeTickRecord }
208
+ | { kind: "gate-resolution"; timestamp: string; record: GateResolutionRecord };
209
+
210
+ export interface OperatorLogResult {
211
+ entries: OperatorLogEntry[];
212
+ /**
213
+ * Unreadable lines behind this answer, per ledger. `readConvergeLedger` and
214
+ * `readGateResolutions` both skip a malformed line and count it rather than
215
+ * throwing, so a corrupted ledger renders a shorter timeline; without this a
216
+ * consumer could not tell that from a genuinely quiet environment.
217
+ */
218
+ malformed: { converge: number; gates: number };
219
+ }
220
+
221
+ /**
222
+ * Gather the converge/gate history a set of ConvergeOps has recorded, merged
223
+ * into one timestamp-ordered timeline, oldest first.
224
+ *
225
+ * `since` (inclusive) and `limit` are applied after the merge — `limit` keeps
226
+ * the *newest* n entries, since that is what a log is asked for, and then
227
+ * prints them oldest-first like every other log.
228
+ */
229
+ export async function collectOperatorLog(
230
+ ops: { name: string; env: string }[],
231
+ opts: { op?: string; since?: string; limit?: number; cwd?: string } = {},
232
+ ): Promise<OperatorLogResult> {
233
+ const wanted = opts.op ? ops.filter((o) => o.name === opts.op) : ops;
234
+ const sinceMs = opts.since ? new Date(opts.since).getTime() : undefined;
235
+
236
+ const entries: OperatorLogEntry[] = [];
237
+ const malformed = { converge: 0, gates: 0 };
238
+
239
+ // One read per distinct environment — several ConvergeOps can share one
240
+ // `<env>/converge.jsonl`, and re-reading it per op would both cost more and
241
+ // double-count its malformed lines.
242
+ const envs = [...new Set(wanted.map((o) => o.env))].sort();
243
+ const opNames = new Set(wanted.map((o) => o.name));
244
+ const gateOps = new Set<string>();
245
+
246
+ for (const env of envs) {
247
+ const { records, malformed: bad } = await readConvergeLedger(env, { cwd: opts.cwd });
248
+ malformed.converge += bad;
249
+ for (const record of records) {
250
+ if (!opNames.has(record.op)) continue;
251
+ for (const outcome of record.outcomes) {
252
+ if (outcome.action === "gated" && outcome.op) gateOps.add(outcome.op);
253
+ }
254
+ entries.push({ kind: "tick", timestamp: record.timestamp, record });
255
+ }
256
+ }
257
+
258
+ for (const gateOp of [...gateOps].sort()) {
259
+ const { records, malformed: bad } = await readGateResolutions(gateOp, { cwd: opts.cwd });
260
+ malformed.gates += bad;
261
+ for (const record of records) {
262
+ entries.push({ kind: "gate-resolution", timestamp: record.timestamp, record });
263
+ }
264
+ }
265
+
266
+ let merged = entries.sort((a, b) => {
267
+ const delta = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
268
+ // A gate resolution recorded in the same instant as a tick reads after it:
269
+ // the tick is what made the gate pending in the first place.
270
+ return delta !== 0 ? delta : (a.kind === b.kind ? 0 : a.kind === "tick" ? -1 : 1);
271
+ });
272
+ if (sinceMs !== undefined) merged = merged.filter((e) => new Date(e.timestamp).getTime() >= sinceMs);
273
+ if (opts.limit !== undefined && merged.length > opts.limit) merged = merged.slice(-opts.limit);
274
+
275
+ return { entries: merged, malformed };
276
+ }
277
+
278
+ function renderLogEntry(entry: OperatorLogEntry): string[] {
279
+ if (entry.kind === "gate-resolution") {
280
+ const { record } = entry;
281
+ return [
282
+ `${record.timestamp} gate-resolved ${record.op}/${record.gate} by ${record.resolvedBy}` +
283
+ (record.url ? ` ${record.url}` : ""),
284
+ ];
285
+ }
286
+
287
+ const { record } = entry;
288
+ const id = record.id ? `[${record.id.slice(0, 8)}]` : "[--------]";
289
+ const lines = [`${record.timestamp} ${record.op}@${record.env} ${id} ${record.log}`];
290
+ for (const outcome of record.outcomes) {
291
+ if (outcome.action !== "gated" || !outcome.gateName) continue;
292
+ lines.push(
293
+ ` gated ${outcome.ruleId} → ${outcome.op ?? "?"} gate "${outcome.gateName}"` +
294
+ (outcome.url ? ` ${outcome.url}` : ""),
295
+ );
296
+ }
297
+ return lines;
298
+ }
299
+
300
+ /**
301
+ * `chant operator log [--env <env>] [--op <name>] [--since <iso>] [--limit
302
+ * <n>] [--json]` (#2029) — the converge tick history and the gate resolutions
303
+ * against it, read from the `chant/lifecycle` orphan branch alone.
304
+ *
305
+ * `readConvergeLedger` has always returned every tick for an environment;
306
+ * until this its only caller was `operator status`, which threw the history
307
+ * away and kept `.at(-1)`. A consumer wanting more than the newest row had to
308
+ * `git show chant/lifecycle:<env>/converge.jsonl` and parse it — which pins it
309
+ * to the orphan branch's name, the `<env>/converge.jsonl` path convention, the
310
+ * `_gates/<op>.jsonl` convention and the on-disk encoding, none of which are
311
+ * promised contracts. This is the read surface that makes those private again.
312
+ *
313
+ * Read-only: no lease, no dispatch, no daemon. Same `--json` discipline as
314
+ * `operator status` — one JSON document on stdout, diagnostics on stderr.
315
+ */
316
+ export async function runOperatorLog(ctx: CommandContext): Promise<number> {
317
+ const { ops, errors } = await discoverConvergeOps({ env: ctx.args.env });
318
+ for (const err of errors) console.error(formatWarning({ message: err }));
319
+
320
+ if (ops.length === 0) {
321
+ console.error(formatWarning({ message: "No ConvergeOp declarations found" }));
322
+ return 0;
323
+ }
324
+
325
+ if (ctx.args.since && Number.isNaN(new Date(ctx.args.since).getTime())) {
326
+ console.error(formatError({
327
+ message: `--since must be an ISO-8601 timestamp (got "${ctx.args.since}")`,
328
+ hint: "e.g. --since 2026-01-01T00:00:00Z",
329
+ }));
330
+ return 1;
331
+ }
332
+ if (ctx.args.limit !== undefined && (!Number.isInteger(ctx.args.limit) || ctx.args.limit < 1)) {
333
+ console.error(formatError({ message: `--limit must be a positive integer (got "${ctx.args.limit}")` }));
334
+ return 1;
335
+ }
336
+
337
+ const { entries, malformed } = await collectOperatorLog(
338
+ ops.map((d) => ({ name: d.config.name, env: d.config.searchAttributes?.Env ?? "unknown" })),
339
+ { op: ctx.args.op, since: ctx.args.since, limit: ctx.args.limit },
340
+ );
341
+
342
+ if (ctx.args.json) {
343
+ console.log(JSON.stringify({ entries, malformed }, null, 2));
344
+ return 0;
345
+ }
346
+
347
+ const unreadable = malformed.converge + malformed.gates;
348
+ if (unreadable > 0) {
349
+ console.error(formatWarning({
350
+ message: `${unreadable} ledger line(s) were unreadable and are missing from this timeline ` +
351
+ `(converge: ${malformed.converge}, gates: ${malformed.gates})`,
352
+ }));
353
+ }
354
+
355
+ if (entries.length === 0) {
356
+ console.error(formatWarning({ message: "No converge ticks recorded yet" }));
357
+ return 0;
358
+ }
359
+
360
+ for (const entry of entries) {
361
+ for (const line of renderLogEntry(entry)) console.log(line);
362
+ }
363
+ return 0;
364
+ }
365
+
193
366
  // ── chant approve <op> <gate> ───────────────────────────────────────────────
194
367
 
195
368
  /**
196
- * `chant approve <op> <gate> [--actor <name>] [--note <text>]` — record the
369
+ * `chant approve <op> <gate> [--actor <name>] [--note <text>] [--url <url>]`
370
+ * — record the
197
371
  * out-of-band resolution fact for a gate a converge tick recorded as gated
198
372
  * (issue: "resolution is an out-of-band act that writes the counterpart
199
373
  * fact"). Per the issue's own leaning on open question 3 ("local trust in
@@ -220,17 +394,31 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
220
394
 
221
395
  const resolvedBy = ctx.args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
222
396
 
397
+ // #2028: the resolution's link is typed. `--url` wins; otherwise, running
398
+ // inside the PR/MR job that carries the change is itself the address, the
399
+ // same env fallback `--actor` uses. `--note` stays free-text prose.
400
+ const url = ctx.args.url ?? resolveApprovalUrl();
401
+ if (url && !isApprovalUrl(url)) {
402
+ console.error(formatError({
403
+ message: `--url must be an absolute http/https URL (got "${url}")`,
404
+ hint: "Pass the PR/MR link, or omit --url and put prose in --note.",
405
+ }));
406
+ return 1;
407
+ }
408
+
223
409
  const { record } = await appendGateResolution({
224
410
  op: opName,
225
411
  gate,
226
412
  resolvedBy,
227
413
  timestamp: new Date().toISOString(),
228
414
  ...(ctx.args.note ? { note: ctx.args.note } : {}),
415
+ ...(url ? { url } : {}),
229
416
  });
230
417
  await pushLifecycle().catch(() => undefined);
231
418
 
232
419
  console.error(formatSuccess(
233
- `Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}`,
420
+ `Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}` +
421
+ (record.url ? ` (${record.url})` : ""),
234
422
  ));
235
423
  console.error(formatInfo(
236
424
  "This records the resolution as a fact; it does not itself re-run the gated dispatch — " +
@@ -22,7 +22,7 @@ import {
22
22
  type WorkflowHistoryRaw,
23
23
  } from "./run-client";
24
24
  import { generateReport, writeReport } from "./run-report";
25
- import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
25
+ import { extractIndexedStepRecords, stepRecordEmitter, countActivities, queryGateState } from "./op-progress";
26
26
  import { runComponents, resolveComponentTargets, findComponentGate, listComponents } from "../../components/cli-support";
27
27
  import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
28
28
  import { ndjsonProgressSink } from "../../components/run-progress";
@@ -1293,17 +1293,15 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1293
1293
  // `--progress-json` (chant #1676): stream one NDJSON StepRecord per line to
1294
1294
  // stdout as each declared step settles — the durable-path counterpart to
1295
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.
1296
+ // Purely additive: when the flag is absent `emitNewRecords` stays undefined
1297
+ // and every call site is a no-op, so poll-loop behavior is unchanged.
1298
+ //
1299
+ // Dedup is by declared-step identity, not by an emitted count (chant #2032):
1300
+ // the terminal `final: true` pass *inserts* a `"skipped"` record at each
1301
+ // never-reached step's declared position, so the list is not append-only and
1302
+ // a count watermark shifts off the end of it.
1301
1303
  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
- };
1304
+ const emitNewRecords = emitProgress ? stepRecordEmitter(emitProgress) : undefined;
1307
1305
 
1308
1306
  let finalDesc: WorkflowExecutionDescription | undefined;
1309
1307
  let finalHistory: WorkflowHistoryRaw | undefined;
@@ -1315,11 +1313,11 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1315
1313
  const history = await fetchNormalizedHistory(handle);
1316
1314
 
1317
1315
  renderProgress(opName, history);
1318
- if (emitProgress) emitNewRecords(extractStepRecords(config, history));
1316
+ emitNewRecords?.(extractIndexedStepRecords(config, history));
1319
1317
 
1320
1318
  if (TERMINAL_STATUSES.has(desc.status.name)) {
1321
1319
  process.stderr.write("\n");
1322
- if (emitProgress) emitNewRecords(extractStepRecords(config, history, { final: true }));
1320
+ emitNewRecords?.(extractIndexedStepRecords(config, history, { final: true }));
1323
1321
  finalDesc = desc;
1324
1322
  finalHistory = history;
1325
1323
  break;
@@ -256,6 +256,25 @@ describe("parseArgs", () => {
256
256
  expect(result.json).toBe(true);
257
257
  });
258
258
 
259
+ test("parses --op, --since and --limit for operator log (#2029)", () => {
260
+ const result = parseArgs([
261
+ "operator", "log", "--env", "staging", "--op", "staging-converge",
262
+ "--since", "2026-01-01T00:00:00Z", "--limit", "20", "--json",
263
+ ]);
264
+ expect(result.command).toBe("operator");
265
+ expect(result.path).toBe("log");
266
+ expect(result.env).toBe("staging");
267
+ expect(result.op).toBe("staging-converge");
268
+ expect(result.since).toBe("2026-01-01T00:00:00Z");
269
+ expect(result.limit).toBe(20);
270
+ expect(result.json).toBe(true);
271
+ });
272
+
273
+ test("parses --url for approve (#2028)", () => {
274
+ const result = parseArgs(["approve", "fountain-apply", "rollout-gate", "--url", "https://github.com/org/repo/pull/1"]);
275
+ expect(result.url).toBe("https://github.com/org/repo/pull/1");
276
+ });
277
+
259
278
  // ── --no-release-record (#597) ───────────────────────────────────────────
260
279
 
261
280
  test("parses --no-release-record for run --components", () => {