@intentius/chant 0.1.6 → 0.1.8

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/build.test.ts +58 -5
  3. package/src/cli/commands/build.ts +24 -3
  4. package/src/cli/handlers/graph.test.ts +91 -0
  5. package/src/cli/handlers/graph.ts +23 -0
  6. package/src/cli/handlers/run-client.ts +134 -0
  7. package/src/cli/handlers/run-report.ts +160 -0
  8. package/src/cli/handlers/run.test.ts +448 -0
  9. package/src/cli/handlers/run.ts +453 -0
  10. package/src/cli/handlers/state.test.ts +409 -0
  11. package/src/cli/handlers/state.ts +232 -10
  12. package/src/cli/main.test.ts +65 -0
  13. package/src/cli/main.ts +32 -18
  14. package/src/cli/mcp/op-tools.ts +204 -0
  15. package/src/cli/mcp/resource-handlers.ts +69 -50
  16. package/src/cli/mcp/resources/context.ts +27 -0
  17. package/src/cli/mcp/server.test.ts +176 -3
  18. package/src/cli/mcp/server.ts +7 -3
  19. package/src/cli/mcp/state-tools.ts +0 -51
  20. package/src/cli/mcp/tools/search.ts +6 -1
  21. package/src/cli/registry.ts +3 -0
  22. package/src/composite.ts +10 -5
  23. package/src/index.ts +1 -2
  24. package/src/lexicon-plugin-helpers.ts +13 -5
  25. package/src/lexicon.ts +57 -1
  26. package/src/lint/config.test.ts +21 -0
  27. package/src/lint/config.ts +19 -3
  28. package/src/op/discover.test.ts +43 -0
  29. package/src/op/discover.ts +89 -0
  30. package/src/op/index.ts +3 -1
  31. package/src/op/types.ts +13 -6
  32. package/src/state/digest.test.ts +117 -0
  33. package/src/state/git.test.ts +191 -0
  34. package/src/state/git.ts +63 -11
  35. package/src/state/live-diff.test.ts +184 -0
  36. package/src/state/live-diff.ts +215 -0
  37. package/src/state/snapshot.test.ts +171 -0
  38. package/src/state/snapshot.ts +39 -19
  39. package/src/state/types.ts +4 -2
  40. package/src/cli/handlers/spell.ts +0 -396
  41. package/src/spell/discovery.ts +0 -183
  42. package/src/spell/index.ts +0 -3
  43. package/src/spell/prompt.ts +0 -133
  44. package/src/spell/types.ts +0 -89
@@ -198,6 +198,7 @@ describe("resolveCommand", () => {
198
198
  watch: false,
199
199
  verbose: false,
200
200
  help: false,
201
+ live: false,
201
202
  ...overrides,
202
203
  };
203
204
  }
@@ -254,4 +255,68 @@ describe("resolveCommand", () => {
254
255
  expect(result!.def.name).toBe("dev generate");
255
256
  expect(result!.compound).toBe(true);
256
257
  });
258
+
259
+ test("resolves run status as compound command", () => {
260
+ const registry: CommandDef[] = [
261
+ { name: "run list", handler: noop },
262
+ { name: "run status", handler: noop },
263
+ { name: "run signal", handler: noop },
264
+ { name: "run cancel", handler: noop },
265
+ { name: "run log", handler: noop },
266
+ { name: "run", handler: noop },
267
+ ];
268
+ const result = resolveCommand(makeArgs({ command: "run", path: "status" }), registry);
269
+ expect(result!.def.name).toBe("run status");
270
+ expect(result!.compound).toBe(true);
271
+ });
272
+
273
+ test("resolves run <name> as simple command", () => {
274
+ const registry: CommandDef[] = [
275
+ { name: "run list", handler: noop },
276
+ { name: "run status", handler: noop },
277
+ { name: "run", handler: noop },
278
+ ];
279
+ const result = resolveCommand(makeArgs({ command: "run", path: "alb-deploy" }), registry);
280
+ expect(result!.def.name).toBe("run");
281
+ expect(result!.compound).toBe(false);
282
+ });
283
+ });
284
+
285
+ describe("parseArgs — run flags", () => {
286
+ test("parses --profile flag", () => {
287
+ const result = parseArgs(["run", "alb-deploy", "--profile", "local"]);
288
+ expect(result.command).toBe("run");
289
+ expect(result.path).toBe("alb-deploy");
290
+ expect(result.profile).toBe("local");
291
+ });
292
+
293
+ test("parses -p shorthand for --profile", () => {
294
+ const result = parseArgs(["run", "alb-deploy", "-p", "cloud"]);
295
+ expect(result.profile).toBe("cloud");
296
+ });
297
+
298
+ test("parses --report flag", () => {
299
+ const result = parseArgs(["run", "alb-deploy", "--report"]);
300
+ expect(result.command).toBe("run");
301
+ expect(result.path).toBe("alb-deploy");
302
+ expect(result.report).toBe(true);
303
+ });
304
+
305
+ test("report is undefined when not provided", () => {
306
+ const result = parseArgs(["run", "alb-deploy"]);
307
+ expect(result.report).toBe(undefined);
308
+ });
309
+
310
+ test("profile is undefined when not provided", () => {
311
+ const result = parseArgs(["run", "alb-deploy"]);
312
+ expect(result.profile).toBe(undefined);
313
+ });
314
+
315
+ test("run signal parses op name and signal into positionals", () => {
316
+ const result = parseArgs(["run", "signal", "alb-deploy", "gate-dns"]);
317
+ expect(result.command).toBe("run");
318
+ expect(result.path).toBe("signal");
319
+ expect(result.extraPositional).toBe("alb-deploy");
320
+ expect(result.extraPositional2).toBe("gate-dns");
321
+ });
257
322
  });
package/src/cli/main.ts CHANGED
@@ -13,7 +13,8 @@ import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
13
13
  import { runInit, runInitLexicon } from "./handlers/init";
14
14
  import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
15
15
  import { runStateSnapshot, runStateShow, runStateDiff, runStateLog, runStateUnknown } from "./handlers/state";
16
- import { runSpellAdd, runSpellRm, runSpellList, runSpellShow, runSpellCast, runSpellDone, runGraph, runSpellUnknown } from "./handlers/spell";
16
+ import { runGraph } from "./handlers/graph";
17
+ import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
17
18
 
18
19
  /**
19
20
  * Parse command line arguments
@@ -33,6 +34,9 @@ export function parseArgs(args: string[]): ParsedArgs {
33
34
  watch: false,
34
35
  verbose: false,
35
36
  help: false,
37
+ profile: undefined,
38
+ report: undefined,
39
+ live: false,
36
40
  };
37
41
 
38
42
  let i = 0;
@@ -57,6 +61,12 @@ export function parseArgs(args: string[]): ParsedArgs {
57
61
  result.watch = true;
58
62
  } else if (arg === "--verbose" || arg === "-v") {
59
63
  result.verbose = true;
64
+ } else if (arg === "--profile" || arg === "-p") {
65
+ result.profile = args[++i];
66
+ } else if (arg === "--report") {
67
+ result.report = true;
68
+ } else if (arg === "--live") {
69
+ result.live = true;
60
70
  } else if (!arg.startsWith("-")) {
61
71
  if (!result.command) {
62
72
  result.command = arg;
@@ -93,19 +103,21 @@ Commands:
93
103
  list List discovered entities
94
104
  import Import external template into TypeScript
95
105
 
96
- Spells:
97
- spell add <name> Create a new spell
98
- spell rm <name> Remove a spell
99
- spell list List all spells with status
100
- spell show <name> Show spell details
101
- spell cast <name> Generate bootstrap prompt for agent
102
- spell done <name> <N> Mark task N as done
103
- graph Show spell dependency graph
106
+ Ops:
107
+ run <name> Start an Op workflow (spawns worker + submits to Temporal)
108
+ run list List all Ops with current run status
109
+ run status <name> Show current workflow run state
110
+ run signal <name> <signal> Send a named signal to unblock a gate
111
+ run cancel <name> Cancel the active workflow run (requires --force)
112
+ run log <name> Show run history for an Op
113
+
114
+ graph Show Op dependency graph
104
115
 
105
116
  State:
106
117
  state snapshot <env> Query API, save metadata to orphan branch
107
118
  state show <env> Show latest state snapshot
108
119
  state diff <env> Compare current build against last snapshot
120
+ --live: query cloud now and detect drift
109
121
  state log [env] History of state snapshots
110
122
 
111
123
  Lexicon development:
@@ -135,6 +147,8 @@ Options:
135
147
  -w, --watch Watch for changes and rebuild/re-lint (build, lint)
136
148
  -v, --verbose Show stack traces on errors
137
149
  -h, --help Show this help message
150
+ -p, --profile <name> Temporal worker profile to use (run command)
151
+ --report Print deployment report instead of running (run command)
138
152
 
139
153
  Examples:
140
154
  chant build ./infra/
@@ -194,13 +208,14 @@ const registry: CommandDef[] = [
194
208
  { name: "dev onboard", handler: runDevOnboard },
195
209
  { name: "dev check-lexicon", handler: runDevCheckLexicon },
196
210
 
197
- // Spell subcommands
198
- { name: "spell add", handler: runSpellAdd },
199
- { name: "spell rm", handler: runSpellRm },
200
- { name: "spell list", handler: runSpellList },
201
- { name: "spell show", handler: runSpellShow },
202
- { name: "spell cast", handler: runSpellCast },
203
- { name: "spell done", handler: runSpellDone },
211
+ // Op / run subcommands
212
+ { name: "run list", handler: runOpList },
213
+ { name: "run status", handler: runOpStatus },
214
+ { name: "run signal", handler: runOpSignal },
215
+ { name: "run cancel", handler: runOpCancel },
216
+ { name: "run log", handler: runOpLog },
217
+ { name: "run", handler: runOp },
218
+
204
219
  { name: "graph", handler: runGraph },
205
220
 
206
221
  // State subcommands
@@ -214,7 +229,6 @@ const registry: CommandDef[] = [
214
229
  { name: "serve mcp", requiresPlugins: true, handler: runServeMcp },
215
230
 
216
231
  // Fallback for unknown subcommands (must come after compound entries)
217
- { name: "spell", handler: runSpellUnknown },
218
232
  { name: "state", handler: runStateUnknown },
219
233
  { name: "dev", handler: runDevUnknown },
220
234
  { name: "serve", handler: runServeUnknown },
@@ -250,7 +264,7 @@ async function main(): Promise<void> {
250
264
  process.exit(1);
251
265
  }
252
266
 
253
- // For compound commands (e.g. "spell cast"), args.path is the subcommand,
267
+ // For compound commands (e.g. "run list"), args.path is the subcommand,
254
268
  // so always use "." as the project path. For simple commands, use args.path.
255
269
  const projectPath = match.compound ? (args.extraPositional || ".") : args.path;
256
270
  const plugins = match.def.requiresPlugins
@@ -0,0 +1,204 @@
1
+ import { resolve } from "node:path";
2
+ import { discoverOps } from "../../op/discover";
3
+ import { makeTemporalClient } from "../handlers/run";
4
+ import { resolveWorkflowId } from "../handlers/run-client";
5
+ import { generateReport } from "../handlers/run-report";
6
+ import type { ToolRegistration } from "./state-tools";
7
+
8
+ function workflowFnName(opName: string): string {
9
+ return opName.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()) + "Workflow";
10
+ }
11
+
12
+ export function createOpListTool(): ToolRegistration {
13
+ return {
14
+ definition: {
15
+ name: "op-list",
16
+ description: "List all Op definitions discovered from *.op.ts files with their current run status",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
21
+ },
22
+ },
23
+ },
24
+ handler: async (params) => {
25
+ const { ops, errors } = await discoverOps();
26
+ const profile = params.profile as string | undefined;
27
+
28
+ let client: Awaited<ReturnType<typeof makeTemporalClient>>["client"] | undefined;
29
+ try {
30
+ ({ client } = await makeTemporalClient(profile, resolve(".")));
31
+ } catch {
32
+ // Temporal not available — degrade gracefully
33
+ }
34
+
35
+ const result = [];
36
+ for (const [name, { config }] of ops) {
37
+ let runStatus = "—";
38
+ if (client) {
39
+ try {
40
+ const desc = await client.workflow.getHandle(resolveWorkflowId(name)).describe();
41
+ runStatus = desc.status.name;
42
+ } catch {
43
+ // workflow not found or Temporal error
44
+ }
45
+ }
46
+ result.push({
47
+ name,
48
+ overview: config.overview,
49
+ phases: config.phases.length,
50
+ taskQueue: config.taskQueue ?? config.name,
51
+ depends: config.depends ?? [],
52
+ runStatus,
53
+ });
54
+ }
55
+
56
+ return { ops: result, errors };
57
+ },
58
+ };
59
+ }
60
+
61
+ export function createOpRunTool(): ToolRegistration {
62
+ return {
63
+ definition: {
64
+ name: "op-run",
65
+ description:
66
+ "Submit an Op workflow to Temporal. The worker must already be running — start it first with `chant run <name>`.",
67
+ inputSchema: {
68
+ type: "object",
69
+ properties: {
70
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
71
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
72
+ },
73
+ required: ["name"],
74
+ },
75
+ },
76
+ handler: async (params) => {
77
+ const name = params.name as string;
78
+ const profile = params.profile as string | undefined;
79
+
80
+ const { ops } = await discoverOps();
81
+ if (!ops.has(name)) {
82
+ const available = [...ops.keys()];
83
+ return `Op "${name}" not found. Available: ${available.join(", ") || "none"}`;
84
+ }
85
+
86
+ const { config } = ops.get(name)!;
87
+ const { client, profile: resolvedProfile } = await makeTemporalClient(profile, resolve("."));
88
+ const workflowId = resolveWorkflowId(name);
89
+ const taskQueue = resolvedProfile.taskQueue ?? config.taskQueue ?? name;
90
+
91
+ await client.workflow.start(workflowFnName(name), {
92
+ taskQueue,
93
+ workflowId,
94
+ workflowIdConflictPolicy: "FAIL",
95
+ });
96
+
97
+ return { workflowId, message: `Workflow "${workflowId}" submitted to task queue "${taskQueue}"` };
98
+ },
99
+ };
100
+ }
101
+
102
+ export function createOpStatusTool(): ToolRegistration {
103
+ return {
104
+ definition: {
105
+ name: "op-status",
106
+ description: "Return the current run state of an Op workflow",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
111
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
112
+ },
113
+ required: ["name"],
114
+ },
115
+ },
116
+ handler: async (params) => {
117
+ const name = params.name as string;
118
+ const profile = params.profile as string | undefined;
119
+
120
+ const { client } = await makeTemporalClient(profile, resolve("."));
121
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
122
+ const desc = await handle.describe();
123
+ const history = await handle.fetchHistory();
124
+
125
+ const events = history.events ?? [];
126
+ const activitiesCompleted = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
127
+ const activitiesScheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
128
+
129
+ return {
130
+ workflowId: desc.workflowId,
131
+ runId: desc.runId,
132
+ status: desc.status.name,
133
+ startTime: desc.startTime,
134
+ closeTime: desc.closeTime ?? null,
135
+ taskQueue: desc.taskQueue,
136
+ activitiesCompleted,
137
+ activitiesScheduled,
138
+ };
139
+ },
140
+ };
141
+ }
142
+
143
+ export function createOpSignalTool(): ToolRegistration {
144
+ return {
145
+ definition: {
146
+ name: "op-signal",
147
+ description: "Send a named signal to an Op workflow (e.g. to unblock a gate step)",
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {
151
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
152
+ signal: { type: "string", description: "Signal name (e.g. gate-dns-delegation)" },
153
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
154
+ },
155
+ required: ["name", "signal"],
156
+ },
157
+ },
158
+ handler: async (params) => {
159
+ const name = params.name as string;
160
+ const signal = params.signal as string;
161
+ const profile = params.profile as string | undefined;
162
+
163
+ const { client } = await makeTemporalClient(profile, resolve("."));
164
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
165
+ await handle.signal(signal);
166
+
167
+ return `Signal "${signal}" sent to Op "${name}"`;
168
+ },
169
+ };
170
+ }
171
+
172
+ export function createOpReportTool(): ToolRegistration {
173
+ return {
174
+ definition: {
175
+ name: "op-report",
176
+ description: "Return a markdown deployment report for the latest run of an Op",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
181
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
182
+ },
183
+ required: ["name"],
184
+ },
185
+ },
186
+ handler: async (params) => {
187
+ const name = params.name as string;
188
+ const profile = params.profile as string | undefined;
189
+
190
+ const { ops } = await discoverOps();
191
+ if (!ops.has(name)) {
192
+ return `Op "${name}" not found`;
193
+ }
194
+ const { config } = ops.get(name)!;
195
+
196
+ const { client } = await makeTemporalClient(profile, resolve("."));
197
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
198
+ const desc = await handle.describe();
199
+ const history = await handle.fetchHistory();
200
+
201
+ return generateReport(name, config, desc, history);
202
+ },
203
+ };
204
+ }
@@ -1,9 +1,10 @@
1
+ import { resolve } from "node:path";
1
2
  import type { ResourceDefinition } from "./types";
2
3
  import { getContext } from "./resources/context";
3
4
  import { readSnapshot, readEnvironmentSnapshots } from "../../state/git";
4
- import { discoverSpells } from "../../spell/discovery";
5
- import { generatePrompt } from "../../spell/prompt";
6
- import { getRuntime } from "../../runtime-adapter";
5
+ import { discoverOps } from "../../op/discover";
6
+ import { makeTemporalClient } from "../handlers/run";
7
+ import { resolveWorkflowId } from "../handlers/run-client";
7
8
 
8
9
  type PluginResourceEntry = { definition: ResourceDefinition; handler: () => Promise<string> };
9
10
 
@@ -24,22 +25,22 @@ export const coreResourceDefinitions: ResourceDefinition[] = [
24
25
  mimeType: "application/json",
25
26
  },
26
27
  {
27
- uri: "chant://spells",
28
- name: "Spells",
29
- description: "List all spells with status, tasks, and lexicon",
28
+ uri: "chant://ops",
29
+ name: "Ops",
30
+ description: "All Op definitions discovered from *.op.ts files",
30
31
  mimeType: "application/json",
31
32
  },
32
33
  {
33
- uri: "chant://spell/{name}",
34
- name: "Spell details",
35
- description: "Show spell definition and status",
34
+ uri: "chant://ops/{name}/runs",
35
+ name: "Op run history",
36
+ description: "Workflow run history for a named Op",
36
37
  mimeType: "application/json",
37
38
  },
38
39
  {
39
- uri: "chant://spell/{name}/prompt",
40
- name: "Spell bootstrap prompt",
41
- description: "Bootstrap prompt for agent consumption",
42
- mimeType: "text/markdown",
40
+ uri: "chant://ops/{name}/runs/latest",
41
+ name: "Op latest run",
42
+ description: "Latest run state for a named Op",
43
+ mimeType: "application/json",
43
44
  },
44
45
  {
45
46
  uri: "chant://state/{environment}",
@@ -84,6 +85,10 @@ export function collectExamples(
84
85
  return examples;
85
86
  }
86
87
 
88
+ function opWorkflowFnName(opName: string): string {
89
+ return opName.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()) + "Workflow";
90
+ }
91
+
87
92
  /**
88
93
  * Handle resources/read request — checks plugin resources after core
89
94
  */
@@ -117,51 +122,65 @@ export async function handleResourcesRead(
117
122
  };
118
123
  }
119
124
 
120
- // Spell resources
121
- if (uri === "chant://spells") {
122
- const { spells } = await discoverSpells();
123
- const list = Array.from(spells.entries()).map(([name, s]) => ({
124
- name,
125
- status: s.status,
126
- tasks: `${s.definition.tasks.filter((t) => t.done).length}/${s.definition.tasks.length}`,
127
- lexicon: s.definition.lexicon ?? null,
128
- overview: s.definition.overview,
125
+ // Op resources
126
+ if (uri === "chant://ops") {
127
+ const { ops } = await discoverOps();
128
+ const list = Array.from(ops.values()).map(({ config }) => ({
129
+ name: config.name,
130
+ overview: config.overview,
131
+ phases: config.phases.length,
132
+ taskQueue: config.taskQueue ?? config.name,
133
+ depends: config.depends ?? [],
129
134
  }));
130
135
  return {
131
136
  contents: [{ uri, mimeType: "application/json", text: JSON.stringify(list, null, 2) }],
132
137
  };
133
138
  }
134
139
 
135
- if (uri.startsWith("chant://spell/") && uri.endsWith("/prompt")) {
136
- const name = uri.replace("chant://spell/", "").replace("/prompt", "");
137
- const { spells } = await discoverSpells();
138
- const spell = spells.get(name);
139
- if (!spell) throw new Error(`Spell "${name}" not found`);
140
- const rt = getRuntime();
141
- const gitRootResult = await rt.spawn(["git", "rev-parse", "--show-toplevel"]);
142
- const gitRoot = gitRootResult.stdout.trim();
143
- const prompt = await generatePrompt(spell.definition, { gitRoot });
144
- return {
145
- contents: [{ uri, mimeType: "text/markdown", text: prompt }],
146
- };
140
+ if (uri.startsWith("chant://ops/") && uri.endsWith("/runs/latest")) {
141
+ const name = uri.replace("chant://ops/", "").replace("/runs/latest", "");
142
+ try {
143
+ const { client } = await makeTemporalClient(undefined, resolve("."));
144
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
145
+ const desc = await handle.describe();
146
+ const history = await handle.fetchHistory();
147
+ const events = history.events ?? [];
148
+ const result = {
149
+ workflowId: desc.workflowId,
150
+ runId: desc.runId,
151
+ status: desc.status.name,
152
+ startTime: desc.startTime,
153
+ closeTime: desc.closeTime ?? null,
154
+ taskQueue: desc.taskQueue,
155
+ activitiesCompleted: events.filter((e) => e.eventType === "ActivityTaskCompleted").length,
156
+ activitiesScheduled: events.filter((e) => e.eventType === "ActivityTaskScheduled").length,
157
+ };
158
+ return {
159
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
160
+ };
161
+ } catch (err) {
162
+ return {
163
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }],
164
+ };
165
+ }
147
166
  }
148
167
 
149
- if (uri.startsWith("chant://spell/")) {
150
- const name = uri.replace("chant://spell/", "");
151
- const { spells } = await discoverSpells();
152
- const spell = spells.get(name);
153
- if (!spell) throw new Error(`Spell "${name}" not found`);
154
- return {
155
- contents: [{
156
- uri,
157
- mimeType: "application/json",
158
- text: JSON.stringify({
159
- ...spell.definition,
160
- status: spell.status,
161
- filePath: spell.filePath,
162
- }, null, 2),
163
- }],
164
- };
168
+ if (uri.startsWith("chant://ops/") && uri.endsWith("/runs")) {
169
+ const name = uri.replace("chant://ops/", "").replace("/runs", "");
170
+ try {
171
+ const { client } = await makeTemporalClient(undefined, resolve("."));
172
+ const runs: unknown[] = [];
173
+ for await (const run of client.workflow.list({ query: `WorkflowType = "${opWorkflowFnName(name)}"` })) {
174
+ runs.push({ runId: run.runId, status: run.status.name, startTime: run.startTime, closeTime: run.closeTime ?? null });
175
+ }
176
+ return {
177
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(runs, null, 2) }],
178
+ };
179
+ } catch (err) {
180
+ return {
181
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }],
182
+ };
183
+ }
165
184
  }
166
185
 
167
186
  // State resources: chant://state/{environment} and chant://state/{environment}/{lexicon}
@@ -49,6 +49,33 @@ chant import template.json # Convert external template
49
49
  chant import template.json -o ./src/ # Custom output dir
50
50
  \`\`\`
51
51
 
52
+ ## Ops (Temporal Workflows)
53
+
54
+ Ops are durable workflow definitions backed by Temporal. Each \`*.op.ts\` file declares an Op with named phases and activity steps.
55
+
56
+ ### Op MCP Tools
57
+
58
+ | Tool | Description |
59
+ |---|---|
60
+ | \`op-list\` | List all discovered Ops with current run status. Optional \`profile\` param. |
61
+ | \`op-run\` | Submit an Op workflow. Requires \`name\`. Worker must already be running via \`chant run <name>\`. |
62
+ | \`op-status\` | Get current run state (status, activity counts, times). Requires \`name\`. |
63
+ | \`op-signal\` | Send a signal to unblock a gate step. Requires \`name\` and \`signal\`. |
64
+ | \`op-report\` | Return a markdown deployment report for the latest run. Requires \`name\`. |
65
+
66
+ All Op tools accept an optional \`profile\` parameter matching a profile name in \`chant.config.ts\` \`temporal.profiles\`.
67
+
68
+ ### Op MCP Resources
69
+
70
+ | URI | Description |
71
+ |---|---|
72
+ | \`chant://ops\` | JSON array of all Op definitions (name, overview, phases, taskQueue, depends) |
73
+ | \`chant://ops/{name}/runs\` | Workflow run history for a named Op |
74
+ | \`chant://ops/{name}/runs/latest\` | Latest run state for a named Op |
75
+
76
+ ### Workflow IDs
77
+ Ops use deterministic workflow IDs: \`chant-op-<opName>\` (e.g. \`chant-op-alb-deploy\`).
78
+
52
79
  ## Best Practices
53
80
 
54
81
  1. **Flat Declarations**: Keep declarations at module level, avoid deep nesting