@intentius/chant 0.49.0 → 0.51.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 (294) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Per-phase progress reconstructed from a Temporal Op workflow's history
3
+ * (chant #1676) — the durable-path counterpart to the local executor's
4
+ * `StepRecord[]` (op/local-executor.ts), so `chant run <name> --temporal
5
+ * --progress-json` and `op-status` can report the same shape `chant run
6
+ * <name> --json` already does for the local path, instead of the two raw
7
+ * `ActivityTaskScheduled`/`ActivityTaskCompleted` counts.
8
+ *
9
+ * A step is joined to its declared (phase, fn) by position, not by name: the
10
+ * declared step list (`declaredStepOrder`, mirroring the serializer's own
11
+ * `collectActivitySteps` traversal — main phases, then `onFailure` phases
12
+ * *reversed*, matching how the generated workflow actually runs them) is
13
+ * walked in order, and each step consumes the next not-yet-consumed
14
+ * `ActivityTaskScheduled` event whose activity type matches its `fn` — a
15
+ * per-fn FIFO queue, not a global "does this name appear anywhere" scan.
16
+ * Two steps calling the same activity therefore each get their own
17
+ * scheduled event instead of the later one overwriting the earlier
18
+ * (the collision the prior `run-report.ts` join had). A step whose queue is
19
+ * empty never ran this execution — an untaken effect branch, or an
20
+ * `onFailure` phase on a run that succeeded.
21
+ */
22
+
23
+ import type { OpConfig, PhaseDefinition } from "../../op/types";
24
+ import type { StepRecord } from "../../op/local-executor";
25
+ import type { WorkflowHistoryRaw, HistoryEvent, WorkflowHandleRaw } from "./run-client";
26
+
27
+ // Synthesized step names an effect step's read-compare-run-write compiles
28
+ // to (lexicons/temporal/src/op/serializer.ts's RECEIPT_READ_STEP/
29
+ // RECEIPT_WRITE_STEP) — duplicated here rather than imported so this module
30
+ // stays lexicon-free, same constraint as op/local-executor.ts.
31
+ const RECEIPT_READ_FN = "receiptRead";
32
+ const RECEIPT_WRITE_FN = "receiptWrite";
33
+
34
+ interface DeclaredStep {
35
+ phase: string;
36
+ fn: string;
37
+ }
38
+
39
+ function flattenPhases(phases: PhaseDefinition[]): DeclaredStep[] {
40
+ const out: DeclaredStep[] = [];
41
+ for (const phase of phases) {
42
+ for (const step of phase.steps) {
43
+ if (step.kind === "activity") {
44
+ out.push({ phase: phase.name, fn: step.fn });
45
+ } else if (step.kind === "effect") {
46
+ out.push({ phase: phase.name, fn: RECEIPT_READ_FN });
47
+ for (const nested of step.steps) {
48
+ if (nested.kind === "activity") out.push({ phase: phase.name, fn: nested.fn });
49
+ }
50
+ out.push({ phase: phase.name, fn: RECEIPT_WRITE_FN });
51
+ }
52
+ // Gate steps schedule no activity — nothing to join them to.
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Declared order matching actual scheduling order: main phases, then `onFailure` reversed (compensation runs last-declared-first — serializer.ts's `renderPhases([...config.onFailure].reverse())`). */
59
+ function declaredStepOrder(config: OpConfig): DeclaredStep[] {
60
+ return [...flattenPhases(config.phases), ...flattenPhases([...(config.onFailure ?? [])].reverse())];
61
+ }
62
+
63
+ interface ScheduledEntry {
64
+ scheduledEventId: string;
65
+ scheduledAt?: Date;
66
+ }
67
+
68
+ /** Every `ActivityTaskScheduled` event, grouped by activity type name, in chronological order. */
69
+ function scheduledQueues(events: HistoryEvent[]): Map<string, ScheduledEntry[]> {
70
+ const byFn = new Map<string, ScheduledEntry[]>();
71
+ for (const e of events) {
72
+ if (e.eventType !== "ActivityTaskScheduled") continue;
73
+ const attrs = e.activityTaskScheduledEventAttributes;
74
+ if (!attrs) continue;
75
+ const fn = attrs.activityType?.name ?? "unknown";
76
+ if (!byFn.has(fn)) byFn.set(fn, []);
77
+ byFn.get(fn)!.push({ scheduledEventId: e.eventId ?? "", scheduledAt: e.eventTime });
78
+ }
79
+ return byFn;
80
+ }
81
+
82
+ interface TerminalOutcome {
83
+ status: "ok" | "fail";
84
+ at?: Date;
85
+ error?: string;
86
+ }
87
+
88
+ /**
89
+ * The terminal outcome of each scheduled activity, keyed by the scheduling
90
+ * event's own id. A retried activity emits one `ActivityTaskFailed` per
91
+ * attempt under the same `scheduledEventId`; walking events in order and
92
+ * always overwriting on a later event leaves whichever came last —
93
+ * `ActivityTaskCompleted` on an eventual success, otherwise the final
94
+ * attempt's failure.
95
+ */
96
+ function terminalOutcomes(events: HistoryEvent[]): Map<string, TerminalOutcome> {
97
+ const out = new Map<string, TerminalOutcome>();
98
+ for (const e of events) {
99
+ if (e.eventType === "ActivityTaskCompleted" && e.activityTaskCompletedEventAttributes) {
100
+ const id = e.activityTaskCompletedEventAttributes.scheduledEventId;
101
+ if (id) out.set(id, { status: "ok", at: e.eventTime });
102
+ } else if (e.eventType === "ActivityTaskFailed" && e.activityTaskFailedEventAttributes) {
103
+ const id = e.activityTaskFailedEventAttributes.scheduledEventId;
104
+ if (id) {
105
+ out.set(id, {
106
+ status: "fail",
107
+ at: e.eventTime,
108
+ error: e.activityTaskFailedEventAttributes.failure?.message ?? "unknown error",
109
+ });
110
+ }
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Reconstruct per-step progress records from a workflow's history, in
118
+ * declared order. Only settled steps (matched to a scheduled event that has
119
+ * since completed or failed) produce a record — a step that hasn't been
120
+ * scheduled yet simply isn't in the result, so a live poll only ever
121
+ * appends. Pass `final: true` once the workflow has reached a terminal
122
+ * status to also emit a `"skipped"` record for every declared step that
123
+ * never got a matching scheduled event (an untaken effect branch, or an
124
+ * `onFailure` phase on a run that succeeded) — never before, or a
125
+ * still-running Op would show its not-yet-reached steps as skipped.
126
+ */
127
+ export function extractStepRecords(
128
+ config: OpConfig,
129
+ history: WorkflowHistoryRaw,
130
+ opts: { final?: boolean } = {},
131
+ ): StepRecord[] {
132
+ const events = history.events ?? [];
133
+ const queues = scheduledQueues(events);
134
+ const terminals = terminalOutcomes(events);
135
+ const nextIndex = new Map<string, number>();
136
+
137
+ const records: StepRecord[] = [];
138
+ for (const step of declaredStepOrder(config)) {
139
+ const queue = queues.get(step.fn);
140
+ const idx = nextIndex.get(step.fn) ?? 0;
141
+ const entry = queue?.[idx];
142
+ if (!entry) {
143
+ if (opts.final) records.push({ phase: step.phase, fn: step.fn, status: "skipped", durationMs: 0 });
144
+ continue;
145
+ }
146
+ nextIndex.set(step.fn, idx + 1);
147
+
148
+ const terminal = terminals.get(entry.scheduledEventId);
149
+ if (!terminal) continue; // scheduled but not yet settled
150
+
151
+ const durationMs =
152
+ entry.scheduledAt && terminal.at ? terminal.at.getTime() - entry.scheduledAt.getTime() : 0;
153
+ records.push({
154
+ phase: step.phase,
155
+ fn: step.fn,
156
+ status: terminal.status,
157
+ durationMs,
158
+ ...(terminal.error ? { error: terminal.error } : {}),
159
+ });
160
+ }
161
+ return records;
162
+ }
163
+
164
+ /** `{completed, scheduled}` activity counts for the summary line `chant run status`/`op-status` show alongside the full record list. */
165
+ export function countActivities(history: WorkflowHistoryRaw): { completed: number; scheduled: number } {
166
+ const events = history.events ?? [];
167
+ return {
168
+ completed: events.filter((e) => e.eventType === "ActivityTaskCompleted").length,
169
+ scheduled: events.filter((e) => e.eventType === "ActivityTaskScheduled").length,
170
+ };
171
+ }
172
+
173
+ /** The `gateState` workflow query's result (op/serializer.ts's `emitGate`) — the gate currently blocking the workflow, or `null` when none is pending. */
174
+ export interface GateQueryResult {
175
+ signalName: string;
176
+ description?: string;
177
+ since: string;
178
+ }
179
+
180
+ /**
181
+ * Best-effort `gateState` query. `undefined` (as opposed to `null`) means
182
+ * the query itself failed — an Op with no gate steps never registers a
183
+ * `gateState` handler, so querying it is expected to fail, not an error
184
+ * worth surfacing.
185
+ */
186
+ export async function queryGateState(handle: WorkflowHandleRaw): Promise<GateQueryResult | null | undefined> {
187
+ try {
188
+ return await handle.query<GateQueryResult | null>("gateState");
189
+ } catch {
190
+ return undefined;
191
+ }
192
+ }
@@ -0,0 +1,255 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+
4
+ const discoverConvergeOpsMock = vi.fn();
5
+ const runOperatorRoundMock = vi.fn();
6
+ const runOperatorForeverMock = vi.fn();
7
+ const discoverOpsMock = vi.fn();
8
+ const loadActivitiesMock = vi.fn();
9
+ const loadProfilesMock = vi.fn();
10
+ const loadChantConfigMock = vi.fn();
11
+ const readLeaseMock = vi.fn();
12
+ const readConvergeLedgerMock = vi.fn();
13
+ const appendGateResolutionMock = vi.fn();
14
+ const readGateResolutionsMock = vi.fn();
15
+ const pushLifecycleMock = vi.fn();
16
+
17
+ vi.mock("../../op/operator", async () => {
18
+ const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
19
+ return {
20
+ ...actual,
21
+ discoverConvergeOps: (...args: unknown[]) => discoverConvergeOpsMock(...args),
22
+ runOperatorRound: (...args: unknown[]) => runOperatorRoundMock(...args),
23
+ runOperatorForever: (...args: unknown[]) => runOperatorForeverMock(...args),
24
+ };
25
+ });
26
+ vi.mock("../../op/discover", () => ({ discoverOps: () => discoverOpsMock() }));
27
+ vi.mock("../../op/activity-registry", () => ({
28
+ loadActivities: (...args: unknown[]) => loadActivitiesMock(...args),
29
+ loadProfiles: (...args: unknown[]) => loadProfilesMock(...args),
30
+ }));
31
+ vi.mock("../../config", async () => {
32
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
33
+ return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
34
+ });
35
+ vi.mock("../../lifecycle/lease", async () => {
36
+ const actual = await vi.importActual<typeof import("../../lifecycle/lease")>("../../lifecycle/lease");
37
+ return { ...actual, readLease: (...args: unknown[]) => readLeaseMock(...args) };
38
+ });
39
+ vi.mock("../../lifecycle/converge-ledger", async () => {
40
+ const actual = await vi.importActual<typeof import("../../lifecycle/converge-ledger")>("../../lifecycle/converge-ledger");
41
+ return { ...actual, readConvergeLedger: (...args: unknown[]) => readConvergeLedgerMock(...args) };
42
+ });
43
+ vi.mock("../../lifecycle/gate-ledger", async () => {
44
+ const actual = await vi.importActual<typeof import("../../lifecycle/gate-ledger")>("../../lifecycle/gate-ledger");
45
+ return {
46
+ ...actual,
47
+ appendGateResolution: (...args: unknown[]) => appendGateResolutionMock(...args),
48
+ readGateResolutions: (...args: unknown[]) => readGateResolutionsMock(...args),
49
+ };
50
+ });
51
+ vi.mock("../../lifecycle/git", async () => {
52
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
53
+ return { ...actual, pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args) };
54
+ });
55
+
56
+ // Imported after the mocks above are registered.
57
+ const { runOperator, runOperatorStatus, runApprove } = await import("./operator");
58
+
59
+ function ctx(args: Partial<ParsedArgs>) {
60
+ return {
61
+ args: { command: "", path: ".", format: "", fix: false, watch: false, verbose: false, help: false, live: false, ...args } as ParsedArgs,
62
+ plugins: [],
63
+ serializers: [],
64
+ };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ loadChantConfigMock.mockResolvedValue({ config: { lexicons: [] } });
70
+ loadActivitiesMock.mockResolvedValue(new Map());
71
+ loadProfilesMock.mockResolvedValue({});
72
+ discoverOpsMock.mockResolvedValue({ ops: new Map([["fountain-apply", {}]]), errors: [] });
73
+ pushLifecycleMock.mockResolvedValue(true);
74
+ });
75
+
76
+ describe("runOperator", () => {
77
+ test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
78
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
79
+ const code = await runOperator(ctx({}));
80
+ expect(code).toBe(0);
81
+ expect(runOperatorRoundMock).not.toHaveBeenCalled();
82
+ expect(runOperatorForeverMock).not.toHaveBeenCalled();
83
+ });
84
+
85
+ test("--once runs exactly one round and exits 0 when nothing failed", async () => {
86
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
87
+ runOperatorRoundMock.mockResolvedValue([{ kind: "ticked", op: "staging-converge", env: "staging", result: { op: "staging-converge", records: [], totalMs: 1, ok: true } }]);
88
+
89
+ const code = await runOperator(ctx({ once: true }));
90
+ expect(code).toBe(0);
91
+ expect(runOperatorRoundMock).toHaveBeenCalledTimes(1);
92
+ expect(runOperatorForeverMock).not.toHaveBeenCalled();
93
+ });
94
+
95
+ test("--once exits 1 when any op's tick failed this round", async () => {
96
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
97
+ runOperatorRoundMock.mockResolvedValue([{ kind: "tick-failed", op: "staging-converge", env: "staging", error: "boom" }]);
98
+
99
+ const code = await runOperator(ctx({ once: true }));
100
+ expect(code).toBe(1);
101
+ });
102
+
103
+ test("without --once, runs the daemon loop (runOperatorForever) and exits 0 when it returns", async () => {
104
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
105
+ runOperatorForeverMock.mockResolvedValue(undefined);
106
+
107
+ const code = await runOperator(ctx({}));
108
+ expect(code).toBe(0);
109
+ expect(runOperatorForeverMock).toHaveBeenCalledTimes(1);
110
+ expect(runOperatorRoundMock).not.toHaveBeenCalled();
111
+ });
112
+
113
+ test("--interval and --lease-ttl are parsed as durations and threaded through", async () => {
114
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
115
+ runOperatorForeverMock.mockResolvedValue(undefined);
116
+
117
+ await runOperator(ctx({ interval: "30s", leaseTtl: "2m" }));
118
+ const passed = runOperatorForeverMock.mock.calls[0][0];
119
+ expect(passed.intervalMs).toBe(30_000);
120
+ expect(passed.leaseTtlMs).toBe(120_000);
121
+ });
122
+
123
+ test("--env is passed through to discovery and the round/loop", async () => {
124
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "prod-converge" } }], errors: [] });
125
+ runOperatorRoundMock.mockResolvedValue([]);
126
+
127
+ await runOperator(ctx({ once: true, env: "prod" }));
128
+ expect(discoverConvergeOpsMock).toHaveBeenCalledWith({ env: "prod" });
129
+ expect(runOperatorRoundMock.mock.calls[0][0].env).toBe("prod");
130
+ });
131
+
132
+ test("a failure loading activities is reported and exits 1", async () => {
133
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
134
+ loadActivitiesMock.mockRejectedValue(new Error("no activities registered"));
135
+
136
+ const code = await runOperator(ctx({ once: true }));
137
+ expect(code).toBe(1);
138
+ });
139
+ });
140
+
141
+ describe("runOperatorStatus", () => {
142
+ test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
143
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
144
+ const code = await runOperatorStatus(ctx({}));
145
+ expect(code).toBe(0);
146
+ });
147
+
148
+ test("--json emits one row per discovered ConvergeOp with last tick, lease, and pending gates", async () => {
149
+ discoverConvergeOpsMock.mockResolvedValue({
150
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
151
+ errors: [],
152
+ });
153
+ readConvergeLedgerMock.mockResolvedValue({
154
+ records: [
155
+ {
156
+ version: 1,
157
+ op: "staging-converge",
158
+ env: "staging",
159
+ timestamp: "2026-01-01T00:00:00.000Z",
160
+ firedRuleIds: ["drift-apply"],
161
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
162
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
163
+ log: "converge(staging): ...",
164
+ },
165
+ ],
166
+ malformed: 0,
167
+ });
168
+ readLeaseMock.mockResolvedValue({ sha: "abc", record: { op: "staging-converge", holder: "op-a", token: "t1", acquiredAt: "x", expiresAt: "y" } });
169
+ readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 }); // no resolution yet — still pending
170
+
171
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
172
+ const code = await runOperatorStatus(ctx({ json: true }));
173
+ expect(code).toBe(0);
174
+
175
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
176
+ expect(printed).toHaveLength(1);
177
+ expect(printed[0].op).toBe("staging-converge");
178
+ expect(printed[0].lease).toEqual({ holder: "op-a", expiresAt: "y" });
179
+ expect(printed[0].pendingGates).toEqual([{ rule: "drift-apply", op: "fountain-apply", gate: "rollout-gate" }]);
180
+ logSpy.mockRestore();
181
+ });
182
+
183
+ test("a gate resolved after the tick that recorded it is no longer pending", async () => {
184
+ discoverConvergeOpsMock.mockResolvedValue({
185
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
186
+ errors: [],
187
+ });
188
+ readConvergeLedgerMock.mockResolvedValue({
189
+ records: [
190
+ {
191
+ version: 1,
192
+ op: "staging-converge",
193
+ env: "staging",
194
+ timestamp: "2026-01-01T00:00:00.000Z",
195
+ firedRuleIds: ["drift-apply"],
196
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
197
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
198
+ log: "converge(staging): ...",
199
+ },
200
+ ],
201
+ malformed: 0,
202
+ });
203
+ readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
204
+ readGateResolutionsMock.mockResolvedValue({
205
+ records: [{ version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z" }],
206
+ malformed: 0,
207
+ });
208
+
209
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
210
+ await runOperatorStatus(ctx({ json: true }));
211
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
212
+ expect(printed[0].pendingGates).toEqual([]);
213
+ logSpy.mockRestore();
214
+ });
215
+ });
216
+
217
+ describe("runApprove", () => {
218
+ test("requires both <op> and <gate>", async () => {
219
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
220
+ expect(await runApprove(ctx({ path: "." }))).toBe(1);
221
+ expect(await runApprove(ctx({ path: "fountain-apply" }))).toBe(1);
222
+ errSpy.mockRestore();
223
+ });
224
+
225
+ test("appends a gate-resolution record and pushes, resolving --actor over env fallbacks", async () => {
226
+ appendGateResolutionMock.mockResolvedValue({
227
+ commit: "sha",
228
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
229
+ });
230
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
231
+
232
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", note: "https://pr/1" }));
233
+
234
+ expect(code).toBe(0);
235
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(
236
+ expect.objectContaining({ op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", note: "https://pr/1" }),
237
+ );
238
+ expect(pushLifecycleMock).toHaveBeenCalled();
239
+ errSpy.mockRestore();
240
+ });
241
+
242
+ test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
243
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
244
+ appendGateResolutionMock.mockResolvedValue({
245
+ commit: "sha",
246
+ record: { version: 1, op: "unknown-op", gate: "g", resolvedBy: "unknown", timestamp: "2026-01-01T00:00:00.000Z" },
247
+ });
248
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
249
+
250
+ const code = await runApprove(ctx({ path: "unknown-op", extraPositional: "g" }));
251
+ expect(code).toBe(0);
252
+ expect(appendGateResolutionMock).toHaveBeenCalled();
253
+ errSpy.mockRestore();
254
+ });
255
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * `chant operator` / `chant operator status` / `chant approve` (#1485,
3
+ * epic #1487) — the CLI surface for the native durable-tick operator. See
4
+ * `../../op/operator.ts` for the loop itself; this module is the thin CLI
5
+ * shell around it (discover, load activities, print), mirroring
6
+ * `run.ts`'s `runOpLocal` shape (SIGINT → AbortController, `loadActivities`/
7
+ * `loadProfiles` from the project's configured lexicons).
8
+ */
9
+ import { loadChantConfig } from "../../config";
10
+ import { discoverOps } from "../../op/discover";
11
+ import { loadActivities, loadProfiles } from "../../op/activity-registry";
12
+ import { parseDuration } from "../../op/local-executor";
13
+ import {
14
+ discoverConvergeOps,
15
+ runOperatorRound,
16
+ runOperatorForever,
17
+ formatRoundLine,
18
+ DEFAULT_OPERATOR_INTERVAL_MS,
19
+ type OperatorTickEvent,
20
+ } from "../../op/operator";
21
+ import { readLease, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
22
+ import { readConvergeLedger, type ConvergeTickRecord } from "../../lifecycle/converge-ledger";
23
+ import { appendGateResolution, readGateResolutions, latestResolutionSince } from "../../lifecycle/gate-ledger";
24
+ import { pushLifecycle } from "../../lifecycle/git";
25
+ import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
26
+ import type { CommandContext } from "../registry";
27
+
28
+ /** Load the activities a local tick needs, the same way `runOpLocal` does: base temporal activities plus whatever the project's configured lexicons contribute. Best-effort on the lexicon list — an unreadable `chant.config.ts` falls back to base activities only. */
29
+ async function loadOperatorActivities() {
30
+ let lexicons: string[] = [];
31
+ try {
32
+ lexicons = (await loadChantConfig(process.cwd())).config.lexicons ?? [];
33
+ } catch {
34
+ // No/invalid chant.config — fall back to base activities only.
35
+ }
36
+ return Promise.all([loadActivities(lexicons), loadProfiles()]);
37
+ }
38
+
39
+ // ── chant operator ──────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * `chant operator [--env <env>] [--interval <duration>] [--lease-ttl
43
+ * <duration>] [--once]` — run scheduled ticks for this project's discovered
44
+ * ConvergeOps, locally, with no Temporal installed (issue's own worked
45
+ * example). `--once` runs a single round and exits (also the offline test
46
+ * story, and what a cron/systemd-timer/k8s-CronJob invoker uses instead of
47
+ * leaving the daemon running); omitted, the daemon loops until Ctrl-C.
48
+ */
49
+ export async function runOperator(ctx: CommandContext): Promise<number> {
50
+ const { ops, errors } = await discoverConvergeOps({ env: ctx.args.env });
51
+ for (const err of errors) console.error(formatWarning({ message: err }));
52
+
53
+ if (ops.length === 0) {
54
+ console.error(formatWarning({
55
+ message: ctx.args.env
56
+ ? `No ConvergeOp declarations found for env "${ctx.args.env}"`
57
+ : "No ConvergeOp declarations found (*.op.ts built with ConvergeOp(...))",
58
+ }));
59
+ return 0;
60
+ }
61
+
62
+ let activities, profiles;
63
+ try {
64
+ [activities, profiles] = await loadOperatorActivities();
65
+ } catch (err) {
66
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
67
+ return 1;
68
+ }
69
+
70
+ const intervalMs = ctx.args.interval ? parseDuration(ctx.args.interval) : DEFAULT_OPERATOR_INTERVAL_MS;
71
+ const leaseTtlMs = ctx.args.leaseTtl ? parseDuration(ctx.args.leaseTtl) : DEFAULT_LEASE_TTL_MS;
72
+
73
+ const controller = new AbortController();
74
+ const onSigint = () => {
75
+ console.error(formatWarning({ message: "interrupted — stopping operator" }));
76
+ controller.abort();
77
+ };
78
+ process.once("SIGINT", onSigint);
79
+
80
+ const printRound = (events: OperatorTickEvent[]) => {
81
+ for (const event of events) console.error(formatInfo(formatRoundLine(event)));
82
+ };
83
+
84
+ try {
85
+ if (ctx.args.once) {
86
+ const events = await runOperatorRound({ env: ctx.args.env, leaseTtlMs, activities, profiles, signal: controller.signal });
87
+ printRound(events);
88
+ return events.some((e) => e.kind === "tick-failed") ? 1 : 0;
89
+ }
90
+
91
+ console.error(formatInfo(
92
+ `chant operator: watching ${ops.length} ConvergeOp(s) every ${intervalMs}ms (Ctrl-C to stop)`,
93
+ ));
94
+ await runOperatorForever({
95
+ env: ctx.args.env,
96
+ intervalMs,
97
+ leaseTtlMs,
98
+ activities,
99
+ profiles,
100
+ signal: controller.signal,
101
+ onRound: printRound,
102
+ });
103
+ return 0;
104
+ } finally {
105
+ process.removeListener("SIGINT", onSigint);
106
+ }
107
+ }
108
+
109
+ // ── chant operator status ───────────────────────────────────────────────────
110
+
111
+ interface OpStatusLine {
112
+ op: string;
113
+ env: string;
114
+ lastTick?: ConvergeTickRecord;
115
+ pendingGates: { rule: string; op?: string; gate: string }[];
116
+ lease?: { holder: string; expiresAt: string };
117
+ }
118
+
119
+ async function statusFor(opName: string, env: string, cwd?: string): Promise<OpStatusLine> {
120
+ const [{ records }, { record: lease }] = await Promise.all([
121
+ readConvergeLedger(env, { cwd }),
122
+ readLease(opName, { cwd }),
123
+ ]);
124
+ const ownRecords = records.filter((r) => r.op === opName);
125
+ const lastTick = ownRecords.at(-1);
126
+
127
+ const pendingGates: OpStatusLine["pendingGates"] = [];
128
+ if (lastTick) {
129
+ for (const outcome of lastTick.outcomes) {
130
+ if (outcome.action !== "gated" || !outcome.gateName || !outcome.op) continue;
131
+ const { records: resolutions } = await readGateResolutions(outcome.op, { cwd });
132
+ const resolved = latestResolutionSince(resolutions, outcome.gateName, lastTick.timestamp);
133
+ if (!resolved) pendingGates.push({ rule: outcome.ruleId, op: outcome.op, gate: outcome.gateName });
134
+ }
135
+ }
136
+
137
+ return {
138
+ op: opName,
139
+ env,
140
+ lastTick,
141
+ pendingGates,
142
+ lease: lease ? { holder: lease.holder, expiresAt: lease.expiresAt } : undefined,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * `chant operator status [--env <env>] [--json]` — last tick, outcome
148
+ * counts, and pending gates, read from the `chant/lifecycle` orphan branch
149
+ * alone (issue acceptance criterion). No live query, no daemon needs to be
150
+ * running: everything here was already durably recorded by whichever
151
+ * process (this operator, a bare `chant run <op>`, or another machine's
152
+ * operator) ran the last tick.
153
+ */
154
+ export async function runOperatorStatus(ctx: CommandContext): Promise<number> {
155
+ const { ops, errors } = await discoverConvergeOps({ env: ctx.args.env });
156
+ for (const err of errors) console.error(formatWarning({ message: err }));
157
+
158
+ if (ops.length === 0) {
159
+ console.error(formatWarning({ message: "No ConvergeOp declarations found" }));
160
+ return 0;
161
+ }
162
+
163
+ const rows = await Promise.all(
164
+ ops.map((d) => statusFor(d.config.name, d.config.searchAttributes?.Env ?? "unknown")),
165
+ );
166
+
167
+ if (ctx.args.json) {
168
+ console.log(JSON.stringify(rows, null, 2));
169
+ return 0;
170
+ }
171
+
172
+ for (const row of rows) {
173
+ console.log(formatBold(`${row.op} (${row.env})`));
174
+ if (!row.lastTick) {
175
+ console.log(" no ticks recorded yet");
176
+ } else {
177
+ console.log(` last tick : ${row.lastTick.timestamp}`);
178
+ console.log(` ${row.lastTick.log}`);
179
+ }
180
+ console.log(` lease : ${row.lease ? `held by ${row.lease.holder} (expires ${row.lease.expiresAt})` : "free"}`);
181
+ if (row.pendingGates.length > 0) {
182
+ console.log(` pending gates:`);
183
+ for (const g of row.pendingGates) {
184
+ console.log(` - ${g.op ?? row.op} gate "${g.gate}" (rule ${g.rule}) — resolve: chant approve ${g.op ?? row.op} ${g.gate}`);
185
+ }
186
+ }
187
+ console.log("");
188
+ }
189
+
190
+ return 0;
191
+ }
192
+
193
+ // ── chant approve <op> <gate> ───────────────────────────────────────────────
194
+
195
+ /**
196
+ * `chant approve <op> <gate> [--actor <name>] [--note <text>]` — record the
197
+ * out-of-band resolution fact for a gate a converge tick recorded as gated
198
+ * (issue: "resolution is an out-of-band act that writes the counterpart
199
+ * fact"). Per the issue's own leaning on open question 3 ("local trust in
200
+ * v1"), this performs no authorization check beyond "you can run `chant
201
+ * approve` locally" — see `../../lifecycle/gate-ledger.ts`'s doc for what
202
+ * this record does and, just as importantly, does not (yet) do: it does
203
+ * not retroactively unblock the gated op's own local dispatch, which the
204
+ * local executor still refuses unconditionally.
205
+ */
206
+ export async function runApprove(ctx: CommandContext): Promise<number> {
207
+ const opName = ctx.args.path;
208
+ const gate = ctx.args.extraPositional;
209
+ if (!opName || opName === "." || !gate) {
210
+ console.error(formatError({ message: "Usage: chant approve <op> <gate>" }));
211
+ return 1;
212
+ }
213
+
214
+ const { ops } = await discoverOps();
215
+ if (!ops.has(opName)) {
216
+ console.error(formatWarning({
217
+ message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
218
+ }));
219
+ }
220
+
221
+ const resolvedBy = ctx.args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
222
+
223
+ const { record } = await appendGateResolution({
224
+ op: opName,
225
+ gate,
226
+ resolvedBy,
227
+ timestamp: new Date().toISOString(),
228
+ ...(ctx.args.note ? { note: ctx.args.note } : {}),
229
+ });
230
+ await pushLifecycle().catch(() => undefined);
231
+
232
+ console.error(formatSuccess(
233
+ `Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}`,
234
+ ));
235
+ console.error(formatInfo(
236
+ "This records the resolution as a fact; it does not itself re-run the gated dispatch — " +
237
+ "the local executor still refuses any op with a gate. Re-run it with --temporal, or via the PR that carries the change.",
238
+ ));
239
+ return 0;
240
+ }