@davidorex/pi-workflows 0.1.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 (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +169 -0
  3. package/agents/audit-fixer.agent.yaml +18 -0
  4. package/agents/code-explorer.agent.yaml +11 -0
  5. package/agents/decomposer.agent.yaml +51 -0
  6. package/agents/investigator.agent.yaml +49 -0
  7. package/agents/pattern-analyzer.agent.yaml +21 -0
  8. package/agents/phase-author.agent.yaml +16 -0
  9. package/agents/plan-decomposer.agent.yaml +12 -0
  10. package/agents/quality-analyzer.agent.yaml +21 -0
  11. package/agents/researcher.agent.yaml +38 -0
  12. package/agents/spec-implementer.agent.yaml +12 -0
  13. package/agents/structure-analyzer.agent.yaml +21 -0
  14. package/agents/synthesizer.agent.yaml +23 -0
  15. package/agents/verifier.agent.yaml +12 -0
  16. package/package.json +40 -0
  17. package/schemas/decomposition-specs.schema.json +50 -0
  18. package/schemas/execution-results.schema.json +50 -0
  19. package/schemas/investigation-findings.schema.json +45 -0
  20. package/schemas/pattern-analysis.schema.json +43 -0
  21. package/schemas/plan-breakdown.schema.json +22 -0
  22. package/schemas/quality-analysis.schema.json +35 -0
  23. package/schemas/research-findings.schema.json +44 -0
  24. package/schemas/structure-analysis.schema.json +42 -0
  25. package/schemas/synthesis.schema.json +33 -0
  26. package/schemas/verifier-output.schema.json +96 -0
  27. package/src/agent-spec.test.ts +289 -0
  28. package/src/agent-spec.ts +122 -0
  29. package/src/block-validation.test.ts +350 -0
  30. package/src/checkpoint.test.ts +237 -0
  31. package/src/checkpoint.ts +139 -0
  32. package/src/completion.test.ts +143 -0
  33. package/src/completion.ts +68 -0
  34. package/src/dag.test.ts +350 -0
  35. package/src/dag.ts +219 -0
  36. package/src/dispatch.test.ts +473 -0
  37. package/src/dispatch.ts +353 -0
  38. package/src/expression.test.ts +353 -0
  39. package/src/expression.ts +332 -0
  40. package/src/format.test.ts +80 -0
  41. package/src/format.ts +41 -0
  42. package/src/graduated-failure.test.ts +556 -0
  43. package/src/index.test.ts +114 -0
  44. package/src/index.ts +488 -0
  45. package/src/loop.test.ts +549 -0
  46. package/src/output.test.ts +213 -0
  47. package/src/output.ts +70 -0
  48. package/src/parallel-integration.test.ts +175 -0
  49. package/src/resume.test.ts +192 -0
  50. package/src/state.test.ts +192 -0
  51. package/src/state.ts +253 -0
  52. package/src/step-agent.test.ts +327 -0
  53. package/src/step-agent.ts +178 -0
  54. package/src/step-command.test.ts +330 -0
  55. package/src/step-command.ts +132 -0
  56. package/src/step-foreach.test.ts +647 -0
  57. package/src/step-foreach.ts +148 -0
  58. package/src/step-gate.test.ts +185 -0
  59. package/src/step-gate.ts +116 -0
  60. package/src/step-loop.test.ts +626 -0
  61. package/src/step-loop.ts +323 -0
  62. package/src/step-parallel.test.ts +475 -0
  63. package/src/step-parallel.ts +168 -0
  64. package/src/step-pause.test.ts +30 -0
  65. package/src/step-pause.ts +27 -0
  66. package/src/step-shared.test.ts +355 -0
  67. package/src/step-shared.ts +157 -0
  68. package/src/step-transform.test.ts +155 -0
  69. package/src/step-transform.ts +47 -0
  70. package/src/template-integration.test.ts +72 -0
  71. package/src/template.test.ts +162 -0
  72. package/src/template.ts +92 -0
  73. package/src/test-helpers.ts +51 -0
  74. package/src/tui.test.ts +355 -0
  75. package/src/tui.ts +182 -0
  76. package/src/types.ts +195 -0
  77. package/src/verifier-schema.test.ts +226 -0
  78. package/src/workflow-discovery.test.ts +105 -0
  79. package/src/workflow-discovery.ts +113 -0
  80. package/src/workflow-executor.test.ts +1530 -0
  81. package/src/workflow-executor.ts +810 -0
  82. package/src/workflow-sdk.test.ts +238 -0
  83. package/src/workflow-sdk.ts +262 -0
  84. package/src/workflow-spec.test.ts +576 -0
  85. package/src/workflow-spec.ts +471 -0
  86. package/templates/analyzers/base-analyzer.md +9 -0
  87. package/templates/analyzers/macros.md +1 -0
  88. package/templates/analyzers/patterns-task.md +11 -0
  89. package/templates/analyzers/patterns.md +15 -0
  90. package/templates/analyzers/quality-task.md +11 -0
  91. package/templates/analyzers/quality.md +15 -0
  92. package/templates/analyzers/structure-task.md +17 -0
  93. package/templates/analyzers/structure.md +15 -0
  94. package/templates/audit-fixer/task.md +67 -0
  95. package/templates/decomposer/task.md +56 -0
  96. package/templates/explorer/system.md +3 -0
  97. package/templates/explorer/task.md +9 -0
  98. package/templates/investigator/task.md +30 -0
  99. package/templates/phase-author/task.md +74 -0
  100. package/templates/plan-decomposer/task.md +46 -0
  101. package/templates/researcher/task.md +26 -0
  102. package/templates/spec-implementer/task.md +53 -0
  103. package/templates/synthesizer/system.md +3 -0
  104. package/templates/synthesizer/task.md +38 -0
  105. package/templates/verifier/task.md +57 -0
  106. package/workflows/create-phase.workflow.yaml +67 -0
  107. package/workflows/do-gap.workflow.yaml +153 -0
  108. package/workflows/fix-audit.workflow.yaml +217 -0
  109. package/workflows/gap-to-phase.workflow.yaml +98 -0
  110. package/workflows/parallel-analysis.workflow.yaml +62 -0
  111. package/workflows/parallel-explicit.workflow.yaml +42 -0
  112. package/workflows/pausable-analysis.workflow.yaml +44 -0
  113. package/workflows/resumable-analysis.workflow.yaml +42 -0
  114. package/workflows/self-implement.workflow.yaml +63 -0
  115. package/workflows/typed-analysis.workflow.yaml +63 -0
package/src/types.ts ADDED
@@ -0,0 +1,195 @@
1
+ // ── Workflow Spec (parsed from YAML) ──
2
+
3
+ export interface WorkflowSpec {
4
+ name: string;
5
+ description: string;
6
+ version?: string;
7
+ input?: Record<string, unknown>; // JSON Schema object
8
+ output?: Record<string, unknown>; // JSON Schema object
9
+ triggerTurn?: boolean; // default: true
10
+ completion?: CompletionSpec; // controls post-completion message to main LLM
11
+ steps: Record<string, StepSpec>; // ordered (YAML preserves insertion order)
12
+ artifacts?: Record<string, ArtifactSpec>; // persistent files written after workflow completion
13
+ // Set by discovery, not by YAML:
14
+ source: "user" | "project";
15
+ filePath: string;
16
+ }
17
+
18
+ export interface ArtifactSpec {
19
+ path: string; // destination path, may contain ${{ }} expressions
20
+ from: string; // ${{ }} expression for the data source
21
+ schema?: string; // optional JSON Schema to validate before writing
22
+ }
23
+
24
+ export interface RetryConfig {
25
+ maxAttempts?: number; // default 1 (no retry). Total attempts, not retries.
26
+ onExhausted?: 'fail' | 'skip'; // default 'fail'
27
+ steeringMessage?: string; // optional custom steering text for retries (agent steps only)
28
+ }
29
+
30
+ export interface StepSpec {
31
+ agent?: string; // optional — not needed for gate/transform steps
32
+ model?: string; // model override
33
+ input?: Record<string, unknown>; // values may contain ${{ }} expressions
34
+ output?: StepOutputSpec;
35
+ when?: string; // ${{ }} expression, evaluated as truthy/falsy
36
+ timeout?: { seconds: number };
37
+ retry?: RetryConfig; // per-step retry config
38
+ loop?: LoopSpec;
39
+ gate?: GateSpec;
40
+ transform?: TransformSpec;
41
+ parallel?: Record<string, StepSpec>; // named sub-steps to run concurrently
42
+ pause?: string | boolean; // pause step — string is message to display, true = pause with no message
43
+ command?: string; // shell command to run (captures stdout as output)
44
+ forEach?: string; // ${{ }} expression resolving to an array
45
+ as?: string; // variable name to bind each element (default: "item")
46
+ workflow?: string; // phase 6 — not yet supported
47
+ }
48
+ // Note: exactly one of agent, gate, transform, loop, or parallel must be set.
49
+ // workflow is phase 6.
50
+
51
+ export interface LoopSpec {
52
+ maxAttempts: number; // max iterations (required)
53
+ attempts?: string; // ${{ }} expression overriding maxAttempts at runtime
54
+ steps: Record<string, StepSpec>; // steps to execute each iteration
55
+ onExhausted?: StepSpec; // step to run if all attempts fail
56
+ }
57
+
58
+ export interface GateSpec {
59
+ check: string; // shell command to run
60
+ onPass?: "continue" | "break"; // default: "continue" (proceed to next step)
61
+ onFail?: "continue" | "break" | "fail"; // default: "fail" (stop the workflow)
62
+ }
63
+
64
+ export interface TransformSpec {
65
+ /**
66
+ * A mapping of output field names to ${{ }} expressions.
67
+ * The result is an object with each field resolved.
68
+ * No LLM invocation — pure data transformation.
69
+ */
70
+ mapping: Record<string, unknown>;
71
+ }
72
+
73
+ export interface StepOutputSpec {
74
+ format?: "json" | "text"; // default: "text"
75
+ schema?: string; // path to JSON Schema file, relative to workflow spec
76
+ path?: string; // output file path — may contain ${{ }} expressions
77
+ }
78
+
79
+ // ── Agent Spec (loaded from .md frontmatter or .agent.yaml) ──
80
+
81
+ export interface AgentSpec {
82
+ name: string;
83
+ description?: string;
84
+ role?: string; // sensor | reasoning | action | quality
85
+ systemPrompt?: string; // compiled system prompt (from template rendering)
86
+ promptTemplate?: string; // system prompt template path
87
+ taskTemplate?: string; // task prompt template path
88
+ model?: string;
89
+ thinking?: string;
90
+ tools?: string[];
91
+ extensions?: string[];
92
+ skills?: string[];
93
+ output?: string; // output file path
94
+ inputSchema?: Record<string, unknown>; // JSON Schema for input
95
+ outputFormat?: "json" | "text"; // what the agent produces
96
+ outputSchema?: string; // path to output JSON Schema
97
+ }
98
+
99
+ // ── Execution State ──
100
+
101
+ export interface ExecutionState {
102
+ input: unknown;
103
+ steps: Record<string, StepResult>;
104
+ status: "running" | "completed" | "failed" | "paused";
105
+ loop?: LoopState; // set when inside a loop
106
+ // Resume support:
107
+ workflowName?: string; // which workflow this run belongs to
108
+ specVersion?: string; // workflow spec version at time of run
109
+ startedAt?: string; // ISO timestamp of run start
110
+ updatedAt?: string; // ISO timestamp of last state write
111
+ }
112
+
113
+ export interface LoopState {
114
+ stepName: string; // name of the loop step
115
+ iteration: number; // current iteration (0-based)
116
+ maxAttempts: number;
117
+ priorAttempts: LoopAttempt[]; // results from previous iterations
118
+ }
119
+
120
+ export interface LoopAttempt {
121
+ iteration: number;
122
+ steps: Record<string, StepResult>; // results of all steps in this iteration
123
+ }
124
+
125
+ export interface StepResult {
126
+ step: string;
127
+ agent: string;
128
+ status: "completed" | "failed" | "skipped";
129
+ output?: unknown; // parsed structured output (if schema-bound)
130
+ textOutput?: string; // raw text from last assistant message
131
+ outputPath?: string; // absolute path to persisted output JSON
132
+ sessionLog?: string; // path to session log file
133
+ usage: StepUsage;
134
+ durationMs: number;
135
+ error?: string; // error message if failed
136
+ truncated?: boolean; // true if stdout exceeded MAX_STDOUT_BYTES and data was lost
137
+ warnings?: string[]; // non-fatal issues encountered during step execution
138
+ attempt?: number; // which attempt this result is from (1-based)
139
+ totalAttempts?: number; // total attempts made
140
+ priorErrors?: string[]; // error messages from prior failed attempts
141
+ }
142
+
143
+ export interface StepUsage {
144
+ input: number;
145
+ output: number;
146
+ cacheRead: number;
147
+ cacheWrite: number;
148
+ cost: number;
149
+ turns: number;
150
+ }
151
+
152
+ // ── Workflow Result (returned to caller) ──
153
+
154
+ export interface WorkflowResult {
155
+ workflow: string;
156
+ runId: string;
157
+ status: "completed" | "failed" | "paused";
158
+ steps: Record<string, StepResult>;
159
+ output?: unknown; // final workflow output (last step's output, or explicit)
160
+ totalUsage: StepUsage;
161
+ totalDurationMs: number;
162
+ runDir: string; // absolute path to .pi/workflow-runs/<name>/runs/<run-id>/
163
+ artifacts?: Record<string, string>; // name → absolute path of written artifact files
164
+ }
165
+
166
+ // ── Completion Spec ──
167
+
168
+ export interface CompletionSpec {
169
+ message?: string; // LLM instruction text (may contain ${{ }} expressions)
170
+ include?: string[]; // expression paths to resolve and attach as data
171
+ template?: string; // full template with ${{ }} (mutually exclusive with message)
172
+ }
173
+
174
+ // ── Expression Scope (what ${{ }} expressions resolve against) ──
175
+
176
+ export interface ExpressionScope {
177
+ input: unknown;
178
+ steps: Record<string, StepResult>;
179
+ [key: string]: unknown; // allows use as Record<string, unknown>
180
+ }
181
+
182
+ // ── Completion Scope (wider scope for completion templates) ──
183
+
184
+ export interface CompletionScope {
185
+ input: unknown;
186
+ steps: Record<string, StepResult>;
187
+ totalUsage: StepUsage;
188
+ totalDurationMs: number;
189
+ runDir: string;
190
+ runId: string;
191
+ workflow: string;
192
+ status: "completed" | "failed";
193
+ output?: unknown;
194
+ [key: string]: unknown; // allows use as Record<string, unknown>
195
+ }
@@ -0,0 +1,226 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import path from "node:path";
4
+ import { validateFromFile } from "@davidorex/pi-project/src/schema-validator.ts";
5
+
6
+ const schemaPath = path.resolve(import.meta.dirname, "..", "schemas", "verifier-output.schema.json");
7
+
8
+ function validVerifierOutput() {
9
+ return {
10
+ status: "passed",
11
+ score: "3/3",
12
+ truths: [
13
+ { truth: "Tests pass", status: "verified", evidence: "npm test exited 0" },
14
+ { truth: "File exists", status: "verified", evidence: "ls confirmed" },
15
+ { truth: "Schema valid", status: "verified", evidence: "ajv validates" },
16
+ ],
17
+ criteria_results: [
18
+ {
19
+ criterion: "All tests pass",
20
+ verify_method: "command",
21
+ status: "passed",
22
+ expected_outcome: "exit code 0",
23
+ actual_outcome: "exit code 0",
24
+ evidence: "npm test output: 3 passing",
25
+ },
26
+ {
27
+ criterion: "File structure correct",
28
+ verify_method: "inspect",
29
+ status: "passed",
30
+ evidence: "All expected files present",
31
+ },
32
+ {
33
+ criterion: "UI renders correctly",
34
+ verify_method: "inspect",
35
+ status: "skipped",
36
+ evidence: "Requires visual inspection",
37
+ },
38
+ ],
39
+ };
40
+ }
41
+
42
+ describe("verifier-output schema validation", () => {
43
+ it("validates a complete verifier output", () => {
44
+ const data = validVerifierOutput();
45
+ const result = validateFromFile(schemaPath, data, "test");
46
+ assert.deepStrictEqual(result, data);
47
+ });
48
+
49
+ it("validates with all optional fields present", () => {
50
+ const data = {
51
+ ...validVerifierOutput(),
52
+ artifacts: [
53
+ { path: "src/index.ts", status: "verified", exists: true, substantive: true, wired: true, details: "Main entry point" },
54
+ { path: "src/missing.ts", status: "missing", exists: false },
55
+ ],
56
+ requirements_coverage: [
57
+ { requirement_id: "REQ-001", status: "satisfied", supporting_truths: ["Tests pass"] },
58
+ { requirement_id: "REQ-002", status: "needs_human" },
59
+ ],
60
+ human_verification: [
61
+ { name: "UI layout", test: "Open browser and check layout", expected: "Grid renders correctly", why_human: "Requires visual inspection" },
62
+ ],
63
+ gaps: [
64
+ { truth: "Performance benchmark", status: "failed", reason: "No benchmark data available" },
65
+ ],
66
+ };
67
+ const result = validateFromFile(schemaPath, data, "test");
68
+ assert.ok(result);
69
+ });
70
+
71
+ it("validates with empty optional arrays", () => {
72
+ const data = {
73
+ ...validVerifierOutput(),
74
+ artifacts: [],
75
+ requirements_coverage: [],
76
+ human_verification: [],
77
+ gaps: [],
78
+ };
79
+ const result = validateFromFile(schemaPath, data, "test");
80
+ assert.ok(result);
81
+ });
82
+
83
+ it("rejects missing required field: status", () => {
84
+ const data = validVerifierOutput();
85
+ delete (data as any).status;
86
+ assert.throws(
87
+ () => validateFromFile(schemaPath, data, "test"),
88
+ (err: any) => err.message.includes("Validation failed"),
89
+ );
90
+ });
91
+
92
+ it("rejects missing required field: score", () => {
93
+ const data = validVerifierOutput();
94
+ delete (data as any).score;
95
+ assert.throws(
96
+ () => validateFromFile(schemaPath, data, "test"),
97
+ (err: any) => err.message.includes("Validation failed"),
98
+ );
99
+ });
100
+
101
+ it("rejects missing required field: truths", () => {
102
+ const data = validVerifierOutput();
103
+ delete (data as any).truths;
104
+ assert.throws(
105
+ () => validateFromFile(schemaPath, data, "test"),
106
+ (err: any) => err.message.includes("Validation failed"),
107
+ );
108
+ });
109
+
110
+ it("rejects missing required field: criteria_results", () => {
111
+ const data = validVerifierOutput();
112
+ delete (data as any).criteria_results;
113
+ assert.throws(
114
+ () => validateFromFile(schemaPath, data, "test"),
115
+ (err: any) => err.message.includes("Validation failed"),
116
+ );
117
+ });
118
+
119
+ it("rejects invalid status enum value", () => {
120
+ const data = validVerifierOutput();
121
+ data.status = "invalid_status" as any;
122
+ assert.throws(
123
+ () => validateFromFile(schemaPath, data, "test"),
124
+ (err: any) => err.message.includes("Validation failed"),
125
+ );
126
+ });
127
+
128
+ it("rejects invalid truth status enum value", () => {
129
+ const data = validVerifierOutput();
130
+ data.truths[0].status = "bad_status" as any;
131
+ assert.throws(
132
+ () => validateFromFile(schemaPath, data, "test"),
133
+ (err: any) => err.message.includes("Validation failed"),
134
+ );
135
+ });
136
+
137
+ it("rejects invalid verify_method enum value", () => {
138
+ const data = validVerifierOutput();
139
+ data.criteria_results[0].verify_method = "magic" as any;
140
+ assert.throws(
141
+ () => validateFromFile(schemaPath, data, "test"),
142
+ (err: any) => err.message.includes("Validation failed"),
143
+ );
144
+ });
145
+
146
+ it("rejects invalid criteria_results status enum value", () => {
147
+ const data = validVerifierOutput();
148
+ data.criteria_results[0].status = "invalid" as any;
149
+ assert.throws(
150
+ () => validateFromFile(schemaPath, data, "test"),
151
+ (err: any) => err.message.includes("Validation failed"),
152
+ );
153
+ });
154
+
155
+ it("rejects invalid artifact status enum value", () => {
156
+ const data = {
157
+ ...validVerifierOutput(),
158
+ artifacts: [{ path: "test.ts", status: "unknown" }],
159
+ };
160
+ assert.throws(
161
+ () => validateFromFile(schemaPath, data, "test"),
162
+ (err: any) => err.message.includes("Validation failed"),
163
+ );
164
+ });
165
+ });
166
+
167
+ describe("plan-breakdown schema validation", () => {
168
+ const planSchemaPath = path.resolve(import.meta.dirname, "..", "schemas", "plan-breakdown.schema.json");
169
+
170
+ it("validates a valid plan breakdown", () => {
171
+ const data = {
172
+ plans: [
173
+ {
174
+ name: "implement-auth",
175
+ intent: "Add authentication",
176
+ tasks: ["Create auth module", "Add login endpoint"],
177
+ acceptance_criteria: ["Login works", "Tests pass"],
178
+ files_to_change: ["src/auth.ts"],
179
+ context_needed: ["existing user model"],
180
+ parallel_group: "core",
181
+ },
182
+ ],
183
+ };
184
+ const result = validateFromFile(planSchemaPath, data, "test");
185
+ assert.ok(result);
186
+ });
187
+
188
+ it("rejects missing required plan fields", () => {
189
+ const data = { plans: [{ name: "test" }] };
190
+ assert.throws(
191
+ () => validateFromFile(planSchemaPath, data, "test"),
192
+ (err: any) => err.message.includes("Validation failed"),
193
+ );
194
+ });
195
+ });
196
+
197
+ describe("execution-results schema validation", () => {
198
+ const execSchemaPath = path.resolve(import.meta.dirname, "..", "schemas", "execution-results.schema.json");
199
+
200
+ it("validates a valid execution result", () => {
201
+ const data = {
202
+ status: "complete",
203
+ tasks: [
204
+ { name: "implement feature", status: "done", files_modified: ["src/index.ts"], commit_hash: "abc123", notes: "Done" },
205
+ ],
206
+ decisions: [
207
+ { id: "D-001", decision: "Use ESM", rationale: "Modern standard", status: "decided" },
208
+ ],
209
+ issues: [
210
+ { severity: "info", description: "Minor lint warning" },
211
+ ],
212
+ test_count: 42,
213
+ commit_hash: "abc123",
214
+ };
215
+ const result = validateFromFile(execSchemaPath, data, "test");
216
+ assert.ok(result);
217
+ });
218
+
219
+ it("rejects invalid status enum", () => {
220
+ const data = { status: "unknown", tasks: [] };
221
+ assert.throws(
222
+ () => validateFromFile(execSchemaPath, data, "test"),
223
+ (err: any) => err.message.includes("Validation failed"),
224
+ );
225
+ });
226
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { discoverWorkflows, findWorkflow } from "./workflow-discovery.ts";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+
8
+ // Use a nonexistent dir as builtinDir so tests don't pick up real demo workflows
9
+ const NO_BUILTINS = path.join(os.tmpdir(), "wf-no-builtins-nonexistent");
10
+
11
+ describe("discoverWorkflows", () => {
12
+ it("discovers workflows from project directory", () => {
13
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
14
+ const wfDir = path.join(tmpDir, ".pi", "workflows");
15
+ fs.mkdirSync(wfDir, { recursive: true });
16
+ fs.writeFileSync(
17
+ path.join(wfDir, "test.workflow.yaml"),
18
+ "name: test\nsteps:\n s:\n agent: a",
19
+ );
20
+
21
+ const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
22
+ assert.strictEqual(specs.length, 1);
23
+ assert.strictEqual(specs[0].name, "test");
24
+ assert.strictEqual(specs[0].source, "project");
25
+
26
+ fs.rmSync(tmpDir, { recursive: true });
27
+ });
28
+
29
+ it("returns empty array when no workflow directories exist", () => {
30
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
31
+ const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
32
+ assert.strictEqual(specs.length, 0);
33
+ fs.rmSync(tmpDir, { recursive: true });
34
+ });
35
+
36
+ it("skips invalid specs with warning (no throw)", () => {
37
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
38
+ const wfDir = path.join(tmpDir, ".pi", "workflows");
39
+ fs.mkdirSync(wfDir, { recursive: true });
40
+ fs.writeFileSync(path.join(wfDir, "bad.workflow.yaml"), "not: valid: workflow");
41
+ fs.writeFileSync(
42
+ path.join(wfDir, "good.workflow.yaml"),
43
+ "name: good\nsteps:\n s:\n agent: a",
44
+ );
45
+
46
+ const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
47
+ assert.strictEqual(specs.length, 1);
48
+ assert.strictEqual(specs[0].name, "good");
49
+
50
+ fs.rmSync(tmpDir, { recursive: true });
51
+ });
52
+
53
+ it("project specs shadow user specs with same name", () => {
54
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
55
+ const projectDir = path.join(tmpDir, ".pi", "workflows");
56
+ fs.mkdirSync(projectDir, { recursive: true });
57
+ fs.writeFileSync(
58
+ path.join(projectDir, "shared.workflow.yaml"),
59
+ "name: shared\ndescription: project version\nsteps:\n s:\n agent: a",
60
+ );
61
+
62
+ const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
63
+ assert.strictEqual(specs.length, 1);
64
+ assert.strictEqual(specs[0].source, "project");
65
+
66
+ fs.rmSync(tmpDir, { recursive: true });
67
+ });
68
+
69
+ it("discovers builtin demo workflows", () => {
70
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
71
+ const demoDir = path.resolve(import.meta.dirname, "..", "workflows");
72
+
73
+ const specs = discoverWorkflows(tmpDir, demoDir);
74
+ assert.ok(specs.length > 0, "should find at least one demo workflow");
75
+
76
+ const names = specs.map((s) => s.name);
77
+ assert.ok(names.includes("typed-analysis"), "should find typed-analysis demo");
78
+
79
+ fs.rmSync(tmpDir, { recursive: true });
80
+ });
81
+ });
82
+
83
+ describe("findWorkflow", () => {
84
+ it("finds a workflow by name", () => {
85
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
86
+ const wfDir = path.join(tmpDir, ".pi", "workflows");
87
+ fs.mkdirSync(wfDir, { recursive: true });
88
+ fs.writeFileSync(
89
+ path.join(wfDir, "bugfix.workflow.yaml"),
90
+ "name: bugfix\nsteps:\n s:\n agent: a",
91
+ );
92
+
93
+ const spec = findWorkflow("bugfix", tmpDir, NO_BUILTINS);
94
+ assert.ok(spec);
95
+ assert.strictEqual(spec.name, "bugfix");
96
+
97
+ fs.rmSync(tmpDir, { recursive: true });
98
+ });
99
+
100
+ it("returns undefined for unknown workflow", () => {
101
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
102
+ assert.strictEqual(findWorkflow("nonexistent", tmpDir, NO_BUILTINS), undefined);
103
+ fs.rmSync(tmpDir, { recursive: true });
104
+ });
105
+ });
@@ -0,0 +1,113 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import type { WorkflowSpec } from "./types.ts";
5
+ import { parseWorkflowSpec } from "./workflow-spec.ts";
6
+
7
+ /**
8
+ * Discover all workflow specs from project, user, and builtin directories.
9
+ *
10
+ * Scans (highest priority first):
11
+ * 1. .pi/workflows/ (project-level, source: "project")
12
+ * 2. ~/.pi/agent/workflows/ (user-level, source: "user")
13
+ * 3. <package>/workflows/ (builtin workflows, source: "user")
14
+ *
15
+ * Higher-priority specs shadow lower-priority specs with the same name.
16
+ *
17
+ * @param cwd - current working directory (project root)
18
+ * @param builtinDir - optional path to builtin workflows (defaults to workflows/ relative to package root)
19
+ * @returns Array of parsed WorkflowSpec objects. Specs that fail parsing are
20
+ * skipped with a warning (logged to stderr), not thrown.
21
+ */
22
+ export function discoverWorkflows(cwd: string, builtinDir?: string): WorkflowSpec[] {
23
+ const projectDir = path.join(cwd, ".pi", "workflows");
24
+ const userDir = path.join(os.homedir(), ".pi", "agent", "workflows");
25
+ const demoDir = builtinDir ?? path.resolve(import.meta.dirname, "..", "workflows");
26
+
27
+ const projectSpecs = scanDirectory(projectDir, "project");
28
+ const userSpecs = scanDirectory(userDir, "user");
29
+ const builtinSpecs = scanDirectory(demoDir, "user");
30
+
31
+ // Deduplicate: project > user > builtin (last write wins, so add lowest priority first)
32
+ const byName = new Map<string, WorkflowSpec>();
33
+
34
+ for (const spec of builtinSpecs) {
35
+ byName.set(spec.name, spec);
36
+ }
37
+ for (const spec of userSpecs) {
38
+ byName.set(spec.name, spec);
39
+ }
40
+ for (const spec of projectSpecs) {
41
+ byName.set(spec.name, spec);
42
+ }
43
+
44
+ // Return sorted by name
45
+ const result = Array.from(byName.values());
46
+ result.sort((a, b) => a.name.localeCompare(b.name));
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Find a workflow by name from discovered workflows.
52
+ * Returns undefined if not found.
53
+ */
54
+ export function findWorkflow(name: string, cwd: string, builtinDir?: string): WorkflowSpec | undefined {
55
+ const specs = discoverWorkflows(cwd, builtinDir);
56
+ return specs.find((s) => s.name === name);
57
+ }
58
+
59
+ /**
60
+ * Scan a directory for *.workflow.yaml files and parse them.
61
+ * Non-recursive. Returns parsed specs; logs warnings for parse failures.
62
+ */
63
+ function scanDirectory(dirPath: string, source: "user" | "project"): WorkflowSpec[] {
64
+ if (!fs.existsSync(dirPath)) {
65
+ return [];
66
+ }
67
+
68
+ let entries: string[];
69
+ try {
70
+ entries = fs.readdirSync(dirPath);
71
+ } catch {
72
+ return [];
73
+ }
74
+
75
+ const specs: WorkflowSpec[] = [];
76
+
77
+ for (const entry of entries) {
78
+ if (!entry.endsWith(".workflow.yaml")) {
79
+ continue;
80
+ }
81
+
82
+ const filePath = path.join(dirPath, entry);
83
+
84
+ // Only process files, not directories
85
+ let stat: fs.Stats;
86
+ try {
87
+ stat = fs.statSync(filePath);
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (!stat.isFile()) {
92
+ continue;
93
+ }
94
+
95
+ let content: string;
96
+ try {
97
+ content = fs.readFileSync(filePath, "utf-8");
98
+ } catch {
99
+ console.error(`[pi-workflows] Warning: skipping ${filePath}: could not read file`);
100
+ continue;
101
+ }
102
+
103
+ try {
104
+ const spec = parseWorkflowSpec(content, filePath, source);
105
+ specs.push(spec);
106
+ } catch (err) {
107
+ const msg = err instanceof Error ? err.message : String(err);
108
+ console.error(`[pi-workflows] Warning: skipping ${filePath}: ${msg}`);
109
+ }
110
+ }
111
+
112
+ return specs;
113
+ }