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,216 @@
1
+ /**
2
+ * custom-workflow-engine.ts — WorkflowEngine implementation for custom workflows.
3
+ *
4
+ * Drives the auto-loop using GRAPH.yaml step state from a run directory.
5
+ * Each iteration: deriveState reads the graph, resolveDispatch picks the
6
+ * next eligible step, reconcile marks it complete and persists.
7
+ *
8
+ * Observability:
9
+ * - All state reads/writes go through graph.ts YAML I/O — inspectable on disk.
10
+ * - `resolveDispatch` returns unitType "custom-step" with unitId "<name>/<stepId>".
11
+ * - `getDisplayMetadata` provides step N/M progress for dashboard rendering.
12
+ * - Phase transitions are derivable from GRAPH.yaml step statuses.
13
+ */
14
+
15
+ import type { WorkflowEngine } from "./workflow-engine.js";
16
+ import type {
17
+ EngineState,
18
+ EngineDispatchAction,
19
+ CompletedStep,
20
+ ReconcileResult,
21
+ DisplayMetadata,
22
+ } from "./engine-types.js";
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { parse } from "yaml";
26
+ import {
27
+ readGraph,
28
+ writeGraph,
29
+ getNextPendingStep,
30
+ markStepComplete,
31
+ expandIteration,
32
+ type WorkflowGraph,
33
+ } from "./graph.js";
34
+ import { injectContext } from "./context-injector.js";
35
+ import type { WorkflowDefinition, StepDefinition } from "./definition-loader.js";
36
+
37
+ /** Read and parse the frozen DEFINITION.yaml from a run directory. */
38
+ export function readFrozenDefinition(runDir: string): WorkflowDefinition {
39
+ const defPath = join(runDir, "DEFINITION.yaml");
40
+ const raw = readFileSync(defPath, "utf-8");
41
+ return parse(raw, { schema: "core" }) as WorkflowDefinition;
42
+ }
43
+
44
+ export class CustomWorkflowEngine implements WorkflowEngine {
45
+ readonly engineId = "custom";
46
+ private readonly runDir: string;
47
+
48
+ constructor(runDir: string) {
49
+ this.runDir = runDir;
50
+ }
51
+
52
+ /**
53
+ * Derive engine state from GRAPH.yaml on disk.
54
+ *
55
+ * Phase is "complete" when all steps are complete or expanded,
56
+ * "running" otherwise (any pending or active steps remain).
57
+ */
58
+ async deriveState(_basePath: string): Promise<EngineState> {
59
+ const graph = readGraph(this.runDir);
60
+ const allDone = graph.steps.every(
61
+ (s) => s.status === "complete" || s.status === "expanded",
62
+ );
63
+ const phase = allDone ? "complete" : "running";
64
+
65
+ return {
66
+ phase,
67
+ currentMilestoneId: null,
68
+ activeSliceId: null,
69
+ activeTaskId: null,
70
+ isComplete: allDone,
71
+ raw: graph,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Resolve the next dispatch action from graph state.
77
+ *
78
+ * Uses getNextPendingStep to find the first step whose dependencies
79
+ * are all satisfied. If the step has an `iterate` config in the frozen
80
+ * DEFINITION.yaml, expands it into instance steps before dispatching.
81
+ *
82
+ * Returns a dispatch with unitType "custom-step" and unitId in
83
+ * "<workflowName>/<stepId>" format.
84
+ *
85
+ * Observability:
86
+ * - Iterate expansion is logged to stderr with item count and parent step ID.
87
+ * - Missing source artifacts throw with the full resolved path for diagnosis.
88
+ * - Zero-match expansions return a stop action with level "info".
89
+ * - Expanded GRAPH.yaml is written to disk before dispatch — inspectable on disk.
90
+ */
91
+ async resolveDispatch(
92
+ state: EngineState,
93
+ _context: { basePath: string },
94
+ ): Promise<EngineDispatchAction> {
95
+ let graph = state.raw as WorkflowGraph;
96
+ let next = getNextPendingStep(graph);
97
+
98
+ if (!next) {
99
+ return {
100
+ action: "stop",
101
+ reason: "All steps complete",
102
+ level: "info",
103
+ };
104
+ }
105
+
106
+ // Check frozen DEFINITION.yaml for iterate config on this step
107
+ const def = readFrozenDefinition(this.runDir);
108
+ const stepDef = def.steps.find((s: StepDefinition) => s.id === next!.id);
109
+
110
+ if (stepDef?.iterate) {
111
+ const iterate = stepDef.iterate;
112
+
113
+ // Read source artifact
114
+ const sourcePath = join(this.runDir, iterate.source);
115
+ let sourceContent: string;
116
+ try {
117
+ sourceContent = readFileSync(sourcePath, "utf-8");
118
+ } catch {
119
+ throw new Error(
120
+ `Iterate source artifact not found: ${sourcePath} (step "${next.id}", source: "${iterate.source}")`,
121
+ );
122
+ }
123
+
124
+ // Extract items via regex with global+multiline flags.
125
+ // Guard against ReDoS: if matching takes too long on large inputs, bail.
126
+ const regex = new RegExp(iterate.pattern, "gm");
127
+ const items: string[] = [];
128
+ const matchStart = Date.now();
129
+ let match: RegExpExecArray | null;
130
+ while ((match = regex.exec(sourceContent)) !== null) {
131
+ if (match[1] !== undefined) items.push(match[1]);
132
+ if (Date.now() - matchStart > 5_000) {
133
+ throw new Error(
134
+ `Iterate pattern "${iterate.pattern}" exceeded 5s timeout on step "${next.id}" — possible ReDoS`,
135
+ );
136
+ }
137
+ }
138
+
139
+ // Expand the graph
140
+ const expandedGraph = expandIteration(graph, next.id, items, next.prompt);
141
+ writeGraph(this.runDir, expandedGraph);
142
+ graph = expandedGraph;
143
+
144
+ // Re-query for first instance step
145
+ next = getNextPendingStep(expandedGraph);
146
+
147
+ if (!next) {
148
+ return {
149
+ action: "stop",
150
+ reason: "Iterate expansion produced no instances",
151
+ level: "info",
152
+ };
153
+ }
154
+ }
155
+
156
+ // Enrich prompt with context from prior step artifacts
157
+ const enrichedPrompt = injectContext(this.runDir, next.id, next.prompt);
158
+
159
+ return {
160
+ action: "dispatch",
161
+ step: {
162
+ unitType: "custom-step",
163
+ unitId: `${graph.metadata.name}/${next.id}`,
164
+ prompt: enrichedPrompt,
165
+ },
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Reconcile state after a step completes.
171
+ *
172
+ * Extracts the stepId from the completedStep's unitId (last segment after `/`),
173
+ * marks it complete in the graph, and writes the updated GRAPH.yaml to disk.
174
+ *
175
+ * Returns "milestone-complete" when all steps are now done, "continue" otherwise.
176
+ */
177
+ async reconcile(
178
+ state: EngineState,
179
+ completedStep: CompletedStep,
180
+ ): Promise<ReconcileResult> {
181
+ const graph = state.raw as WorkflowGraph;
182
+
183
+ // Extract stepId from "<workflowName>/<stepId>"
184
+ const parts = completedStep.unitId.split("/");
185
+ const stepId = parts[parts.length - 1];
186
+
187
+ const updatedGraph = markStepComplete(graph, stepId);
188
+ writeGraph(this.runDir, updatedGraph);
189
+
190
+ const allDone = updatedGraph.steps.every(
191
+ (s) => s.status === "complete" || s.status === "expanded",
192
+ );
193
+
194
+ return {
195
+ outcome: allDone ? "milestone-complete" : "continue",
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Return UI-facing metadata for progress display.
201
+ *
202
+ * Shows "Step N/M" progress where N = completed count and M = total.
203
+ */
204
+ getDisplayMetadata(state: EngineState): DisplayMetadata {
205
+ const graph = state.raw as WorkflowGraph;
206
+ const total = graph.steps.length;
207
+ const completed = graph.steps.filter((s) => s.status === "complete").length;
208
+
209
+ return {
210
+ engineLabel: "WORKFLOW",
211
+ currentPhase: state.phase,
212
+ progressSummary: `Step ${completed}/${total}`,
213
+ stepCount: { completed, total },
214
+ };
215
+ }
216
+ }
@@ -38,6 +38,7 @@ function unitLabel(type: string): string {
38
38
  case "triage-captures": return "Triage";
39
39
  case "quick-task": return "Quick Task";
40
40
  case "replan-slice": return "Replan";
41
+ case "custom-step": return "Workflow Step";
41
42
  default: return type;
42
43
  }
43
44
  }
@@ -0,0 +1,462 @@
1
+ /**
2
+ * definition-loader.ts — Parse and validate V1 YAML workflow definitions.
3
+ *
4
+ * Loads definition YAML files from `.gsd/workflow-defs/`, validates the
5
+ * V1 schema shape, and returns typed TypeScript objects. Pure functions
6
+ * with no engine or runtime dependencies — just `yaml` and `node:fs`.
7
+ *
8
+ * YAML uses snake_case (`depends_on`, `context_from`) per project convention (P005).
9
+ * TypeScript uses camelCase (`dependsOn`, `contextFrom`).
10
+ *
11
+ * Observability: All validation errors are collected into a string[] — callers
12
+ * can log, surface in dashboards, or return to agents for self-repair.
13
+ * substituteParams errors include the offending key name for traceability.
14
+ */
15
+
16
+ import { parse } from "yaml";
17
+ import { readFileSync, existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ // ─── Public TypeScript Types (camelCase) ─────────────────────────────────
21
+
22
+ export type VerifyPolicy =
23
+ | { policy: "content-heuristic"; minSize?: number; pattern?: string }
24
+ | { policy: "shell-command"; command: string }
25
+ | { policy: "prompt-verify"; prompt: string }
26
+ | { policy: "human-review" };
27
+
28
+ export interface IterateConfig {
29
+ /** Artifact path (relative to run dir) to read and match against. */
30
+ source: string;
31
+ /** Regex pattern string. Must contain at least one capture group. Applied with global flag. */
32
+ pattern: string;
33
+ }
34
+
35
+ export interface StepDefinition {
36
+ /** Unique step identifier within the workflow. */
37
+ id: string;
38
+ /** Human-readable step name. */
39
+ name: string;
40
+ /** The prompt to dispatch for this step. */
41
+ prompt: string;
42
+ /** IDs of steps that must complete before this step can run. */
43
+ requires: string[];
44
+ /** Artifact paths produced by this step (relative to run dir). */
45
+ produces: string[];
46
+ /** Step IDs whose artifacts to include as context (S05 — accepted, not processed). */
47
+ contextFrom?: string[];
48
+ /** Verification policy for this step (S05 — typed + validated). */
49
+ verify?: VerifyPolicy;
50
+ /** Iteration config for this step (S06 — typed + validated). */
51
+ iterate?: IterateConfig;
52
+ }
53
+
54
+ export interface WorkflowDefinition {
55
+ /** Schema version — must be 1. */
56
+ version: number;
57
+ /** Workflow name. */
58
+ name: string;
59
+ /** Optional description. */
60
+ description?: string;
61
+ /** Optional parameter map for template substitution (S07). */
62
+ params?: Record<string, string>;
63
+ /** Ordered list of steps. */
64
+ steps: StepDefinition[];
65
+ }
66
+
67
+ // ─── Internal YAML Types (snake_case) ────────────────────────────────────
68
+
69
+ interface YamlStepDef {
70
+ id?: unknown;
71
+ name?: unknown;
72
+ prompt?: unknown;
73
+ requires?: unknown;
74
+ depends_on?: unknown;
75
+ produces?: unknown;
76
+ context_from?: unknown;
77
+ verify?: unknown;
78
+ iterate?: unknown;
79
+ [key: string]: unknown; // Forward-compat: unknown fields accepted silently
80
+ }
81
+
82
+ interface YamlWorkflowDef {
83
+ version?: unknown;
84
+ name?: unknown;
85
+ description?: unknown;
86
+ params?: unknown;
87
+ steps?: unknown;
88
+ [key: string]: unknown; // Forward-compat: unknown fields accepted silently
89
+ }
90
+
91
+ // ─── Validation ──────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Validate a parsed (but untyped) YAML object against the V1 workflow schema.
95
+ *
96
+ * Collects all errors (does not short-circuit) so a single call reveals
97
+ * every problem with the definition.
98
+ *
99
+ * Unknown fields are silently accepted for forward compatibility with
100
+ * S05/S06 features (`context_from`, `verify`, `iterate`).
101
+ */
102
+ export function validateDefinition(parsed: unknown): { valid: boolean; errors: string[] } {
103
+ const errors: string[] = [];
104
+
105
+ if (parsed == null || typeof parsed !== "object") {
106
+ return { valid: false, errors: ["Definition must be a non-null object"] };
107
+ }
108
+
109
+ const def = parsed as YamlWorkflowDef;
110
+
111
+ // version: must be 1 (number)
112
+ if (def.version === undefined || def.version === null) {
113
+ errors.push("Missing required field: version");
114
+ } else if (def.version !== 1) {
115
+ errors.push(`Unsupported version: ${def.version} (expected 1)`);
116
+ }
117
+
118
+ // name: must be a non-empty string
119
+ if (typeof def.name !== "string" || def.name.trim() === "") {
120
+ errors.push("Missing or empty required field: name");
121
+ }
122
+
123
+ // steps: must be a non-empty array
124
+ if (!Array.isArray(def.steps)) {
125
+ errors.push("Missing required field: steps (must be an array)");
126
+ } else if (def.steps.length === 0) {
127
+ errors.push("steps must contain at least one step");
128
+ } else {
129
+ // Track whether all steps have valid IDs — graph-level checks only run when true
130
+ let allStepIdsValid = true;
131
+
132
+ for (let i = 0; i < def.steps.length; i++) {
133
+ const step = def.steps[i] as YamlStepDef;
134
+ if (step == null || typeof step !== "object") {
135
+ errors.push(`Step at index ${i} is not an object`);
136
+ allStepIdsValid = false;
137
+ continue;
138
+ }
139
+
140
+ // Required step fields
141
+ if (typeof step.id !== "string" || step.id.trim() === "") {
142
+ errors.push(`Step at index ${i} missing required field: id`);
143
+ allStepIdsValid = false;
144
+ }
145
+ if (typeof step.name !== "string" || step.name.trim() === "") {
146
+ errors.push(`Step at index ${i} missing required field: name`);
147
+ }
148
+ if (typeof step.prompt !== "string" || step.prompt.trim() === "") {
149
+ errors.push(`Step at index ${i} missing required field: prompt`);
150
+ }
151
+
152
+ // produces: path traversal guard
153
+ if (Array.isArray(step.produces)) {
154
+ for (const p of step.produces) {
155
+ if (typeof p === "string" && p.includes("..")) {
156
+ errors.push(`Step "${step.id}" produces path contains disallowed '..': ${p}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ // iterate: optional, but if present must conform to IterateConfig shape
162
+ if (step.iterate !== undefined) {
163
+ const it = step.iterate;
164
+ const sid = typeof step.id === "string" ? step.id : `index ${i}`;
165
+ if (it == null || typeof it !== "object" || Array.isArray(it)) {
166
+ errors.push(`Step "${sid}" iterate must be an object with "source" and "pattern" fields`);
167
+ } else {
168
+ const itObj = it as Record<string, unknown>;
169
+ if (typeof itObj.source !== "string" || (itObj.source as string).trim() === "") {
170
+ errors.push(`Step "${sid}" iterate.source must be a non-empty string`);
171
+ } else if ((itObj.source as string).includes("..")) {
172
+ errors.push(`Step "${sid}" iterate.source contains disallowed '..' path traversal`);
173
+ }
174
+ if (typeof itObj.pattern !== "string" || (itObj.pattern as string).trim() === "") {
175
+ errors.push(`Step "${sid}" iterate.pattern must be a non-empty string`);
176
+ } else {
177
+ const pat = itObj.pattern as string;
178
+ let regexValid = true;
179
+ try {
180
+ new RegExp(pat);
181
+ } catch {
182
+ regexValid = false;
183
+ errors.push(`Step "${sid}" iterate.pattern is not a valid regex: ${pat}`);
184
+ }
185
+ if (regexValid && !/\((?!\?)/.test(pat)) {
186
+ errors.push(`Step "${sid}" iterate.pattern must contain at least one capture group`);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ // verify: optional, but if present must conform to VerifyPolicy shape
193
+ if (step.verify !== undefined) {
194
+ const v = step.verify;
195
+ const sid = typeof step.id === "string" ? step.id : `index ${i}`;
196
+ if (v == null || typeof v !== "object" || Array.isArray(v)) {
197
+ errors.push(`Step "${sid}" verify must be an object with a "policy" field`);
198
+ } else {
199
+ const vObj = v as Record<string, unknown>;
200
+ const VALID_POLICIES = ["content-heuristic", "shell-command", "prompt-verify", "human-review"];
201
+ if (typeof vObj.policy !== "string" || !VALID_POLICIES.includes(vObj.policy)) {
202
+ errors.push(`Step "${sid}" verify.policy must be one of: ${VALID_POLICIES.join(", ")}`);
203
+ } else {
204
+ // Policy-specific required field checks
205
+ if (vObj.policy === "shell-command") {
206
+ if (typeof vObj.command !== "string" || (vObj.command as string).trim() === "") {
207
+ errors.push(`Step "${sid}" verify policy "shell-command" requires a non-empty "command" field`);
208
+ }
209
+ }
210
+ if (vObj.policy === "prompt-verify") {
211
+ if (typeof vObj.prompt !== "string" || (vObj.prompt as string).trim() === "") {
212
+ errors.push(`Step "${sid}" verify policy "prompt-verify" requires a non-empty "prompt" field`);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // ─── Graph-level validations (only when all step IDs are valid) ────
221
+ if (allStepIdsValid) {
222
+ const steps = def.steps as YamlStepDef[];
223
+
224
+ // 1. Duplicate step ID check
225
+ const idCounts = new Map<string, number>();
226
+ for (const step of steps) {
227
+ const id = step.id as string;
228
+ idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
229
+ }
230
+ for (const [id, count] of idCounts) {
231
+ if (count > 1) {
232
+ errors.push(`Duplicate step id: ${id}`);
233
+ }
234
+ }
235
+
236
+ // Build valid ID set for remaining checks
237
+ const validIds = new Set(steps.map((s) => s.id as string));
238
+
239
+ // 2. Dangling dependency check + 3. Self-referencing dependency check
240
+ for (const step of steps) {
241
+ const sid = step.id as string;
242
+ const deps = Array.isArray(step.requires)
243
+ ? (step.requires as string[])
244
+ : Array.isArray(step.depends_on)
245
+ ? (step.depends_on as string[])
246
+ : [];
247
+
248
+ for (const depId of deps) {
249
+ if (depId === sid) {
250
+ errors.push(`Step '${sid}' depends on itself`);
251
+ } else if (!validIds.has(depId)) {
252
+ errors.push(`Step '${sid}' requires unknown step '${depId}'`);
253
+ }
254
+ }
255
+ }
256
+
257
+ // 4. Cycle detection (DFS) — only when no duplicate IDs
258
+ if (![...idCounts.values()].some((c: number) => c > 1)) {
259
+ // Build adjacency list: step → its dependencies
260
+ const adj = new Map<string, string[]>();
261
+ for (const step of steps) {
262
+ const sid = step.id as string;
263
+ const deps = Array.isArray(step.requires)
264
+ ? (step.requires as string[])
265
+ : Array.isArray(step.depends_on)
266
+ ? (step.depends_on as string[])
267
+ : [];
268
+ adj.set(sid, deps.filter((d) => validIds.has(d) && d !== sid));
269
+ }
270
+
271
+ const WHITE = 0, GRAY = 1, BLACK = 2;
272
+ const color = new Map<string, number>();
273
+ for (const id of validIds) color.set(id, WHITE);
274
+
275
+ const parent = new Map<string, string | null>();
276
+
277
+ function dfs(node: string): string[] | null {
278
+ color.set(node, GRAY);
279
+ for (const dep of adj.get(node) ?? []) {
280
+ if (color.get(dep) === GRAY) {
281
+ // Back edge found — reconstruct cycle path
282
+ const cycle: string[] = [dep, node];
283
+ let cur = node;
284
+ while (parent.has(cur) && parent.get(cur) !== null && parent.get(cur) !== dep) {
285
+ cur = parent.get(cur)!;
286
+ cycle.push(cur);
287
+ }
288
+ cycle.push(dep);
289
+ cycle.reverse();
290
+ return cycle;
291
+ }
292
+ if (color.get(dep) === WHITE) {
293
+ parent.set(dep, node);
294
+ const result = dfs(dep);
295
+ if (result) return result;
296
+ }
297
+ }
298
+ color.set(node, BLACK);
299
+ return null;
300
+ }
301
+
302
+ for (const id of validIds) {
303
+ if (color.get(id) === WHITE) {
304
+ parent.set(id, null);
305
+ const cycle = dfs(id);
306
+ if (cycle) {
307
+ errors.push(`Cycle detected: ${cycle.join(" → ")}`);
308
+ break; // One cycle error is enough
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ return { valid: errors.length === 0, errors };
317
+ }
318
+
319
+ // ─── Loading ─────────────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Load and validate a YAML workflow definition from the filesystem.
323
+ *
324
+ * Reads `<defsDir>/<name>.yaml`, parses YAML, validates the V1 schema,
325
+ * and converts snake_case YAML keys to camelCase TypeScript types.
326
+ *
327
+ * @param defsDir — directory containing definition YAML files
328
+ * @param name — definition filename without extension
329
+ * @returns Parsed and validated WorkflowDefinition
330
+ * @throws Error if file is missing, YAML is malformed, or schema is invalid
331
+ */
332
+ export function loadDefinition(defsDir: string, name: string): WorkflowDefinition {
333
+ const filePath = join(defsDir, `${name}.yaml`);
334
+
335
+ if (!existsSync(filePath)) {
336
+ throw new Error(`Definition file not found: ${filePath}`);
337
+ }
338
+
339
+ const raw = readFileSync(filePath, "utf-8");
340
+ let parsed: unknown;
341
+ try {
342
+ parsed = parse(raw);
343
+ } catch (e) {
344
+ const msg = e instanceof Error ? e.message : String(e);
345
+ throw new Error(`Failed to parse YAML in ${filePath}: ${msg}`);
346
+ }
347
+
348
+ const { valid, errors } = validateDefinition(parsed);
349
+ if (!valid) {
350
+ throw new Error(`Invalid workflow definition in ${filePath}:\n - ${errors.join("\n - ")}`);
351
+ }
352
+
353
+ // Convert snake_case YAML → camelCase TypeScript
354
+ const yamlDef = parsed as YamlWorkflowDef;
355
+ const yamlSteps = yamlDef.steps as YamlStepDef[];
356
+
357
+ return {
358
+ version: yamlDef.version as number,
359
+ name: yamlDef.name as string,
360
+ description: typeof yamlDef.description === "string" ? yamlDef.description : undefined,
361
+ params: yamlDef.params != null && typeof yamlDef.params === "object"
362
+ ? Object.fromEntries(
363
+ Object.entries(yamlDef.params as Record<string, unknown>).map(
364
+ ([k, v]) => [k, String(v)],
365
+ ),
366
+ )
367
+ : undefined,
368
+ steps: yamlSteps.map((s) => ({
369
+ id: s.id as string,
370
+ name: s.name as string,
371
+ prompt: s.prompt as string,
372
+ requires: Array.isArray(s.requires)
373
+ ? (s.requires as string[])
374
+ : Array.isArray(s.depends_on)
375
+ ? (s.depends_on as string[])
376
+ : [],
377
+ produces: Array.isArray(s.produces) ? (s.produces as string[]) : [],
378
+ contextFrom: Array.isArray(s.context_from) ? (s.context_from as string[]) : undefined,
379
+ verify: s.verify as VerifyPolicy | undefined,
380
+ iterate: (s.iterate != null && typeof s.iterate === "object")
381
+ ? s.iterate as IterateConfig
382
+ : undefined,
383
+ })),
384
+ };
385
+ }
386
+
387
+ // ─── Parameter Substitution ──────────────────────────────────────────────
388
+
389
+ /** Regex matching `{{key}}` placeholders — captures the key name. */
390
+ const PARAM_PATTERN = /\{\{(\w+)\}\}/g;
391
+
392
+ /**
393
+ * Replace `{{key}}` placeholders in a single prompt string.
394
+ *
395
+ * Exported for use by the engine on iteration-instance prompts that live
396
+ * in GRAPH.yaml (outside the definition's step list).
397
+ *
398
+ * @throws Error if any merged param value contains `..` (path-traversal guard)
399
+ */
400
+ export function substitutePromptString(
401
+ prompt: string,
402
+ merged: Record<string, string>,
403
+ ): string {
404
+ return prompt.replace(PARAM_PATTERN, (match, key: string) => {
405
+ const value = merged[key];
406
+ return value !== undefined ? value : match;
407
+ });
408
+ }
409
+
410
+ /**
411
+ * Replace `{{key}}` placeholders in all step prompts with param values.
412
+ *
413
+ * Merge order: `definition.params` (defaults) ← `overrides` (CLI wins).
414
+ * Returns a **new** WorkflowDefinition — the input is never mutated.
415
+ *
416
+ * @throws Error if any param value contains `..` (path-traversal guard)
417
+ * @throws Error if any `{{key}}` remains unresolved after substitution
418
+ */
419
+ export function substituteParams(
420
+ definition: WorkflowDefinition,
421
+ overrides?: Record<string, string>,
422
+ ): WorkflowDefinition {
423
+ const merged: Record<string, string> = {
424
+ ...(definition.params ?? {}),
425
+ ...(overrides ?? {}),
426
+ };
427
+
428
+ // Path-traversal guard: reject any value containing ".."
429
+ for (const [key, value] of Object.entries(merged)) {
430
+ if (value.includes("..")) {
431
+ throw new Error(
432
+ `Parameter "${key}" contains disallowed '..' (path traversal): ${value}`,
433
+ );
434
+ }
435
+ }
436
+
437
+ // Substitute in each step prompt
438
+ const substitutedSteps = definition.steps.map((step) => ({
439
+ ...step,
440
+ prompt: substitutePromptString(step.prompt, merged),
441
+ }));
442
+
443
+ // Check for unresolved placeholders
444
+ const unresolved = new Set<string>();
445
+ for (const step of substitutedSteps) {
446
+ let m: RegExpExecArray | null;
447
+ const re = new RegExp(PARAM_PATTERN.source, "g");
448
+ while ((m = re.exec(step.prompt)) !== null) {
449
+ unresolved.add(m[1]);
450
+ }
451
+ }
452
+
453
+ if (unresolved.size > 0) {
454
+ const keys = [...unresolved].sort().join(", ");
455
+ throw new Error(`Unresolved parameter(s) in step prompts: ${keys}`);
456
+ }
457
+
458
+ return {
459
+ ...definition,
460
+ steps: substitutedSteps,
461
+ };
462
+ }