@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
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Agent spec loading — YAML specs are the source of truth.
3
+ *
4
+ * Agent specs are declarative YAML files that define typed functions:
5
+ * InputSchema → OutputSchema, with template references for prompt
6
+ * composition. The .md that pi consumes is compiled at dispatch time
7
+ * from spec + templates + typed input. It exists in memory only.
8
+ *
9
+ * Search order (first match wins):
10
+ * 1. .pi/agents/<name>.agent.yaml (project)
11
+ * 2. ~/.pi/agent/agents/<name>.agent.yaml (user)
12
+ * 3. <package>/agents/<name>.agent.yaml (builtin)
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ /** Check if a prompt.system value looks like a template file path vs inline text. */
18
+ function isTemplatePath(value: string | undefined): boolean {
19
+ if (!value) return false;
20
+ return value.endsWith(".md") || value.endsWith(".txt") || (value.includes("/") && !value.includes("\n"));
21
+ }
22
+ import os from "node:os";
23
+ import { parse as parseYaml } from "yaml";
24
+ import type { AgentSpec } from "./types.ts";
25
+
26
+ /**
27
+ * Thrown when an agent spec file is not found in any search path.
28
+ */
29
+ export class AgentNotFoundError extends Error {
30
+ public readonly agentName: string;
31
+ public readonly searchPaths: string[];
32
+
33
+ constructor(agentName: string, searchPaths: string[]) {
34
+ const pathList = searchPaths.map(p => ` - ${p}`).join("\n");
35
+ super(`Agent '${agentName}' not found. Searched:\n${pathList}`);
36
+ this.name = "AgentNotFoundError";
37
+ this.agentName = agentName;
38
+ this.searchPaths = searchPaths;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Thrown when an agent spec file exists but cannot be read or parsed.
44
+ */
45
+ export class AgentParseError extends Error {
46
+ public readonly agentName: string;
47
+ public readonly filePath: string;
48
+ public readonly cause: Error;
49
+
50
+ constructor(agentName: string, filePath: string, cause: Error) {
51
+ super(`Agent '${agentName}' at ${filePath}: ${cause.message}`);
52
+ this.name = "AgentParseError";
53
+ this.agentName = agentName;
54
+ this.filePath = filePath;
55
+ this.cause = cause;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Parse a YAML agent spec file into an AgentSpec.
61
+ */
62
+ export function parseAgentYaml(filePath: string): AgentSpec {
63
+ const name = path.basename(filePath, ".agent.yaml");
64
+
65
+ let content: string;
66
+ try {
67
+ content = fs.readFileSync(filePath, "utf-8");
68
+ } catch (err) {
69
+ throw new AgentParseError(name, filePath, err instanceof Error ? err : new Error(String(err)));
70
+ }
71
+
72
+ let spec: any;
73
+ try {
74
+ spec = parseYaml(content);
75
+ } catch (err) {
76
+ throw new AgentParseError(name, filePath, err instanceof Error ? err : new Error(String(err)));
77
+ }
78
+
79
+ // Handle null/undefined from parsing empty file or non-mapping YAML
80
+ if (!spec || typeof spec !== "object") {
81
+ throw new AgentParseError(name, filePath, new Error("File is empty or does not contain a YAML mapping"));
82
+ }
83
+
84
+ return {
85
+ name: spec.name || name,
86
+ description: spec.description,
87
+ role: spec.role,
88
+ model: spec.model,
89
+ thinking: spec.thinking,
90
+ tools: spec.tools,
91
+ extensions: spec.extensions,
92
+ skills: spec.skills,
93
+ output: spec.output?.file,
94
+ promptTemplate: isTemplatePath(spec.prompt?.system) ? spec.prompt?.system : undefined,
95
+ systemPrompt: isTemplatePath(spec.prompt?.system) ? undefined : spec.prompt?.system,
96
+ taskTemplate: spec.prompt?.task,
97
+ inputSchema: spec.input,
98
+ outputFormat: spec.output?.format,
99
+ outputSchema: spec.output?.schema,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Create an agent loader that finds .agent.yaml specs.
105
+ */
106
+ export function createAgentLoader(cwd: string, builtinDir?: string): (name: string) => AgentSpec {
107
+ const defaultBuiltinDir = builtinDir ?? path.resolve(import.meta.dirname, "..", "agents");
108
+
109
+ return (name: string): AgentSpec => {
110
+ const searchPaths = [
111
+ path.join(cwd, ".pi", "agents", `${name}.agent.yaml`),
112
+ path.join(os.homedir(), ".pi", "agent", "agents", `${name}.agent.yaml`),
113
+ path.join(defaultBuiltinDir, `${name}.agent.yaml`),
114
+ ];
115
+
116
+ for (const p of searchPaths) {
117
+ if (fs.existsSync(p)) return parseAgentYaml(p);
118
+ }
119
+
120
+ throw new AgentNotFoundError(name, searchPaths);
121
+ };
122
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Tests for post-step block validation with filesystem diff.
3
+ */
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ import { snapshotBlockFiles, validateChangedBlocks } from "@davidorex/pi-project/src/block-validation.ts";
10
+ import { executeWorkflow } from "./workflow-executor.ts";
11
+ import type { WorkflowSpec } from "./types.ts";
12
+ import { mockCtx, mockPi } from "./test-helpers.ts";
13
+
14
+ // ── Unit tests for snapshotBlockFiles / validateChangedBlocks ──
15
+
16
+ describe("snapshotBlockFiles", () => {
17
+ it("returns empty map when .project/ does not exist", () => {
18
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-snap-"));
19
+ const result = snapshotBlockFiles(tmpDir);
20
+ assert.strictEqual(result.size, 0);
21
+ fs.rmSync(tmpDir, { recursive: true });
22
+ });
23
+
24
+ it("snapshots .json files in .project/", () => {
25
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-snap-"));
26
+ const wfDir = path.join(tmpDir, ".project");
27
+ fs.mkdirSync(wfDir, { recursive: true });
28
+ fs.writeFileSync(path.join(wfDir, "gaps.json"), "{}");
29
+ fs.writeFileSync(path.join(wfDir, "decisions.json"), "[]");
30
+ fs.writeFileSync(path.join(wfDir, "readme.txt"), "not json"); // should be ignored
31
+
32
+ const result = snapshotBlockFiles(tmpDir);
33
+ assert.strictEqual(result.size, 2);
34
+ assert.ok(result.has(path.join(wfDir, "gaps.json")));
35
+ assert.ok(result.has(path.join(wfDir, "decisions.json")));
36
+ assert.ok(!result.has(path.join(wfDir, "readme.txt")));
37
+
38
+ fs.rmSync(tmpDir, { recursive: true });
39
+ });
40
+ });
41
+
42
+ describe("validateChangedBlocks", () => {
43
+ it("does nothing when no files changed", () => {
44
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
45
+ const wfDir = path.join(tmpDir, ".project");
46
+ fs.mkdirSync(wfDir, { recursive: true });
47
+ fs.writeFileSync(path.join(wfDir, "gaps.json"), "{}");
48
+
49
+ const before = snapshotBlockFiles(tmpDir);
50
+ // No changes — should not throw
51
+ assert.doesNotThrow(() => validateChangedBlocks(tmpDir, before));
52
+
53
+ fs.rmSync(tmpDir, { recursive: true });
54
+ });
55
+
56
+ it("skips changed files with no matching schema", () => {
57
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
58
+ const wfDir = path.join(tmpDir, ".project");
59
+ fs.mkdirSync(wfDir, { recursive: true });
60
+
61
+ const before = snapshotBlockFiles(tmpDir);
62
+
63
+ // Create a new file with no schema
64
+ fs.writeFileSync(path.join(wfDir, "no-schema.json"), '{"data": true}');
65
+
66
+ // Should not throw — no schema to validate against
67
+ assert.doesNotThrow(() => validateChangedBlocks(tmpDir, before));
68
+
69
+ fs.rmSync(tmpDir, { recursive: true });
70
+ });
71
+
72
+ it("validates changed file against its schema — passes", () => {
73
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
74
+ const wfDir = path.join(tmpDir, ".project");
75
+ const schemasDir = path.join(wfDir, "schemas");
76
+ fs.mkdirSync(schemasDir, { recursive: true });
77
+
78
+ // Create schema
79
+ fs.writeFileSync(path.join(schemasDir, "test-block.schema.json"), JSON.stringify({
80
+ type: "object",
81
+ required: ["name"],
82
+ properties: { name: { type: "string" } },
83
+ }));
84
+
85
+ const before = snapshotBlockFiles(tmpDir);
86
+
87
+ // Create a valid block file
88
+ fs.writeFileSync(path.join(wfDir, "test-block.json"), JSON.stringify({ name: "valid" }));
89
+
90
+ assert.doesNotThrow(() => validateChangedBlocks(tmpDir, before));
91
+
92
+ fs.rmSync(tmpDir, { recursive: true });
93
+ });
94
+
95
+ it("throws on validation failure", () => {
96
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
97
+ const wfDir = path.join(tmpDir, ".project");
98
+ const schemasDir = path.join(wfDir, "schemas");
99
+ fs.mkdirSync(schemasDir, { recursive: true });
100
+
101
+ // Create schema requiring name as string
102
+ fs.writeFileSync(path.join(schemasDir, "test-block.schema.json"), JSON.stringify({
103
+ type: "object",
104
+ required: ["name"],
105
+ properties: { name: { type: "string" } },
106
+ }));
107
+
108
+ const before = snapshotBlockFiles(tmpDir);
109
+
110
+ // Create an invalid block file (name should be string, not number)
111
+ fs.writeFileSync(path.join(wfDir, "test-block.json"), JSON.stringify({ name: 123 }));
112
+
113
+ assert.throws(
114
+ () => validateChangedBlocks(tmpDir, before),
115
+ (err: unknown) => err instanceof Error && err.message.includes("Block validation failed"),
116
+ );
117
+
118
+ fs.rmSync(tmpDir, { recursive: true });
119
+ });
120
+
121
+ it("detects modified files (not just new files)", () => {
122
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
123
+ const wfDir = path.join(tmpDir, ".project");
124
+ const schemasDir = path.join(wfDir, "schemas");
125
+ fs.mkdirSync(schemasDir, { recursive: true });
126
+
127
+ // Create schema
128
+ fs.writeFileSync(path.join(schemasDir, "data.schema.json"), JSON.stringify({
129
+ type: "object",
130
+ required: ["value"],
131
+ properties: { value: { type: "number" } },
132
+ }));
133
+
134
+ // Create valid initial file
135
+ const blockPath = path.join(wfDir, "data.json");
136
+ fs.writeFileSync(blockPath, JSON.stringify({ value: 42 }));
137
+
138
+ const before = snapshotBlockFiles(tmpDir);
139
+
140
+ // Modify the file to be invalid (need to ensure mtime changes)
141
+ // Use a tiny delay or touch the file
142
+ const newContent = JSON.stringify({ value: "not a number" });
143
+ // Force mtime change by writing with a slight delay
144
+ const origMtime = fs.statSync(blockPath).mtimeMs;
145
+ fs.writeFileSync(blockPath, newContent);
146
+ // If mtime didn't change (same millisecond), force it
147
+ const newMtime = fs.statSync(blockPath).mtimeMs;
148
+ if (newMtime === origMtime) {
149
+ fs.utimesSync(blockPath, new Date(), new Date(Date.now() + 1000));
150
+ }
151
+
152
+ assert.throws(
153
+ () => validateChangedBlocks(tmpDir, before),
154
+ (err: unknown) => err instanceof Error && err.message.includes("Block validation failed"),
155
+ );
156
+
157
+ fs.rmSync(tmpDir, { recursive: true });
158
+ });
159
+ });
160
+
161
+ // ── Integration tests: block validation in workflow executor ──
162
+
163
+ describe("post-step block validation in executor", () => {
164
+ it("step that does not modify .project/ passes normally", async () => {
165
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
166
+ const spec: WorkflowSpec = {
167
+ name: "bv-no-change",
168
+ description: "step without block changes",
169
+ steps: {
170
+ compute: {
171
+ transform: { mapping: { result: 42 } },
172
+ },
173
+ },
174
+ source: "project",
175
+ filePath: path.join(tmpDir, "test.project.yaml"),
176
+ };
177
+
178
+ const result = await executeWorkflow(spec, {}, {
179
+ ctx: mockCtx(tmpDir),
180
+ pi: mockPi(),
181
+ loadAgent: () => ({ name: "default" }),
182
+ });
183
+
184
+ assert.strictEqual(result.status, "completed");
185
+ assert.strictEqual(result.steps.compute.status, "completed");
186
+
187
+ fs.rmSync(tmpDir, { recursive: true });
188
+ });
189
+
190
+ it("step that writes valid data to a .project/ block passes", async () => {
191
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
192
+ const wfDir = path.join(tmpDir, ".project");
193
+ const schemasDir = path.join(wfDir, "schemas");
194
+ fs.mkdirSync(schemasDir, { recursive: true });
195
+
196
+ // Create schema
197
+ fs.writeFileSync(path.join(schemasDir, "result.schema.json"), JSON.stringify({
198
+ type: "object",
199
+ required: ["status"],
200
+ properties: { status: { type: "string" } },
201
+ }));
202
+
203
+ // Use a command step that writes a valid block file
204
+ const spec: WorkflowSpec = {
205
+ name: "bv-valid-write",
206
+ description: "step writes valid block",
207
+ steps: {
208
+ writeBlock: {
209
+ command: `echo '{"status": "ok"}' > ${path.join(wfDir, "result.json")}`,
210
+ },
211
+ },
212
+ source: "project",
213
+ filePath: path.join(tmpDir, "test.project.yaml"),
214
+ };
215
+
216
+ const result = await executeWorkflow(spec, {}, {
217
+ ctx: mockCtx(tmpDir),
218
+ pi: mockPi(),
219
+ loadAgent: () => ({ name: "default" }),
220
+ });
221
+
222
+ assert.strictEqual(result.status, "completed");
223
+ assert.strictEqual(result.steps.writeBlock.status, "completed");
224
+
225
+ fs.rmSync(tmpDir, { recursive: true });
226
+ });
227
+
228
+ it("step that writes invalid data to a .project/ block fails", async () => {
229
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
230
+ const wfDir = path.join(tmpDir, ".project");
231
+ const schemasDir = path.join(wfDir, "schemas");
232
+ fs.mkdirSync(schemasDir, { recursive: true });
233
+
234
+ // Create schema requiring status as string
235
+ fs.writeFileSync(path.join(schemasDir, "result.schema.json"), JSON.stringify({
236
+ type: "object",
237
+ required: ["status"],
238
+ properties: { status: { type: "string" } },
239
+ }));
240
+
241
+ // Command step writes invalid data (status is number, not string)
242
+ const spec: WorkflowSpec = {
243
+ name: "bv-invalid-write",
244
+ description: "step writes invalid block",
245
+ steps: {
246
+ writeBlock: {
247
+ command: `echo '{"status": 123}' > ${path.join(wfDir, "result.json")}`,
248
+ },
249
+ shouldNotRun: {
250
+ transform: { mapping: { ran: true } },
251
+ },
252
+ },
253
+ source: "project",
254
+ filePath: path.join(tmpDir, "test.project.yaml"),
255
+ };
256
+
257
+ const result = await executeWorkflow(spec, {}, {
258
+ ctx: mockCtx(tmpDir),
259
+ pi: mockPi(),
260
+ loadAgent: () => ({ name: "default" }),
261
+ });
262
+
263
+ assert.strictEqual(result.status, "failed");
264
+ assert.strictEqual(result.steps.writeBlock.status, "failed");
265
+ assert.ok(result.steps.writeBlock.error?.includes("Block validation failed"));
266
+ // Fail-fast: next step should not run
267
+ assert.ok(!result.steps.shouldNotRun);
268
+
269
+ fs.rmSync(tmpDir, { recursive: true });
270
+ });
271
+
272
+ it("changed .project/ file with no matching schema is skipped", async () => {
273
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
274
+ const wfDir = path.join(tmpDir, ".project");
275
+ fs.mkdirSync(wfDir, { recursive: true });
276
+ // No schemas directory at all
277
+
278
+ // Command writes a JSON file with no schema
279
+ const spec: WorkflowSpec = {
280
+ name: "bv-no-schema",
281
+ description: "step writes block with no schema",
282
+ steps: {
283
+ writeBlock: {
284
+ command: `echo '{"anything": "goes"}' > ${path.join(wfDir, "custom.json")}`,
285
+ },
286
+ },
287
+ source: "project",
288
+ filePath: path.join(tmpDir, "test.project.yaml"),
289
+ };
290
+
291
+ const result = await executeWorkflow(spec, {}, {
292
+ ctx: mockCtx(tmpDir),
293
+ pi: mockPi(),
294
+ loadAgent: () => ({ name: "default" }),
295
+ });
296
+
297
+ assert.strictEqual(result.status, "completed");
298
+ assert.strictEqual(result.steps.writeBlock.status, "completed");
299
+
300
+ fs.rmSync(tmpDir, { recursive: true });
301
+ });
302
+
303
+ it("validates multiple changed block files in one step", async () => {
304
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
305
+ const wfDir = path.join(tmpDir, ".project");
306
+ const schemasDir = path.join(wfDir, "schemas");
307
+ fs.mkdirSync(schemasDir, { recursive: true });
308
+
309
+ // Schema for file-a: requires name as string
310
+ fs.writeFileSync(path.join(schemasDir, "file-a.schema.json"), JSON.stringify({
311
+ type: "object",
312
+ required: ["name"],
313
+ properties: { name: { type: "string" } },
314
+ }));
315
+ // Schema for file-b: requires count as number
316
+ fs.writeFileSync(path.join(schemasDir, "file-b.schema.json"), JSON.stringify({
317
+ type: "object",
318
+ required: ["count"],
319
+ properties: { count: { type: "number" } },
320
+ }));
321
+
322
+ // Write valid file-a, invalid file-b
323
+ const cmd = [
324
+ `echo '{"name": "ok"}' > ${path.join(wfDir, "file-a.json")}`,
325
+ `echo '{"count": "not-a-number"}' > ${path.join(wfDir, "file-b.json")}`,
326
+ ].join(" && ");
327
+
328
+ const spec: WorkflowSpec = {
329
+ name: "bv-multi",
330
+ description: "step writes multiple blocks",
331
+ steps: {
332
+ writeBlocks: { command: cmd },
333
+ },
334
+ source: "project",
335
+ filePath: path.join(tmpDir, "test.project.yaml"),
336
+ };
337
+
338
+ const result = await executeWorkflow(spec, {}, {
339
+ ctx: mockCtx(tmpDir),
340
+ pi: mockPi(),
341
+ loadAgent: () => ({ name: "default" }),
342
+ });
343
+
344
+ assert.strictEqual(result.status, "failed");
345
+ assert.strictEqual(result.steps.writeBlocks.status, "failed");
346
+ assert.ok(result.steps.writeBlocks.error?.includes("file-b.json"));
347
+
348
+ fs.rmSync(tmpDir, { recursive: true });
349
+ });
350
+ });