gsd-pi 2.41.0-dev.cac69f9 → 2.42.0-dev.97e9e30

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/dist/resources/extensions/gsd/auto/loop.js +80 -0
  2. package/dist/resources/extensions/gsd/auto/phases.js +2 -2
  3. package/dist/resources/extensions/gsd/auto/session.js +6 -0
  4. package/dist/resources/extensions/gsd/auto-dashboard.js +2 -0
  5. package/dist/resources/extensions/gsd/auto.js +28 -1
  6. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +7 -2
  7. package/dist/resources/extensions/gsd/commands/catalog.js +32 -0
  8. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +146 -0
  9. package/dist/resources/extensions/gsd/context-injector.js +74 -0
  10. package/dist/resources/extensions/gsd/custom-execution-policy.js +47 -0
  11. package/dist/resources/extensions/gsd/custom-verification.js +145 -0
  12. package/dist/resources/extensions/gsd/custom-workflow-engine.js +164 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.js +1 -0
  14. package/dist/resources/extensions/gsd/definition-loader.js +352 -0
  15. package/dist/resources/extensions/gsd/dev-execution-policy.js +24 -0
  16. package/dist/resources/extensions/gsd/dev-workflow-engine.js +82 -0
  17. package/dist/resources/extensions/gsd/engine-resolver.js +40 -0
  18. package/dist/resources/extensions/gsd/engine-types.js +8 -0
  19. package/dist/resources/extensions/gsd/execution-policy.js +8 -0
  20. package/dist/resources/extensions/gsd/graph.js +225 -0
  21. package/dist/resources/extensions/gsd/run-manager.js +134 -0
  22. package/dist/resources/extensions/gsd/workflow-engine.js +7 -0
  23. package/dist/resources/skills/create-workflow/SKILL.md +103 -0
  24. package/dist/resources/skills/create-workflow/references/feature-patterns.md +128 -0
  25. package/dist/resources/skills/create-workflow/references/verification-policies.md +76 -0
  26. package/dist/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
  27. package/dist/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
  28. package/dist/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
  29. package/dist/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
  30. package/dist/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
  31. package/dist/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
  32. package/dist/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
  33. package/dist/web/standalone/.next/BUILD_ID +1 -1
  34. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  35. package/dist/web/standalone/.next/build-manifest.json +2 -2
  36. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  37. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  38. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.html +1 -1
  54. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  61. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  62. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  63. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  64. package/package.json +1 -1
  65. package/packages/pi-coding-agent/package.json +1 -1
  66. package/pkg/package.json +1 -1
  67. package/src/resources/extensions/gsd/auto/loop.ts +91 -0
  68. package/src/resources/extensions/gsd/auto/phases.ts +2 -2
  69. package/src/resources/extensions/gsd/auto/session.ts +6 -0
  70. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  71. package/src/resources/extensions/gsd/auto.ts +31 -1
  72. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +9 -2
  73. package/src/resources/extensions/gsd/commands/catalog.ts +32 -0
  74. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +164 -0
  75. package/src/resources/extensions/gsd/context-injector.ts +100 -0
  76. package/src/resources/extensions/gsd/custom-execution-policy.ts +73 -0
  77. package/src/resources/extensions/gsd/custom-verification.ts +180 -0
  78. package/src/resources/extensions/gsd/custom-workflow-engine.ts +216 -0
  79. package/src/resources/extensions/gsd/dashboard-overlay.ts +1 -0
  80. package/src/resources/extensions/gsd/definition-loader.ts +462 -0
  81. package/src/resources/extensions/gsd/dev-execution-policy.ts +51 -0
  82. package/src/resources/extensions/gsd/dev-workflow-engine.ts +110 -0
  83. package/src/resources/extensions/gsd/engine-resolver.ts +57 -0
  84. package/src/resources/extensions/gsd/engine-types.ts +71 -0
  85. package/src/resources/extensions/gsd/execution-policy.ts +43 -0
  86. package/src/resources/extensions/gsd/graph.ts +312 -0
  87. package/src/resources/extensions/gsd/run-manager.ts +180 -0
  88. package/src/resources/extensions/gsd/tests/bundled-workflow-defs.test.ts +180 -0
  89. package/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts +283 -0
  90. package/src/resources/extensions/gsd/tests/context-injector.test.ts +313 -0
  91. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +540 -0
  92. package/src/resources/extensions/gsd/tests/custom-verification.test.ts +382 -0
  93. package/src/resources/extensions/gsd/tests/custom-workflow-engine.test.ts +339 -0
  94. package/src/resources/extensions/gsd/tests/dashboard-custom-engine.test.ts +87 -0
  95. package/src/resources/extensions/gsd/tests/definition-loader.test.ts +778 -0
  96. package/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts +318 -0
  97. package/src/resources/extensions/gsd/tests/e2e-workflow-pipeline-integration.test.ts +476 -0
  98. package/src/resources/extensions/gsd/tests/engine-interfaces-contract.test.ts +271 -0
  99. package/src/resources/extensions/gsd/tests/graph-operations.test.ts +599 -0
  100. package/src/resources/extensions/gsd/tests/iterate-engine-integration.test.ts +429 -0
  101. package/src/resources/extensions/gsd/tests/run-manager.test.ts +229 -0
  102. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +45 -0
  103. package/src/resources/extensions/gsd/workflow-engine.ts +38 -0
  104. package/src/resources/skills/create-workflow/SKILL.md +103 -0
  105. package/src/resources/skills/create-workflow/references/feature-patterns.md +128 -0
  106. package/src/resources/skills/create-workflow/references/verification-policies.md +76 -0
  107. package/src/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
  108. package/src/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
  109. package/src/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
  110. package/src/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
  111. package/src/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
  112. package/src/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
  113. package/src/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
  114. /package/dist/web/standalone/.next/static/{EnGUNqHeGbE0tuuUkTJVA → PXrI5DoWsm7rwAVnEU2rD}/_buildManifest.js +0 -0
  115. /package/dist/web/standalone/.next/static/{EnGUNqHeGbE0tuuUkTJVA → PXrI5DoWsm7rwAVnEU2rD}/_ssgManifest.js +0 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * graph-operations.test.ts — Comprehensive tests for graph.ts DAG operations.
3
+ *
4
+ * Covers: YAML I/O round-trips, DAG queries (getNextPendingStep),
5
+ * immutable step completion, iteration expansion with downstream dep
6
+ * rewriting, initializeGraph conversion, and atomic write safety.
7
+ */
8
+
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ import {
16
+ readGraph,
17
+ writeGraph,
18
+ getNextPendingStep,
19
+ markStepComplete,
20
+ expandIteration,
21
+ initializeGraph,
22
+ graphFromDefinition,
23
+ type WorkflowGraph,
24
+ type GraphStep,
25
+ } from "../graph.ts";
26
+ import type { WorkflowDefinition } from "../definition-loader.ts";
27
+
28
+ // ─── Helpers ─────────────────────────────────────────────────────────────
29
+
30
+ function makeTmpDir(): string {
31
+ return mkdtempSync(join(tmpdir(), "graph-test-"));
32
+ }
33
+
34
+ function cleanupDir(dir: string): void {
35
+ try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
36
+ }
37
+
38
+ /** Minimal valid graph for testing. */
39
+ function makeGraph(steps: GraphStep[], name = "test-workflow"): WorkflowGraph {
40
+ return {
41
+ steps,
42
+ metadata: { name, createdAt: "2026-01-01T00:00:00.000Z" },
43
+ };
44
+ }
45
+
46
+ function makeStep(overrides: Partial<GraphStep> & { id: string }): GraphStep {
47
+ return {
48
+ title: overrides.id,
49
+ status: "pending",
50
+ prompt: `Do ${overrides.id}`,
51
+ dependsOn: [],
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ // ─── writeGraph + readGraph round-trip ───────────────────────────────────
57
+
58
+ describe("writeGraph + readGraph round-trip", () => {
59
+ it("preserves all fields including parentStepId and dependsOn", () => {
60
+ const dir = makeTmpDir();
61
+ try {
62
+ const graph = makeGraph([
63
+ makeStep({ id: "step-1", title: "First Step", dependsOn: [] }),
64
+ makeStep({
65
+ id: "step-2",
66
+ title: "Second Step",
67
+ dependsOn: ["step-1"],
68
+ parentStepId: "parent-iter",
69
+ }),
70
+ ]);
71
+
72
+ writeGraph(dir, graph);
73
+ const loaded = readGraph(dir);
74
+
75
+ assert.equal(loaded.steps.length, 2);
76
+ assert.equal(loaded.steps[0].id, "step-1");
77
+ assert.equal(loaded.steps[0].title, "First Step");
78
+ assert.equal(loaded.steps[0].status, "pending");
79
+ assert.deepStrictEqual(loaded.steps[0].dependsOn, []);
80
+
81
+ assert.equal(loaded.steps[1].id, "step-2");
82
+ assert.deepStrictEqual(loaded.steps[1].dependsOn, ["step-1"]);
83
+ assert.equal(loaded.steps[1].parentStepId, "parent-iter");
84
+
85
+ assert.equal(loaded.metadata.name, "test-workflow");
86
+ assert.equal(loaded.metadata.createdAt, "2026-01-01T00:00:00.000Z");
87
+ } finally {
88
+ cleanupDir(dir);
89
+ }
90
+ });
91
+
92
+ it("preserves startedAt and finishedAt fields", () => {
93
+ const dir = makeTmpDir();
94
+ try {
95
+ const graph = makeGraph([
96
+ makeStep({
97
+ id: "s1",
98
+ status: "complete",
99
+ startedAt: "2026-01-01T01:00:00.000Z",
100
+ finishedAt: "2026-01-01T01:05:00.000Z",
101
+ }),
102
+ ]);
103
+ writeGraph(dir, graph);
104
+ const loaded = readGraph(dir);
105
+
106
+ assert.equal(loaded.steps[0].startedAt, "2026-01-01T01:00:00.000Z");
107
+ assert.equal(loaded.steps[0].finishedAt, "2026-01-01T01:05:00.000Z");
108
+ } finally {
109
+ cleanupDir(dir);
110
+ }
111
+ });
112
+
113
+ it("creates directory if it does not exist", () => {
114
+ const base = makeTmpDir();
115
+ const nested = join(base, "sub", "dir");
116
+ try {
117
+ const graph = makeGraph([makeStep({ id: "s1" })]);
118
+ writeGraph(nested, graph);
119
+ assert.ok(existsSync(join(nested, "GRAPH.yaml")));
120
+
121
+ const loaded = readGraph(nested);
122
+ assert.equal(loaded.steps[0].id, "s1");
123
+ } finally {
124
+ cleanupDir(base);
125
+ }
126
+ });
127
+ });
128
+
129
+ // ─── readGraph error paths ───────────────────────────────────────────────
130
+
131
+ describe("readGraph error paths", () => {
132
+ it("throws with descriptive error when file is missing", () => {
133
+ const dir = makeTmpDir();
134
+ try {
135
+ assert.throws(
136
+ () => readGraph(dir),
137
+ (err: Error) => {
138
+ assert.ok(err.message.includes("GRAPH.yaml not found"));
139
+ assert.ok(err.message.includes(dir));
140
+ return true;
141
+ },
142
+ );
143
+ } finally {
144
+ cleanupDir(dir);
145
+ }
146
+ });
147
+
148
+ it("throws with descriptive error when YAML is malformed (missing steps)", () => {
149
+ const dir = makeTmpDir();
150
+ try {
151
+ writeFileSync(join(dir, "GRAPH.yaml"), "metadata:\n name: bad\n", "utf-8");
152
+ assert.throws(
153
+ () => readGraph(dir),
154
+ (err: Error) => {
155
+ assert.ok(err.message.includes("missing or invalid 'steps' array"));
156
+ return true;
157
+ },
158
+ );
159
+ } finally {
160
+ cleanupDir(dir);
161
+ }
162
+ });
163
+
164
+ it("throws when steps is not an array", () => {
165
+ const dir = makeTmpDir();
166
+ try {
167
+ writeFileSync(join(dir, "GRAPH.yaml"), "steps: not-an-array\nmetadata:\n name: bad\n", "utf-8");
168
+ assert.throws(
169
+ () => readGraph(dir),
170
+ (err: Error) => {
171
+ assert.ok(err.message.includes("missing or invalid 'steps' array"));
172
+ return true;
173
+ },
174
+ );
175
+ } finally {
176
+ cleanupDir(dir);
177
+ }
178
+ });
179
+ });
180
+
181
+ // ─── getNextPendingStep ──────────────────────────────────────────────────
182
+
183
+ describe("getNextPendingStep", () => {
184
+ it("returns first step with all deps complete", () => {
185
+ const graph = makeGraph([
186
+ makeStep({ id: "a", status: "complete" }),
187
+ makeStep({ id: "b", dependsOn: ["a"] }),
188
+ makeStep({ id: "c", dependsOn: ["b"] }),
189
+ ]);
190
+
191
+ const next = getNextPendingStep(graph);
192
+ assert.equal(next?.id, "b");
193
+ });
194
+
195
+ it("skips steps with incomplete deps", () => {
196
+ const graph = makeGraph([
197
+ makeStep({ id: "a" }),
198
+ makeStep({ id: "b", dependsOn: ["a"] }),
199
+ ]);
200
+
201
+ // 'a' is still pending, so 'b' is blocked, but 'a' has no deps → returns 'a'
202
+ const next = getNextPendingStep(graph);
203
+ assert.equal(next?.id, "a");
204
+ });
205
+
206
+ it("returns null when all steps are complete", () => {
207
+ const graph = makeGraph([
208
+ makeStep({ id: "a", status: "complete" }),
209
+ makeStep({ id: "b", status: "complete" }),
210
+ ]);
211
+
212
+ assert.equal(getNextPendingStep(graph), null);
213
+ });
214
+
215
+ it("returns null when all pending steps are blocked", () => {
216
+ const graph = makeGraph([
217
+ makeStep({ id: "a", status: "active" }), // not complete
218
+ makeStep({ id: "b", dependsOn: ["a"] }), // blocked
219
+ ]);
220
+
221
+ assert.equal(getNextPendingStep(graph), null);
222
+ });
223
+
224
+ it("returns first pending step with no deps when root steps exist", () => {
225
+ const graph = makeGraph([
226
+ makeStep({ id: "a" }),
227
+ makeStep({ id: "b" }),
228
+ ]);
229
+
230
+ const next = getNextPendingStep(graph);
231
+ assert.equal(next?.id, "a");
232
+ });
233
+
234
+ it("skips expanded steps", () => {
235
+ const graph = makeGraph([
236
+ makeStep({ id: "a", status: "expanded" }),
237
+ makeStep({ id: "b" }),
238
+ ]);
239
+
240
+ const next = getNextPendingStep(graph);
241
+ assert.equal(next?.id, "b");
242
+ });
243
+ });
244
+
245
+ // ─── markStepComplete ────────────────────────────────────────────────────
246
+
247
+ describe("markStepComplete", () => {
248
+ it("returns new graph with step status 'complete' (original unchanged)", () => {
249
+ const original = makeGraph([
250
+ makeStep({ id: "a" }),
251
+ makeStep({ id: "b" }),
252
+ ]);
253
+
254
+ const updated = markStepComplete(original, "a");
255
+
256
+ // Original is untouched
257
+ assert.equal(original.steps[0].status, "pending");
258
+
259
+ // New graph has the step complete
260
+ assert.equal(updated.steps[0].status, "complete");
261
+ assert.equal(updated.steps[0].id, "a");
262
+
263
+ // Other steps unchanged
264
+ assert.equal(updated.steps[1].status, "pending");
265
+ });
266
+
267
+ it("sets finishedAt timestamp", () => {
268
+ const graph = makeGraph([makeStep({ id: "a" })]);
269
+ const updated = markStepComplete(graph, "a");
270
+ assert.ok(updated.steps[0].finishedAt);
271
+ // Should be a valid ISO string
272
+ assert.ok(!isNaN(Date.parse(updated.steps[0].finishedAt!)));
273
+ });
274
+
275
+ it("throws for unknown step ID", () => {
276
+ const graph = makeGraph([makeStep({ id: "a" })]);
277
+ assert.throws(
278
+ () => markStepComplete(graph, "nonexistent"),
279
+ (err: Error) => {
280
+ assert.ok(err.message.includes("Step not found"));
281
+ assert.ok(err.message.includes("nonexistent"));
282
+ return true;
283
+ },
284
+ );
285
+ });
286
+
287
+ it("preserves metadata in returned graph", () => {
288
+ const graph = makeGraph([makeStep({ id: "a" })], "my-workflow");
289
+ const updated = markStepComplete(graph, "a");
290
+ assert.equal(updated.metadata.name, "my-workflow");
291
+ assert.equal(updated.metadata.createdAt, "2026-01-01T00:00:00.000Z");
292
+ });
293
+ });
294
+
295
+ // ─── expandIteration ─────────────────────────────────────────────────────
296
+
297
+ describe("expandIteration", () => {
298
+ it("creates instance steps with correct IDs (stepId--001, stepId--002)", () => {
299
+ const graph = makeGraph([
300
+ makeStep({ id: "iter-step", title: "Process items" }),
301
+ makeStep({ id: "final", dependsOn: ["iter-step"] }),
302
+ ]);
303
+
304
+ const expanded = expandIteration(
305
+ graph,
306
+ "iter-step",
307
+ ["apple", "banana", "cherry"],
308
+ "Process {{item}}",
309
+ );
310
+
311
+ // Parent + 3 instances + final = 5 steps
312
+ assert.equal(expanded.steps.length, 5);
313
+
314
+ // Instances are correctly named
315
+ assert.equal(expanded.steps[1].id, "iter-step--001");
316
+ assert.equal(expanded.steps[2].id, "iter-step--002");
317
+ assert.equal(expanded.steps[3].id, "iter-step--003");
318
+ });
319
+
320
+ it("marks parent step as 'expanded'", () => {
321
+ const graph = makeGraph([
322
+ makeStep({ id: "iter", title: "Iterate" }),
323
+ ]);
324
+
325
+ const expanded = expandIteration(graph, "iter", ["a"], "Do {{item}}");
326
+ assert.equal(expanded.steps[0].status, "expanded");
327
+ });
328
+
329
+ it("instance steps have correct titles, prompts, parentStepId, and deps", () => {
330
+ const graph = makeGraph([
331
+ makeStep({ id: "pre", status: "complete" }),
332
+ makeStep({ id: "iter", title: "Process", dependsOn: ["pre"] }),
333
+ ]);
334
+
335
+ const expanded = expandIteration(
336
+ graph,
337
+ "iter",
338
+ ["foo", "bar"],
339
+ "Handle {{item}} carefully",
340
+ );
341
+
342
+ const inst1 = expanded.steps[2]; // after pre and expanded parent
343
+ assert.equal(inst1.title, "Process: foo");
344
+ assert.equal(inst1.prompt, "Handle foo carefully");
345
+ assert.equal(inst1.parentStepId, "iter");
346
+ assert.deepStrictEqual(inst1.dependsOn, ["pre"]);
347
+ assert.equal(inst1.status, "pending");
348
+
349
+ const inst2 = expanded.steps[3];
350
+ assert.equal(inst2.title, "Process: bar");
351
+ assert.equal(inst2.prompt, "Handle bar carefully");
352
+ assert.equal(inst2.parentStepId, "iter");
353
+ });
354
+
355
+ it("rewrites downstream deps from parent ID to all instance IDs", () => {
356
+ const graph = makeGraph([
357
+ makeStep({ id: "iter", title: "Iterate" }),
358
+ makeStep({ id: "after", dependsOn: ["iter"] }),
359
+ ]);
360
+
361
+ const expanded = expandIteration(
362
+ graph,
363
+ "iter",
364
+ ["x", "y"],
365
+ "Do {{item}}",
366
+ );
367
+
368
+ // 'after' should now depend on iter--001 and iter--002
369
+ const afterStep = expanded.steps.find((s) => s.id === "after")!;
370
+ assert.deepStrictEqual(afterStep.dependsOn, ["iter--001", "iter--002"]);
371
+ });
372
+
373
+ it("preserves steps that don't depend on the parent", () => {
374
+ const graph = makeGraph([
375
+ makeStep({ id: "unrelated" }),
376
+ makeStep({ id: "iter", title: "Iterate" }),
377
+ makeStep({ id: "after", dependsOn: ["iter"] }),
378
+ ]);
379
+
380
+ const expanded = expandIteration(graph, "iter", ["a"], "{{item}}");
381
+ const unrelated = expanded.steps.find((s) => s.id === "unrelated")!;
382
+ assert.deepStrictEqual(unrelated.dependsOn, []);
383
+ });
384
+
385
+ it("throws for non-pending parent step", () => {
386
+ const graph = makeGraph([
387
+ makeStep({ id: "iter", status: "complete" }),
388
+ ]);
389
+
390
+ assert.throws(
391
+ () => expandIteration(graph, "iter", ["a"], "{{item}}"),
392
+ (err: Error) => {
393
+ assert.ok(err.message.includes("complete"));
394
+ assert.ok(err.message.includes("expected \"pending\""));
395
+ return true;
396
+ },
397
+ );
398
+ });
399
+
400
+ it("throws for unknown step ID", () => {
401
+ const graph = makeGraph([makeStep({ id: "a" })]);
402
+ assert.throws(
403
+ () => expandIteration(graph, "nonexistent", ["a"], "{{item}}"),
404
+ (err: Error) => {
405
+ assert.ok(err.message.includes("step not found"));
406
+ assert.ok(err.message.includes("nonexistent"));
407
+ return true;
408
+ },
409
+ );
410
+ });
411
+
412
+ it("does not mutate the input graph", () => {
413
+ const graph = makeGraph([
414
+ makeStep({ id: "iter", title: "Iterate" }),
415
+ makeStep({ id: "after", dependsOn: ["iter"] }),
416
+ ]);
417
+
418
+ const originalStepsLength = graph.steps.length;
419
+ const originalAfterDeps = [...graph.steps[1].dependsOn];
420
+
421
+ expandIteration(graph, "iter", ["a", "b"], "{{item}}");
422
+
423
+ // Original unchanged
424
+ assert.equal(graph.steps.length, originalStepsLength);
425
+ assert.equal(graph.steps[0].status, "pending");
426
+ assert.deepStrictEqual(graph.steps[1].dependsOn, originalAfterDeps);
427
+ });
428
+ });
429
+
430
+ // ─── initializeGraph ─────────────────────────────────────────────────────
431
+
432
+ describe("initializeGraph", () => {
433
+ it("converts a valid 3-step definition to graph with all pending steps", () => {
434
+ const def: WorkflowDefinition = {
435
+ version: 1,
436
+ name: "test-workflow",
437
+ steps: [
438
+ { id: "s1", name: "Step One", prompt: "Do step one", requires: [], produces: ["out.md"] },
439
+ { id: "s2", name: "Step Two", prompt: "Do step two", requires: ["s1"], produces: [] },
440
+ { id: "s3", name: "Step Three", prompt: "Do step three", requires: ["s1", "s2"], produces: [] },
441
+ ],
442
+ };
443
+
444
+ const graph = initializeGraph(def);
445
+
446
+ assert.equal(graph.steps.length, 3);
447
+ assert.equal(graph.metadata.name, "test-workflow");
448
+ assert.ok(graph.metadata.createdAt); // ISO string
449
+
450
+ // All pending
451
+ for (const step of graph.steps) {
452
+ assert.equal(step.status, "pending");
453
+ }
454
+
455
+ // Correct mapping
456
+ assert.equal(graph.steps[0].id, "s1");
457
+ assert.equal(graph.steps[0].title, "Step One");
458
+ assert.equal(graph.steps[0].prompt, "Do step one");
459
+ assert.deepStrictEqual(graph.steps[0].dependsOn, []);
460
+
461
+ assert.equal(graph.steps[1].id, "s2");
462
+ assert.deepStrictEqual(graph.steps[1].dependsOn, ["s1"]);
463
+
464
+ assert.equal(graph.steps[2].id, "s3");
465
+ assert.deepStrictEqual(graph.steps[2].dependsOn, ["s1", "s2"]);
466
+ });
467
+
468
+ it("is also exported as graphFromDefinition (backward compat)", () => {
469
+ assert.equal(graphFromDefinition, initializeGraph);
470
+ });
471
+ });
472
+
473
+ // ─── Atomic write safety ─────────────────────────────────────────────────
474
+
475
+ describe("atomic write safety", () => {
476
+ it("final file exists and .tmp file does not exist after write", () => {
477
+ const dir = makeTmpDir();
478
+ try {
479
+ const graph = makeGraph([makeStep({ id: "s1" })]);
480
+ writeGraph(dir, graph);
481
+
482
+ assert.ok(existsSync(join(dir, "GRAPH.yaml")));
483
+ assert.ok(!existsSync(join(dir, "GRAPH.yaml.tmp")));
484
+ } finally {
485
+ cleanupDir(dir);
486
+ }
487
+ });
488
+
489
+ it("YAML content is valid and parseable", () => {
490
+ const dir = makeTmpDir();
491
+ try {
492
+ const graph = makeGraph([makeStep({ id: "s1" })]);
493
+ writeGraph(dir, graph);
494
+
495
+ const content = readFileSync(join(dir, "GRAPH.yaml"), "utf-8");
496
+ // Should contain snake_case keys
497
+ assert.ok(content.includes("created_at"));
498
+ // Should not contain camelCase keys
499
+ assert.ok(!content.includes("createdAt"));
500
+ assert.ok(!content.includes("dependsOn"));
501
+ } finally {
502
+ cleanupDir(dir);
503
+ }
504
+ });
505
+ });
506
+
507
+ // ─── YAML snake_case / camelCase boundary ────────────────────────────────
508
+
509
+ describe("YAML snake_case / camelCase boundary", () => {
510
+ it("writes snake_case to disk and reads back as camelCase", () => {
511
+ const dir = makeTmpDir();
512
+ try {
513
+ const graph = makeGraph([
514
+ makeStep({
515
+ id: "s1",
516
+ dependsOn: ["s0"],
517
+ parentStepId: "parent",
518
+ startedAt: "2026-01-01T00:00:00Z",
519
+ finishedAt: "2026-01-01T00:01:00Z",
520
+ }),
521
+ ]);
522
+
523
+ writeGraph(dir, graph);
524
+
525
+ // Verify raw YAML uses snake_case
526
+ const raw = readFileSync(join(dir, "GRAPH.yaml"), "utf-8");
527
+ assert.ok(raw.includes("depends_on"));
528
+ assert.ok(raw.includes("parent_step_id"));
529
+ assert.ok(raw.includes("started_at"));
530
+ assert.ok(raw.includes("finished_at"));
531
+ assert.ok(raw.includes("created_at"));
532
+
533
+ // Verify read returns camelCase
534
+ const loaded = readGraph(dir);
535
+ assert.deepStrictEqual(loaded.steps[0].dependsOn, ["s0"]);
536
+ assert.equal(loaded.steps[0].parentStepId, "parent");
537
+ assert.equal(loaded.steps[0].startedAt, "2026-01-01T00:00:00Z");
538
+ assert.equal(loaded.steps[0].finishedAt, "2026-01-01T00:01:00Z");
539
+ } finally {
540
+ cleanupDir(dir);
541
+ }
542
+ });
543
+
544
+ it("omits optional fields from YAML when undefined", () => {
545
+ const dir = makeTmpDir();
546
+ try {
547
+ const graph = makeGraph([
548
+ makeStep({ id: "s1" }),
549
+ ]);
550
+
551
+ writeGraph(dir, graph);
552
+ const raw = readFileSync(join(dir, "GRAPH.yaml"), "utf-8");
553
+
554
+ // No depends_on, parent_step_id, started_at, finished_at when undefined/empty
555
+ assert.ok(!raw.includes("depends_on"));
556
+ assert.ok(!raw.includes("parent_step_id"));
557
+ assert.ok(!raw.includes("started_at"));
558
+ assert.ok(!raw.includes("finished_at"));
559
+ } finally {
560
+ cleanupDir(dir);
561
+ }
562
+ });
563
+ });
564
+
565
+ // ─── Edge cases ──────────────────────────────────────────────────────────
566
+
567
+ describe("edge cases", () => {
568
+ it("handles empty items array in expandIteration", () => {
569
+ const graph = makeGraph([
570
+ makeStep({ id: "iter" }),
571
+ ]);
572
+
573
+ const expanded = expandIteration(graph, "iter", [], "{{item}}");
574
+ // Parent marked expanded, no instances created
575
+ assert.equal(expanded.steps.length, 1);
576
+ assert.equal(expanded.steps[0].status, "expanded");
577
+ });
578
+
579
+ it("handles graph with single step", () => {
580
+ const graph = makeGraph([makeStep({ id: "only" })]);
581
+ const next = getNextPendingStep(graph);
582
+ assert.equal(next?.id, "only");
583
+
584
+ const completed = markStepComplete(graph, "only");
585
+ assert.equal(getNextPendingStep(completed), null);
586
+ });
587
+
588
+ it("initializeGraph handles steps with empty requires", () => {
589
+ const def: WorkflowDefinition = {
590
+ version: 1,
591
+ name: "empty-requires",
592
+ steps: [
593
+ { id: "s1", name: "Step", prompt: "Go", requires: [], produces: [] },
594
+ ],
595
+ };
596
+ const graph = initializeGraph(def);
597
+ assert.deepStrictEqual(graph.steps[0].dependsOn, []);
598
+ });
599
+ });