@bastani/atomic 0.5.34-0 → 0.6.0-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 (94) hide show
  1. package/README.md +329 -50
  2. package/dist/commands/cli/session.d.ts +67 -0
  3. package/dist/commands/cli/session.d.ts.map +1 -0
  4. package/dist/commands/cli/workflow-status.d.ts +63 -0
  5. package/dist/commands/cli/workflow-status.d.ts.map +1 -0
  6. package/dist/sdk/commander.d.ts +74 -0
  7. package/dist/sdk/commander.d.ts.map +1 -0
  8. package/dist/sdk/components/workflow-picker-panel.d.ts +14 -17
  9. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
  10. package/dist/sdk/define-workflow.d.ts +18 -9
  11. package/dist/sdk/define-workflow.d.ts.map +1 -1
  12. package/dist/sdk/index.d.ts +4 -3
  13. package/dist/sdk/index.d.ts.map +1 -1
  14. package/dist/sdk/management-commands.d.ts +42 -0
  15. package/dist/sdk/management-commands.d.ts.map +1 -0
  16. package/dist/sdk/registry.d.ts +27 -0
  17. package/dist/sdk/registry.d.ts.map +1 -0
  18. package/dist/sdk/runtime/attached-footer.d.ts +1 -1
  19. package/dist/sdk/runtime/executor-env.d.ts +20 -0
  20. package/dist/sdk/runtime/executor-env.d.ts.map +1 -0
  21. package/dist/sdk/runtime/executor.d.ts +61 -10
  22. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  23. package/dist/sdk/types.d.ts +147 -4
  24. package/dist/sdk/types.d.ts.map +1 -1
  25. package/dist/sdk/worker-shared.d.ts +42 -0
  26. package/dist/sdk/worker-shared.d.ts.map +1 -0
  27. package/dist/sdk/workflow-cli.d.ts +103 -0
  28. package/dist/sdk/workflow-cli.d.ts.map +1 -0
  29. package/dist/sdk/workflows/builtin-registry.d.ts +113 -0
  30. package/dist/sdk/workflows/builtin-registry.d.ts.map +1 -0
  31. package/dist/sdk/workflows/index.d.ts +5 -5
  32. package/dist/sdk/workflows/index.d.ts.map +1 -1
  33. package/package.json +12 -8
  34. package/src/cli.ts +85 -144
  35. package/src/commands/cli/chat/index.ts +10 -0
  36. package/src/commands/cli/workflow-command.test.ts +279 -938
  37. package/src/commands/cli/workflow-inputs.test.ts +41 -11
  38. package/src/commands/cli/workflow-inputs.ts +47 -12
  39. package/src/commands/cli/workflow-list.test.ts +234 -0
  40. package/src/commands/cli/workflow-list.ts +0 -0
  41. package/src/commands/cli/workflow.ts +11 -798
  42. package/src/scripts/constants.ts +2 -1
  43. package/src/sdk/commander.ts +161 -0
  44. package/src/sdk/components/workflow-picker-panel.tsx +78 -258
  45. package/src/sdk/define-workflow.test.ts +104 -11
  46. package/src/sdk/define-workflow.ts +47 -11
  47. package/src/sdk/errors.test.ts +16 -0
  48. package/src/sdk/index.ts +8 -8
  49. package/src/sdk/management-commands.ts +151 -0
  50. package/src/sdk/registry.ts +132 -0
  51. package/src/sdk/runtime/attached-footer.ts +1 -1
  52. package/src/sdk/runtime/executor-env.ts +45 -0
  53. package/src/sdk/runtime/executor.test.ts +37 -0
  54. package/src/sdk/runtime/executor.ts +147 -68
  55. package/src/sdk/types.ts +169 -4
  56. package/src/sdk/worker-shared.test.ts +163 -0
  57. package/src/sdk/worker-shared.ts +155 -0
  58. package/src/sdk/workflow-cli.ts +409 -0
  59. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -1
  60. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -1
  61. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +1 -1
  62. package/src/sdk/workflows/builtin/open-claude-design/claude/index.ts +1 -1
  63. package/src/sdk/workflows/builtin/open-claude-design/copilot/index.ts +1 -1
  64. package/src/sdk/workflows/builtin/open-claude-design/opencode/index.ts +1 -1
  65. package/src/sdk/workflows/builtin/ralph/claude/index.ts +1 -1
  66. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +1 -1
  67. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +1 -1
  68. package/src/sdk/workflows/builtin-registry.ts +23 -0
  69. package/src/sdk/workflows/index.ts +10 -20
  70. package/src/services/system/auth.test.ts +63 -1
  71. package/.agents/skills/workflow-creator/SKILL.md +0 -334
  72. package/.agents/skills/workflow-creator/references/agent-sessions.md +0 -888
  73. package/.agents/skills/workflow-creator/references/computation-and-validation.md +0 -201
  74. package/.agents/skills/workflow-creator/references/control-flow.md +0 -470
  75. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +0 -232
  76. package/.agents/skills/workflow-creator/references/failure-modes.md +0 -903
  77. package/.agents/skills/workflow-creator/references/getting-started.md +0 -275
  78. package/.agents/skills/workflow-creator/references/running-workflows.md +0 -235
  79. package/.agents/skills/workflow-creator/references/session-config.md +0 -384
  80. package/.agents/skills/workflow-creator/references/state-and-data-flow.md +0 -357
  81. package/.agents/skills/workflow-creator/references/user-input.md +0 -234
  82. package/.agents/skills/workflow-creator/references/workflow-inputs.md +0 -272
  83. package/dist/sdk/runtime/discovery.d.ts +0 -132
  84. package/dist/sdk/runtime/discovery.d.ts.map +0 -1
  85. package/dist/sdk/runtime/executor-entry.d.ts +0 -11
  86. package/dist/sdk/runtime/executor-entry.d.ts.map +0 -1
  87. package/dist/sdk/runtime/loader.d.ts +0 -70
  88. package/dist/sdk/runtime/loader.d.ts.map +0 -1
  89. package/dist/version.d.ts +0 -2
  90. package/dist/version.d.ts.map +0 -1
  91. package/src/commands/cli/workflow.test.ts +0 -317
  92. package/src/sdk/runtime/discovery.ts +0 -368
  93. package/src/sdk/runtime/executor-entry.ts +0 -18
  94. package/src/sdk/runtime/loader.ts +0 -267
@@ -1,317 +0,0 @@
1
- import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
- import {
3
- parsePassthroughArgs,
4
- validateInputsAgainstSchema,
5
- resolveInputs,
6
- renderWorkflowList,
7
- } from "./workflow.ts";
8
- import type { WorkflowInput } from "../../sdk/workflows/index.ts";
9
- import type {
10
- DiscoveredWorkflow,
11
- WorkflowWithMetadata,
12
- } from "../../sdk/workflows/index.ts";
13
-
14
- // ─── Colour handling ────────────────────────────────────────────────────────
15
- // The renderer emits ANSI sequences when the host terminal claims truecolor
16
- // support. Force `NO_COLOR=1` for this file so renderWorkflowList assertions
17
- // can match on plain-text output rather than brittle SGR escapes. Restore the
18
- // prior value on teardown so other suites in the same run are unaffected.
19
- let originalNoColor: string | undefined;
20
- beforeAll(() => {
21
- originalNoColor = process.env.NO_COLOR;
22
- process.env.NO_COLOR = "1";
23
- });
24
- afterAll(() => {
25
- if (originalNoColor === undefined) delete process.env.NO_COLOR;
26
- else process.env.NO_COLOR = originalNoColor;
27
- });
28
-
29
- // ─── parsePassthroughArgs ──────────────────────────────────────────────────
30
-
31
- describe("parsePassthroughArgs", () => {
32
- test("parses --name=value flag pairs", () => {
33
- const out = parsePassthroughArgs(["--foo=bar", "--baz=qux"]);
34
- expect(out.flags).toEqual({ foo: "bar", baz: "qux" });
35
- expect(out.positional).toEqual([]);
36
- expect(out.errors).toEqual([]);
37
- });
38
-
39
- test("parses --name value flag pairs", () => {
40
- const out = parsePassthroughArgs(["--foo", "bar", "--baz", "qux"]);
41
- expect(out.flags).toEqual({ foo: "bar", baz: "qux" });
42
- expect(out.errors).toEqual([]);
43
- });
44
-
45
- test("preserves values containing equals signs", () => {
46
- const out = parsePassthroughArgs(["--query=key=value"]);
47
- expect(out.flags).toEqual({ query: "key=value" });
48
- });
49
-
50
- test("collects positional tokens separately", () => {
51
- const out = parsePassthroughArgs(["fix", "the", "bug"]);
52
- expect(out.positional).toEqual(["fix", "the", "bug"]);
53
- expect(out.flags).toEqual({});
54
- });
55
-
56
- test("returns an error when a flag has no value", () => {
57
- const out = parsePassthroughArgs(["--foo"]);
58
- expect(out.errors.length).toBe(1);
59
- expect(out.errors[0]).toContain("--foo");
60
- });
61
-
62
- test("flags an empty flag name when `--=value` slips through", () => {
63
- // `--=foo` tokenises to body="=foo", splits at the first `=`,
64
- // and yields an empty name. The parser rejects this so users see
65
- // "did you mean --name=foo?" rather than silently accepting a
66
- // nameless entry in the flags record.
67
- const out = parsePassthroughArgs(["--=foo"]);
68
- expect(out.errors.length).toBe(1);
69
- expect(out.errors[0]).toContain("Malformed flag");
70
- expect(out.flags).toEqual({});
71
- });
72
-
73
- test("treats a trailing --flag --other as a missing value", () => {
74
- // Second `--other` looks like a flag so the parser should not
75
- // silently consume it as the first flag's value.
76
- const out = parsePassthroughArgs(["--foo", "--other=x"]);
77
- expect(out.errors.length).toBe(1);
78
- expect(out.flags).toEqual({ other: "x" });
79
- });
80
-
81
- test("handles mixed positional and flag tokens", () => {
82
- const out = parsePassthroughArgs([
83
- "run",
84
- "--mode=fast",
85
- "now",
86
- "--retries",
87
- "3",
88
- ]);
89
- expect(out.positional).toEqual(["run", "now"]);
90
- expect(out.flags).toEqual({ mode: "fast", retries: "3" });
91
- expect(out.errors).toEqual([]);
92
- });
93
- });
94
-
95
- // ─── validateInputsAgainstSchema ───────────────────────────────────────────
96
-
97
- const schema: WorkflowInput[] = [
98
- {
99
- name: "research_doc",
100
- type: "string",
101
- required: true,
102
- description: "path",
103
- },
104
- {
105
- name: "focus",
106
- type: "enum",
107
- required: true,
108
- values: ["minimal", "standard", "exhaustive"],
109
- default: "standard",
110
- },
111
- {
112
- name: "notes",
113
- type: "text",
114
- },
115
- ];
116
-
117
- describe("validateInputsAgainstSchema", () => {
118
- test("passes when all required fields are present", () => {
119
- const errors = validateInputsAgainstSchema(
120
- { research_doc: "notes.md", focus: "standard" },
121
- schema,
122
- );
123
- expect(errors).toEqual([]);
124
- });
125
-
126
- test("accepts an omitted enum with a declared default", () => {
127
- // `focus` has a default of "standard", so omission should be OK.
128
- const errors = validateInputsAgainstSchema(
129
- { research_doc: "notes.md" },
130
- schema,
131
- );
132
- expect(errors).toEqual([]);
133
- });
134
-
135
- test("flags a missing required string input", () => {
136
- const errors = validateInputsAgainstSchema(
137
- { focus: "standard" },
138
- schema,
139
- );
140
- expect(errors.length).toBe(1);
141
- expect(errors[0]).toContain("--research_doc");
142
- });
143
-
144
- test("rejects required strings that are whitespace only", () => {
145
- const errors = validateInputsAgainstSchema(
146
- { research_doc: " ", focus: "standard" },
147
- schema,
148
- );
149
- expect(errors[0]).toContain("--research_doc");
150
- });
151
-
152
- test("rejects enum values that are not in the allowed list", () => {
153
- const errors = validateInputsAgainstSchema(
154
- { research_doc: "notes.md", focus: "bogus" },
155
- schema,
156
- );
157
- expect(errors.length).toBe(1);
158
- expect(errors[0]).toContain("bogus");
159
- expect(errors[0]).toContain("minimal");
160
- });
161
-
162
- test("flags unknown inputs", () => {
163
- const errors = validateInputsAgainstSchema(
164
- {
165
- research_doc: "notes.md",
166
- focus: "standard",
167
- bogus: "hello",
168
- },
169
- schema,
170
- );
171
- expect(errors.length).toBe(1);
172
- expect(errors[0]).toContain("--bogus");
173
- });
174
-
175
- test("flags a required enum that resolves to empty when no values are declared", () => {
176
- // Pathological but reachable: a declared enum with an empty
177
- // `values` array and no default falls through to value==="" in the
178
- // resolver, which should trip the enum-specific required-input
179
- // error branch rather than the generic string-required branch.
180
- const brokenSchema: WorkflowInput[] = [
181
- { name: "mode", type: "enum", required: true, values: [] },
182
- ];
183
- const errors = validateInputsAgainstSchema({}, brokenSchema);
184
- expect(errors.length).toBe(1);
185
- expect(errors[0]).toContain("--mode");
186
- expect(errors[0]).toContain("expected one of:");
187
- });
188
- });
189
-
190
- // ─── renderWorkflowList ────────────────────────────────────────────────────
191
-
192
- function wf(
193
- name: string,
194
- agent: DiscoveredWorkflow["agent"],
195
- source: DiscoveredWorkflow["source"],
196
- status: WorkflowWithMetadata["status"] = { kind: "ok" },
197
- ): WorkflowWithMetadata {
198
- return {
199
- name,
200
- agent,
201
- source,
202
- path: `/tmp/fake/${source}/${name}/${agent}/index.ts`,
203
- description: "",
204
- inputs: [],
205
- status,
206
- };
207
- }
208
-
209
- describe("renderWorkflowList", () => {
210
- test("renders an empty-state stanza when no workflows are available", () => {
211
- const out = renderWorkflowList([]);
212
- expect(out).toContain("no workflows found");
213
- // Teaches the user where to drop a new workflow.
214
- expect(out).toContain(".atomic/workflows/<name>/<agent>/index.ts");
215
- });
216
-
217
- test("uses singular 'workflow' for a count of exactly one", () => {
218
- const out = renderWorkflowList([wf("only", "claude", "local")]);
219
- // Must say "1 workflow", never "1 workflows".
220
- expect(out).toMatch(/\b1 workflow\b/);
221
- expect(out).not.toMatch(/\b1 workflows\b/);
222
- expect(out).toContain("only");
223
- });
224
-
225
- test("groups entries by source → provider and sorts names", () => {
226
- const workflows: WorkflowWithMetadata[] = [
227
- wf("zebra", "claude", "local"),
228
- wf("apple", "claude", "local"),
229
- wf("middle", "opencode", "local"),
230
- wf("personal", "claude", "global"),
231
- wf("shipped", "copilot", "builtin"),
232
- ];
233
- const out = renderWorkflowList(workflows);
234
-
235
- // Count uses plural noun.
236
- expect(out).toMatch(/\b5 workflows\b/);
237
-
238
- // Section headings appear with friendly directory hints.
239
- expect(out).toContain("local");
240
- expect(out).toContain(".atomic/workflows");
241
- expect(out).toContain("global");
242
- expect(out).toContain("~/.atomic/workflows");
243
- expect(out).toContain("builtin");
244
- expect(out).toContain("built-in");
245
-
246
- // Provider sub-headings are present with branded names.
247
- expect(out).toContain("Claude");
248
- expect(out).toContain("OpenCode");
249
- expect(out).toContain("Copilot CLI");
250
-
251
- // Run-hint footer is appended.
252
- expect(out).toContain("run: atomic workflow -n <name> -a <agent>");
253
-
254
- // Local/Claude names are sorted alphabetically: apple before zebra.
255
- const appleIdx = out.indexOf("apple");
256
- const zebraIdx = out.indexOf("zebra");
257
- expect(appleIdx).toBeGreaterThanOrEqual(0);
258
- expect(zebraIdx).toBeGreaterThan(appleIdx);
259
-
260
- // Source ordering: local before global before builtin.
261
- const localIdx = out.indexOf("local");
262
- const globalIdx = out.indexOf("global");
263
- const builtinIdx = out.indexOf("builtin");
264
- expect(localIdx).toBeLessThan(globalIdx);
265
- expect(globalIdx).toBeLessThan(builtinIdx);
266
- });
267
-
268
- test("omits provider sub-groups that have no entries for a given source", () => {
269
- // Only a copilot workflow exists, so the local stanza should render a
270
- // single "Copilot CLI" sub-heading — never "Claude" or "OpenCode".
271
- const out = renderWorkflowList([wf("alone", "copilot", "local")]);
272
- expect(out).toContain("Copilot CLI");
273
- expect(out).not.toContain("Claude");
274
- expect(out).not.toContain("OpenCode");
275
- expect(out).toContain("alone");
276
- });
277
- });
278
-
279
- // ─── resolveInputs ─────────────────────────────────────────────────────────
280
-
281
- describe("resolveInputs", () => {
282
- test("fills in declared defaults", () => {
283
- const out = resolveInputs({ research_doc: "notes.md" }, schema);
284
- expect(out.research_doc).toBe("notes.md");
285
- expect(out.focus).toBe("standard");
286
- // `notes` has no default — should be absent, not empty-string.
287
- expect(out.notes).toBeUndefined();
288
- });
289
-
290
- test("prefers provided values over defaults", () => {
291
- const out = resolveInputs(
292
- { research_doc: "notes.md", focus: "exhaustive" },
293
- schema,
294
- );
295
- expect(out.focus).toBe("exhaustive");
296
- });
297
-
298
- test("falls back to the first enum value when no default and no input", () => {
299
- const enumOnly: WorkflowInput[] = [
300
- {
301
- name: "mode",
302
- type: "enum",
303
- required: true,
304
- values: ["a", "b", "c"],
305
- },
306
- ];
307
- expect(resolveInputs({}, enumOnly)).toEqual({ mode: "a" });
308
- });
309
-
310
- test("ignores unknown provided keys", () => {
311
- const out = resolveInputs(
312
- { research_doc: "notes.md", bogus: "hello" },
313
- schema,
314
- );
315
- expect(out.bogus).toBeUndefined();
316
- });
317
- });
@@ -1,368 +0,0 @@
1
- /**
2
- * Workflow discovery — finds workflow definitions from disk.
3
- *
4
- * Workflows are discovered from:
5
- * 1. src/sdk/workflows/builtin/<name>/<agent>/index.ts (SDK-shipped builtins)
6
- * 2. .atomic/workflows/<name>/<agent>/index.ts (project-local)
7
- * 3. ~/.atomic/workflows/<name>/<agent>/index.ts (global)
8
- *
9
- * All three sources use the same scanning function (`discoverFromBaseDir`)
10
- * so registration is uniform. Builtin names are reserved — project-local
11
- * and global workflows with the same name are dropped during merge.
12
- */
13
-
14
- import { join } from "node:path";
15
- import { readdir } from "node:fs/promises";
16
- import { homedir } from "node:os";
17
- import ignore from "ignore";
18
- import type { AgentType, WorkflowInput } from "../types.ts";
19
- import { WorkflowLoader } from "./loader.ts";
20
- import { IncompatibleSDKError } from "../errors.ts";
21
-
22
- export interface DiscoveredWorkflow {
23
- name: string;
24
- agent: AgentType;
25
- path: string;
26
- source: "local" | "global" | "builtin";
27
- }
28
-
29
- function getLocalWorkflowsDir(projectRoot: string): string {
30
- return join(projectRoot, ".atomic", "workflows");
31
- }
32
-
33
- function getGlobalWorkflowsDir(): string {
34
- return join(homedir(), ".atomic", "workflows");
35
- }
36
-
37
- export const AGENTS: AgentType[] = ["copilot", "opencode", "claude"];
38
- const AGENT_SET = new Set<string>(AGENTS);
39
-
40
- /**
41
- * Default `.gitignore` content for a workflows directory.
42
- * Auto-generated during install; regenerated by discovery if missing.
43
- */
44
- export const WORKFLOWS_GITIGNORE = [
45
- "node_modules/",
46
- "dist/",
47
- "build/",
48
- "coverage/",
49
- ".cache/",
50
- "*.log",
51
- "*.tsbuildinfo",
52
- "",
53
- ].join("\n");
54
-
55
- /**
56
- * Load the `.gitignore` from a workflows directory, regenerating it if absent.
57
- * The workflows `.gitignore` is always auto-generated so a missing file
58
- * indicates an incomplete setup rather than an intentional absence.
59
- */
60
- async function loadWorkflowsGitignore(workflowsDir: string): Promise<ignore.Ignore> {
61
- const gitignorePath = join(workflowsDir, ".gitignore");
62
- let content: string;
63
- try {
64
- content = await Bun.file(gitignorePath).text();
65
- } catch {
66
- // Missing — regenerate from the canonical template
67
- await Bun.write(gitignorePath, WORKFLOWS_GITIGNORE);
68
- content = WORKFLOWS_GITIGNORE;
69
- }
70
- return ignore().add(content);
71
- }
72
-
73
- /**
74
- * Discover workflows from a base directory by scanning workflow-name
75
- * directories first, then agent subdirectories within each.
76
- *
77
- * Layout: baseDir/<workflow_name>/<agent>/index.ts
78
- *
79
- * Entries are filtered against the `.gitignore` that lives inside the
80
- * workflows directory itself (auto-generated, regenerated if missing).
81
- */
82
- async function discoverFromBaseDir(
83
- baseDir: string,
84
- source: "local" | "global" | "builtin",
85
- agentFilter?: AgentType,
86
- ): Promise<DiscoveredWorkflow[]> {
87
- const workflows: DiscoveredWorkflow[] = [];
88
- const agents = agentFilter ? [agentFilter] : AGENTS;
89
- const agentNames = new Set<string>(agents);
90
-
91
- let workflowEntries;
92
- try {
93
- workflowEntries = await readdir(baseDir, { withFileTypes: true });
94
- } catch {
95
- return workflows;
96
- }
97
-
98
- // Builtin workflows live inside the SDK source tree — skip .gitignore
99
- // handling to avoid writing files into node_modules or the SDK dir.
100
- const ig = source === "builtin"
101
- ? ignore()
102
- : await loadWorkflowsGitignore(baseDir);
103
-
104
- for (const wfEntry of workflowEntries) {
105
- if (!wfEntry.isDirectory()) continue;
106
- if (wfEntry.name.startsWith(".")) continue;
107
- // Skip agent-named directories at root (they are not workflow names)
108
- if (AGENT_SET.has(wfEntry.name)) continue;
109
- // Skip directories matched by the workflows .gitignore.
110
- // Append "/" so directory-only patterns (e.g. "build/") match correctly.
111
- if (ig.ignores(wfEntry.name + "/")) continue;
112
-
113
- const workflowDir = join(baseDir, wfEntry.name);
114
-
115
- let agentEntries;
116
- try {
117
- agentEntries = await readdir(workflowDir, { withFileTypes: true });
118
- } catch {
119
- continue;
120
- }
121
-
122
- for (const agentEntry of agentEntries) {
123
- if (!agentEntry.isDirectory()) continue;
124
- if (!agentNames.has(agentEntry.name)) continue;
125
-
126
- const indexPath = join(workflowDir, agentEntry.name, "index.ts");
127
- const file = Bun.file(indexPath);
128
- if (await file.exists()) {
129
- workflows.push({
130
- name: wfEntry.name,
131
- agent: agentEntry.name as AgentType,
132
- path: indexPath,
133
- source,
134
- });
135
- }
136
- }
137
- }
138
-
139
- return workflows;
140
- }
141
-
142
- /**
143
- * Absolute path to the `builtin/` directory inside the SDK source tree.
144
- * Computed from this file's URL so it always resolves correctly regardless
145
- * of how the package was installed (dev checkout, global, bunx, etc.).
146
- */
147
- const BUILTIN_WORKFLOWS_DIR = join(
148
- Bun.fileURLToPath(new URL("../workflows/builtin", import.meta.url)),
149
- );
150
-
151
- /**
152
- * Discover all available workflows from built-in, global, and local sources.
153
- * Optionally filter by agent.
154
- *
155
- * **Merge precedence:** `builtin > local > global`.
156
- *
157
- * Builtin names are **strictly reserved** — a user-defined local or
158
- * global workflow whose name matches any built-in workflow is dropped
159
- * entirely from discovery. It will not be registered, returned from
160
- * `findWorkflow`, appear in the interactive picker, or show up in
161
- * `atomic workflow -l`. This protects SDK-shipped workflows (e.g.
162
- * `ralph`) from being silently overridden or even visibly "competing
163
- * with" a user's own definition, which would otherwise be confusing
164
- * when someone tries to run the canonical version.
165
- *
166
- * Reservation is by **name only**, across all agents: if a builtin
167
- * defines `ralph` for any agent, a local `ralph` for any other agent is
168
- * also dropped. Local still overrides global for every non-builtin
169
- * name, so project-scoped customisation of user-scoped workflows
170
- * continues to work.
171
- *
172
- * By default, the result is **merged by precedence** — if a workflow is
173
- * defined in both local and global sources, only the higher-precedence
174
- * entry is returned. This is the right shape for `findWorkflow`, which
175
- * needs the single resolved entry per (name, agent) pair.
176
- *
177
- * Pass `{ merge: false }` to get the **unmerged** result — local and
178
- * global contribute their entries independently, so `--list` can show
179
- * both a local and a global copy of the same workflow when they coexist
180
- * on disk. (Builtin reservation still applies in both modes.)
181
- */
182
- export async function discoverWorkflows(
183
- projectRoot: string = process.cwd(),
184
- agentFilter?: AgentType,
185
- options: { merge?: boolean } = {},
186
- ): Promise<DiscoveredWorkflow[]> {
187
- const { merge = true } = options;
188
-
189
- const localDir = getLocalWorkflowsDir(projectRoot);
190
- const globalDir = getGlobalWorkflowsDir();
191
-
192
- // Collect ALL builtin names (ignoring agentFilter) so reservation is
193
- // name-based across every agent: a local `ralph` for copilot is still
194
- // reserved by a builtin `ralph` for claude, even when the discovery
195
- // call was filtered to copilot.
196
- const [allBuiltins, globalResults, localResults] = await Promise.all([
197
- discoverFromBaseDir(BUILTIN_WORKFLOWS_DIR, "builtin"),
198
- discoverFromBaseDir(globalDir, "global", agentFilter),
199
- discoverFromBaseDir(localDir, "local", agentFilter),
200
- ]);
201
- const reservedNames = new Set<string>(allBuiltins.map((w) => w.name));
202
- const builtinResults = agentFilter
203
- ? allBuiltins.filter((w) => w.agent === agentFilter)
204
- : allBuiltins;
205
-
206
- // Drop any local/global workflow whose name matches a reserved
207
- // builtin. This happens BEFORE both merge and unmerged code paths so
208
- // reserved names never leak into `findWorkflow`, the picker, or
209
- // `--list` — there is exactly one canonical entry per reserved name,
210
- // the SDK-shipped one.
211
- const filteredGlobal = globalResults.filter((w) => !reservedNames.has(w.name));
212
- const filteredLocal = localResults.filter((w) => !reservedNames.has(w.name));
213
-
214
- if (!merge) {
215
- // Unmerged: keep local and global independent so `--list` can show
216
- // both copies of a non-reserved name when they coexist. Order lowest
217
- // → highest precedence so callers that want the winning entry can
218
- // take the last one by (agent, name).
219
- return [...filteredGlobal, ...filteredLocal, ...builtinResults];
220
- }
221
-
222
- // Merge with precedence: global (lowest) → local → builtin (highest).
223
- // Builtin is layered last as a belt-and-braces guarantee — though
224
- // reserved-name filtering above already makes this overwrite
225
- // impossible in practice.
226
- const byKey = new Map<string, DiscoveredWorkflow>();
227
- for (const wf of filteredGlobal) {
228
- byKey.set(`${wf.agent}/${wf.name}`, wf);
229
- }
230
- for (const wf of filteredLocal) {
231
- byKey.set(`${wf.agent}/${wf.name}`, wf);
232
- }
233
- for (const wf of builtinResults) {
234
- byKey.set(`${wf.agent}/${wf.name}`, wf);
235
- }
236
-
237
- return Array.from(byKey.values());
238
- }
239
-
240
- /**
241
- * Find a specific workflow by name and agent.
242
- */
243
- export async function findWorkflow(
244
- name: string,
245
- agent: AgentType,
246
- projectRoot: string = process.cwd()
247
- ): Promise<DiscoveredWorkflow | null> {
248
- const all = await discoverWorkflows(projectRoot, agent);
249
- return all.find((w) => w.name === name) ?? null;
250
- }
251
-
252
- /**
253
- * Load status for a {@link WorkflowWithMetadata} entry.
254
- *
255
- * - `ok` — the workflow compiled cleanly and is ready to run.
256
- * - `incompatible` — the workflow declared a `minSDKVersion` newer
257
- * than the bundled CLI. The CLI renders it with an
258
- * "update required" badge so users see the mismatch
259
- * rather than a silent disappearance.
260
- * - `error` — any other load failure (syntax error, missing
261
- * `.compile()`, invalid default export, etc.).
262
- * Rendered with a "failed to load" badge plus the
263
- * underlying message.
264
- */
265
- export type WorkflowMetadataStatus =
266
- | { kind: "ok" }
267
- | {
268
- kind: "incompatible";
269
- requiredVersion: string;
270
- currentVersion: string;
271
- message: string;
272
- }
273
- | {
274
- kind: "error";
275
- stage: "resolve" | "validate" | "load";
276
- message: string;
277
- };
278
-
279
- /**
280
- * A discovered workflow enriched with the metadata the picker needs to
281
- * render it: the human description, the declared input schema, and the
282
- * load status.
283
- *
284
- * Populated by {@link loadWorkflowsMetadata}, which runs each discovered
285
- * workflow through {@link WorkflowLoader.loadWorkflow} and extracts the
286
- * display-relevant fields — the full compiled definition is discarded
287
- * after extraction so re-imports during execution are cheap.
288
- *
289
- * Broken entries still materialise with empty `description` / `inputs`
290
- * and a non-`ok` {@link status}, so the picker can render them as
291
- * visible "update required" / "failed to load" rows instead of
292
- * silently omitting them (the previous behaviour, which made user and
293
- * global workflows vanish whenever the base SDK shape drifted between
294
- * releases).
295
- */
296
- export interface WorkflowWithMetadata extends DiscoveredWorkflow {
297
- /** Workflow description, empty string when none was declared or the workflow failed to load. */
298
- description: string;
299
- /** Picker-ready input schema; empty for free-form or failed-to-load workflows. */
300
- inputs: readonly WorkflowInput[];
301
- /** Load outcome — non-`ok` entries are rendered as visible diagnostics in the picker/list. */
302
- status: WorkflowMetadataStatus;
303
- }
304
-
305
- /**
306
- * Load metadata (description + picker-ready inputs + status) for a batch
307
- * of discovered workflows.
308
- *
309
- * **Failed workflows are kept in the returned list**, not dropped. Each
310
- * broken entry carries a {@link WorkflowMetadataStatus} explaining the
311
- * failure so the picker and `atomic workflow -l` can surface it as an
312
- * actionable diagnostic. This is the only way end users discover that a
313
- * workflow from an older SDK release has gone incompatible after an
314
- * `atomic` upgrade — silent filtering would leave them with a missing
315
- * entry and no breadcrumb.
316
- *
317
- * Callers that want to execute a workflow should still route through
318
- * {@link WorkflowLoader.loadWorkflow} — this function throws away the
319
- * compiled definition so re-running the loader on a confirmed pick is
320
- * unavoidable.
321
- */
322
- export async function loadWorkflowsMetadata(
323
- discovered: DiscoveredWorkflow[],
324
- ): Promise<WorkflowWithMetadata[]> {
325
- return Promise.all(
326
- discovered.map(async (wf): Promise<WorkflowWithMetadata> => {
327
- const loaded = await WorkflowLoader.loadWorkflow(wf);
328
- if (loaded.ok) {
329
- return {
330
- ...wf,
331
- description: loaded.value.definition.description,
332
- inputs: loaded.value.definition.inputs,
333
- status: { kind: "ok" },
334
- };
335
- }
336
-
337
- // Incompatible SDK version is a first-class status so the UI can
338
- // show a dedicated "update required" hint. Every other failure
339
- // maps to a generic `error` variant — the picker renders the
340
- // message but doesn't try to interpret it further.
341
- if (loaded.error instanceof IncompatibleSDKError) {
342
- return {
343
- ...wf,
344
- description: "",
345
- inputs: [],
346
- status: {
347
- kind: "incompatible",
348
- requiredVersion: loaded.error.requiredVersion,
349
- currentVersion: loaded.error.currentVersion,
350
- message: loaded.message,
351
- },
352
- };
353
- }
354
-
355
- return {
356
- ...wf,
357
- description: "",
358
- inputs: [],
359
- status: {
360
- kind: "error",
361
- stage: loaded.stage,
362
- message: loaded.message,
363
- },
364
- };
365
- }),
366
- );
367
- }
368
-