gsd-pi 2.41.0-dev.3557dc4 → 2.41.0-dev.5a170d0

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 (123) hide show
  1. package/README.md +1 -1
  2. package/dist/resources/extensions/gsd/auto/loop.js +80 -0
  3. package/dist/resources/extensions/gsd/auto/phases.js +2 -2
  4. package/dist/resources/extensions/gsd/auto/session.js +6 -0
  5. package/dist/resources/extensions/gsd/auto-dashboard.js +2 -0
  6. package/dist/resources/extensions/gsd/auto.js +28 -1
  7. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +7 -2
  8. package/dist/resources/extensions/gsd/commands/catalog.js +32 -0
  9. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +146 -0
  10. package/dist/resources/extensions/gsd/context-injector.js +74 -0
  11. package/dist/resources/extensions/gsd/custom-execution-policy.js +47 -0
  12. package/dist/resources/extensions/gsd/custom-verification.js +145 -0
  13. package/dist/resources/extensions/gsd/custom-workflow-engine.js +164 -0
  14. package/dist/resources/extensions/gsd/dashboard-overlay.js +1 -0
  15. package/dist/resources/extensions/gsd/definition-loader.js +352 -0
  16. package/dist/resources/extensions/gsd/dev-execution-policy.js +24 -0
  17. package/dist/resources/extensions/gsd/dev-workflow-engine.js +82 -0
  18. package/dist/resources/extensions/gsd/engine-resolver.js +40 -0
  19. package/dist/resources/extensions/gsd/engine-types.js +8 -0
  20. package/dist/resources/extensions/gsd/execution-policy.js +8 -0
  21. package/dist/resources/extensions/gsd/graph.js +225 -0
  22. package/dist/resources/extensions/gsd/run-manager.js +134 -0
  23. package/dist/resources/extensions/gsd/workflow-engine.js +7 -0
  24. package/dist/resources/skills/create-workflow/SKILL.md +103 -0
  25. package/dist/resources/skills/create-workflow/references/feature-patterns.md +128 -0
  26. package/dist/resources/skills/create-workflow/references/verification-policies.md +76 -0
  27. package/dist/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
  28. package/dist/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
  29. package/dist/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
  30. package/dist/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
  31. package/dist/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
  32. package/dist/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
  33. package/dist/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
  34. package/dist/web/standalone/.next/BUILD_ID +1 -1
  35. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  36. package/dist/web/standalone/.next/build-manifest.json +2 -2
  37. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  38. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  39. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.html +1 -1
  55. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  62. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  63. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  64. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  65. package/package.json +1 -1
  66. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  67. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +4 -0
  68. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  69. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +5 -0
  70. package/src/resources/extensions/gsd/auto/loop.ts +91 -0
  71. package/src/resources/extensions/gsd/auto/phases.ts +2 -2
  72. package/src/resources/extensions/gsd/auto/session.ts +6 -0
  73. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  74. package/src/resources/extensions/gsd/auto.ts +31 -1
  75. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +9 -2
  76. package/src/resources/extensions/gsd/commands/catalog.ts +32 -0
  77. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +164 -0
  78. package/src/resources/extensions/gsd/context-injector.ts +100 -0
  79. package/src/resources/extensions/gsd/custom-execution-policy.ts +73 -0
  80. package/src/resources/extensions/gsd/custom-verification.ts +180 -0
  81. package/src/resources/extensions/gsd/custom-workflow-engine.ts +216 -0
  82. package/src/resources/extensions/gsd/dashboard-overlay.ts +1 -0
  83. package/src/resources/extensions/gsd/definition-loader.ts +462 -0
  84. package/src/resources/extensions/gsd/dev-execution-policy.ts +51 -0
  85. package/src/resources/extensions/gsd/dev-workflow-engine.ts +110 -0
  86. package/src/resources/extensions/gsd/engine-resolver.ts +57 -0
  87. package/src/resources/extensions/gsd/engine-types.ts +71 -0
  88. package/src/resources/extensions/gsd/execution-policy.ts +43 -0
  89. package/src/resources/extensions/gsd/graph.ts +312 -0
  90. package/src/resources/extensions/gsd/run-manager.ts +180 -0
  91. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +100 -118
  92. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +2 -2
  93. package/src/resources/extensions/gsd/tests/bundled-workflow-defs.test.ts +180 -0
  94. package/src/resources/extensions/gsd/tests/captures.test.ts +12 -1
  95. package/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts +283 -0
  96. package/src/resources/extensions/gsd/tests/context-injector.test.ts +313 -0
  97. package/src/resources/extensions/gsd/tests/continue-here.test.ts +20 -20
  98. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +540 -0
  99. package/src/resources/extensions/gsd/tests/custom-verification.test.ts +382 -0
  100. package/src/resources/extensions/gsd/tests/custom-workflow-engine.test.ts +339 -0
  101. package/src/resources/extensions/gsd/tests/dashboard-custom-engine.test.ts +87 -0
  102. package/src/resources/extensions/gsd/tests/definition-loader.test.ts +778 -0
  103. package/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts +318 -0
  104. package/src/resources/extensions/gsd/tests/e2e-workflow-pipeline-integration.test.ts +476 -0
  105. package/src/resources/extensions/gsd/tests/engine-interfaces-contract.test.ts +271 -0
  106. package/src/resources/extensions/gsd/tests/graph-operations.test.ts +599 -0
  107. package/src/resources/extensions/gsd/tests/iterate-engine-integration.test.ts +429 -0
  108. package/src/resources/extensions/gsd/tests/run-manager.test.ts +229 -0
  109. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +45 -0
  110. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +195 -105
  111. package/src/resources/extensions/gsd/workflow-engine.ts +38 -0
  112. package/src/resources/skills/create-workflow/SKILL.md +103 -0
  113. package/src/resources/skills/create-workflow/references/feature-patterns.md +128 -0
  114. package/src/resources/skills/create-workflow/references/verification-policies.md +76 -0
  115. package/src/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
  116. package/src/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
  117. package/src/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
  118. package/src/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
  119. package/src/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
  120. package/src/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
  121. package/src/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
  122. /package/dist/web/standalone/.next/static/{JBSIr4fSfHXs5g5x2ZBSC → K7GYOOPvQWX6TKYEKhODM}/_buildManifest.js +0 -0
  123. /package/dist/web/standalone/.next/static/{JBSIr4fSfHXs5g5x2ZBSC → K7GYOOPvQWX6TKYEKhODM}/_ssgManifest.js +0 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * custom-verification.ts — Step verification for custom workflows.
3
+ *
4
+ * Reads the frozen DEFINITION.yaml from a run directory, finds the step's
5
+ * `verify` policy, and dispatches to the appropriate handler. Four policies:
6
+ *
7
+ * - content-heuristic: file existence + optional minSize + optional pattern match
8
+ * - shell-command: spawnSync with 30s timeout, exit 0 → continue, else retry
9
+ * - prompt-verify: always "pause" (defers to agent)
10
+ * - human-review: always "pause" (waits for manual inspection)
11
+ * - (no policy): returns "continue" (passthrough)
12
+ *
13
+ * Observability:
14
+ * - Return value is the typed verification outcome ("continue" | "retry" | "pause").
15
+ * - shell-command captures stderr from spawnSync — callers can inspect on retry.
16
+ * - content-heuristic logs the specific failure (missing file, below minSize, pattern mismatch).
17
+ * - The frozen DEFINITION.yaml on disk is the single source of truth for step policies.
18
+ */
19
+ import { readFileSync, existsSync, statSync } from "node:fs";
20
+ import { resolve, sep } from "node:path";
21
+ import { spawnSync } from "node:child_process";
22
+ import { readFrozenDefinition } from "./custom-workflow-engine.js";
23
+ /**
24
+ * Run custom verification for a specific step in a workflow run.
25
+ *
26
+ * Reads the frozen DEFINITION.yaml from `runDir`, finds the step with the
27
+ * given `stepId`, and dispatches to the appropriate verification handler
28
+ * based on the step's `verify.policy` field.
29
+ *
30
+ * @param runDir — absolute path to the workflow run directory
31
+ * @param stepId — the step ID to verify (e.g. "step-1")
32
+ * @returns "continue" if verification passes, "retry" if it should retry, "pause" if it needs review
33
+ * @throws Error if DEFINITION.yaml is missing or unreadable
34
+ */
35
+ export function runCustomVerification(runDir, stepId) {
36
+ const def = readFrozenDefinition(runDir);
37
+ const step = def.steps.find((s) => s.id === stepId);
38
+ if (!step) {
39
+ // Step not found in definition — nothing to verify, continue
40
+ return "continue";
41
+ }
42
+ if (!step.verify) {
43
+ // No verification policy configured — passthrough
44
+ return "continue";
45
+ }
46
+ return dispatchPolicy(runDir, step, step.verify);
47
+ }
48
+ /**
49
+ * Dispatch to the correct policy handler.
50
+ */
51
+ function dispatchPolicy(runDir, step, verify) {
52
+ switch (verify.policy) {
53
+ case "content-heuristic":
54
+ return handleContentHeuristic(runDir, step, verify);
55
+ case "shell-command":
56
+ return handleShellCommand(runDir, verify);
57
+ case "prompt-verify":
58
+ return "pause";
59
+ case "human-review":
60
+ return "pause";
61
+ default:
62
+ // Unknown policy — safe default is pause
63
+ return "pause";
64
+ }
65
+ }
66
+ /**
67
+ * content-heuristic handler.
68
+ *
69
+ * For each path in the step's `produces` array:
70
+ * 1. Check that the file exists (resolved relative to runDir)
71
+ * 2. If `minSize` is set, check that file size >= minSize bytes
72
+ * 3. If `pattern` is set, check that file content matches the regex
73
+ *
74
+ * Returns "continue" if all checks pass, "pause" if any fail.
75
+ * If `produces` is empty or undefined, returns "continue" (nothing to check).
76
+ */
77
+ function handleContentHeuristic(runDir, step, verify) {
78
+ const produces = step.produces;
79
+ if (!produces || produces.length === 0) {
80
+ return "continue";
81
+ }
82
+ for (const relPath of produces) {
83
+ const absPath = resolve(runDir, relPath);
84
+ // Path traversal guard
85
+ if (!absPath.startsWith(resolve(runDir) + sep) && absPath !== resolve(runDir)) {
86
+ return "pause";
87
+ }
88
+ // 1. File existence
89
+ if (!existsSync(absPath)) {
90
+ return "pause";
91
+ }
92
+ // 2. Minimum size check
93
+ if (verify.minSize !== undefined) {
94
+ const stat = statSync(absPath);
95
+ if (stat.size < verify.minSize) {
96
+ return "pause";
97
+ }
98
+ }
99
+ // 3. Pattern match check (with timeout guard against ReDoS)
100
+ if (verify.pattern !== undefined) {
101
+ const content = readFileSync(absPath, "utf-8");
102
+ try {
103
+ if (!new RegExp(verify.pattern).test(content)) {
104
+ return "pause";
105
+ }
106
+ }
107
+ catch {
108
+ // Invalid regex at runtime — treat as verification failure
109
+ return "pause";
110
+ }
111
+ }
112
+ }
113
+ return "continue";
114
+ }
115
+ /**
116
+ * shell-command handler.
117
+ *
118
+ * Runs the command via `sh -c` with cwd set to the run directory
119
+ * and a 30-second timeout. Returns "continue" if exit code 0,
120
+ * "retry" otherwise (including timeout/signal kills).
121
+ *
122
+ * SECURITY: The command string comes from a frozen DEFINITION.yaml written
123
+ * at run-creation time. The trust boundary is the workflow definition author.
124
+ * Commands run with the same privileges as the GSD process. Only use
125
+ * shell-command verification with definitions you trust.
126
+ */
127
+ function handleShellCommand(runDir, verify) {
128
+ // Guard: reject commands containing shell expansion patterns that suggest injection
129
+ const dangerousPatterns = /\$\(|`|;\s*(rm|curl|wget|nc|bash|sh|eval)\b/;
130
+ if (dangerousPatterns.test(verify.command)) {
131
+ console.warn(`custom-verification: shell-command contains suspicious pattern, skipping: ${verify.command}`);
132
+ return "pause";
133
+ }
134
+ const result = spawnSync("sh", ["-c", verify.command], {
135
+ cwd: runDir,
136
+ timeout: 30_000,
137
+ encoding: "utf-8",
138
+ stdio: "pipe",
139
+ env: { ...process.env, PATH: process.env.PATH },
140
+ });
141
+ if (result.status === 0) {
142
+ return "continue";
143
+ }
144
+ return "retry";
145
+ }
@@ -0,0 +1,164 @@
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
+ import { readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { parse } from "yaml";
17
+ import { readGraph, writeGraph, getNextPendingStep, markStepComplete, expandIteration, } from "./graph.js";
18
+ import { injectContext } from "./context-injector.js";
19
+ /** Read and parse the frozen DEFINITION.yaml from a run directory. */
20
+ export function readFrozenDefinition(runDir) {
21
+ const defPath = join(runDir, "DEFINITION.yaml");
22
+ const raw = readFileSync(defPath, "utf-8");
23
+ return parse(raw, { schema: "core" });
24
+ }
25
+ export class CustomWorkflowEngine {
26
+ engineId = "custom";
27
+ runDir;
28
+ constructor(runDir) {
29
+ this.runDir = runDir;
30
+ }
31
+ /**
32
+ * Derive engine state from GRAPH.yaml on disk.
33
+ *
34
+ * Phase is "complete" when all steps are complete or expanded,
35
+ * "running" otherwise (any pending or active steps remain).
36
+ */
37
+ async deriveState(_basePath) {
38
+ const graph = readGraph(this.runDir);
39
+ const allDone = graph.steps.every((s) => s.status === "complete" || s.status === "expanded");
40
+ const phase = allDone ? "complete" : "running";
41
+ return {
42
+ phase,
43
+ currentMilestoneId: null,
44
+ activeSliceId: null,
45
+ activeTaskId: null,
46
+ isComplete: allDone,
47
+ raw: graph,
48
+ };
49
+ }
50
+ /**
51
+ * Resolve the next dispatch action from graph state.
52
+ *
53
+ * Uses getNextPendingStep to find the first step whose dependencies
54
+ * are all satisfied. If the step has an `iterate` config in the frozen
55
+ * DEFINITION.yaml, expands it into instance steps before dispatching.
56
+ *
57
+ * Returns a dispatch with unitType "custom-step" and unitId in
58
+ * "<workflowName>/<stepId>" format.
59
+ *
60
+ * Observability:
61
+ * - Iterate expansion is logged to stderr with item count and parent step ID.
62
+ * - Missing source artifacts throw with the full resolved path for diagnosis.
63
+ * - Zero-match expansions return a stop action with level "info".
64
+ * - Expanded GRAPH.yaml is written to disk before dispatch — inspectable on disk.
65
+ */
66
+ async resolveDispatch(state, _context) {
67
+ let graph = state.raw;
68
+ let next = getNextPendingStep(graph);
69
+ if (!next) {
70
+ return {
71
+ action: "stop",
72
+ reason: "All steps complete",
73
+ level: "info",
74
+ };
75
+ }
76
+ // Check frozen DEFINITION.yaml for iterate config on this step
77
+ const def = readFrozenDefinition(this.runDir);
78
+ const stepDef = def.steps.find((s) => s.id === next.id);
79
+ if (stepDef?.iterate) {
80
+ const iterate = stepDef.iterate;
81
+ // Read source artifact
82
+ const sourcePath = join(this.runDir, iterate.source);
83
+ let sourceContent;
84
+ try {
85
+ sourceContent = readFileSync(sourcePath, "utf-8");
86
+ }
87
+ catch {
88
+ throw new Error(`Iterate source artifact not found: ${sourcePath} (step "${next.id}", source: "${iterate.source}")`);
89
+ }
90
+ // Extract items via regex with global+multiline flags.
91
+ // Guard against ReDoS: if matching takes too long on large inputs, bail.
92
+ const regex = new RegExp(iterate.pattern, "gm");
93
+ const items = [];
94
+ const matchStart = Date.now();
95
+ let match;
96
+ while ((match = regex.exec(sourceContent)) !== null) {
97
+ if (match[1] !== undefined)
98
+ items.push(match[1]);
99
+ if (Date.now() - matchStart > 5_000) {
100
+ throw new Error(`Iterate pattern "${iterate.pattern}" exceeded 5s timeout on step "${next.id}" — possible ReDoS`);
101
+ }
102
+ }
103
+ // Expand the graph
104
+ const expandedGraph = expandIteration(graph, next.id, items, next.prompt);
105
+ writeGraph(this.runDir, expandedGraph);
106
+ graph = expandedGraph;
107
+ // Re-query for first instance step
108
+ next = getNextPendingStep(expandedGraph);
109
+ if (!next) {
110
+ return {
111
+ action: "stop",
112
+ reason: "Iterate expansion produced no instances",
113
+ level: "info",
114
+ };
115
+ }
116
+ }
117
+ // Enrich prompt with context from prior step artifacts
118
+ const enrichedPrompt = injectContext(this.runDir, next.id, next.prompt);
119
+ return {
120
+ action: "dispatch",
121
+ step: {
122
+ unitType: "custom-step",
123
+ unitId: `${graph.metadata.name}/${next.id}`,
124
+ prompt: enrichedPrompt,
125
+ },
126
+ };
127
+ }
128
+ /**
129
+ * Reconcile state after a step completes.
130
+ *
131
+ * Extracts the stepId from the completedStep's unitId (last segment after `/`),
132
+ * marks it complete in the graph, and writes the updated GRAPH.yaml to disk.
133
+ *
134
+ * Returns "milestone-complete" when all steps are now done, "continue" otherwise.
135
+ */
136
+ async reconcile(state, completedStep) {
137
+ const graph = state.raw;
138
+ // Extract stepId from "<workflowName>/<stepId>"
139
+ const parts = completedStep.unitId.split("/");
140
+ const stepId = parts[parts.length - 1];
141
+ const updatedGraph = markStepComplete(graph, stepId);
142
+ writeGraph(this.runDir, updatedGraph);
143
+ const allDone = updatedGraph.steps.every((s) => s.status === "complete" || s.status === "expanded");
144
+ return {
145
+ outcome: allDone ? "milestone-complete" : "continue",
146
+ };
147
+ }
148
+ /**
149
+ * Return UI-facing metadata for progress display.
150
+ *
151
+ * Shows "Step N/M" progress where N = completed count and M = total.
152
+ */
153
+ getDisplayMetadata(state) {
154
+ const graph = state.raw;
155
+ const total = graph.steps.length;
156
+ const completed = graph.steps.filter((s) => s.status === "complete").length;
157
+ return {
158
+ engineLabel: "WORKFLOW",
159
+ currentPhase: state.phase,
160
+ progressSummary: `Step ${completed}/${total}`,
161
+ stepCount: { completed, total },
162
+ };
163
+ }
164
+ }
@@ -30,6 +30,7 @@ function unitLabel(type) {
30
30
  case "triage-captures": return "Triage";
31
31
  case "quick-task": return "Quick Task";
32
32
  case "replan-slice": return "Replan";
33
+ case "custom-step": return "Workflow Step";
33
34
  default: return type;
34
35
  }
35
36
  }
@@ -0,0 +1,352 @@
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
+ import { parse } from "yaml";
16
+ import { readFileSync, existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ // ─── Validation ──────────────────────────────────────────────────────────
19
+ /**
20
+ * Validate a parsed (but untyped) YAML object against the V1 workflow schema.
21
+ *
22
+ * Collects all errors (does not short-circuit) so a single call reveals
23
+ * every problem with the definition.
24
+ *
25
+ * Unknown fields are silently accepted for forward compatibility with
26
+ * S05/S06 features (`context_from`, `verify`, `iterate`).
27
+ */
28
+ export function validateDefinition(parsed) {
29
+ const errors = [];
30
+ if (parsed == null || typeof parsed !== "object") {
31
+ return { valid: false, errors: ["Definition must be a non-null object"] };
32
+ }
33
+ const def = parsed;
34
+ // version: must be 1 (number)
35
+ if (def.version === undefined || def.version === null) {
36
+ errors.push("Missing required field: version");
37
+ }
38
+ else if (def.version !== 1) {
39
+ errors.push(`Unsupported version: ${def.version} (expected 1)`);
40
+ }
41
+ // name: must be a non-empty string
42
+ if (typeof def.name !== "string" || def.name.trim() === "") {
43
+ errors.push("Missing or empty required field: name");
44
+ }
45
+ // steps: must be a non-empty array
46
+ if (!Array.isArray(def.steps)) {
47
+ errors.push("Missing required field: steps (must be an array)");
48
+ }
49
+ else if (def.steps.length === 0) {
50
+ errors.push("steps must contain at least one step");
51
+ }
52
+ else {
53
+ // Track whether all steps have valid IDs — graph-level checks only run when true
54
+ let allStepIdsValid = true;
55
+ for (let i = 0; i < def.steps.length; i++) {
56
+ const step = def.steps[i];
57
+ if (step == null || typeof step !== "object") {
58
+ errors.push(`Step at index ${i} is not an object`);
59
+ allStepIdsValid = false;
60
+ continue;
61
+ }
62
+ // Required step fields
63
+ if (typeof step.id !== "string" || step.id.trim() === "") {
64
+ errors.push(`Step at index ${i} missing required field: id`);
65
+ allStepIdsValid = false;
66
+ }
67
+ if (typeof step.name !== "string" || step.name.trim() === "") {
68
+ errors.push(`Step at index ${i} missing required field: name`);
69
+ }
70
+ if (typeof step.prompt !== "string" || step.prompt.trim() === "") {
71
+ errors.push(`Step at index ${i} missing required field: prompt`);
72
+ }
73
+ // produces: path traversal guard
74
+ if (Array.isArray(step.produces)) {
75
+ for (const p of step.produces) {
76
+ if (typeof p === "string" && p.includes("..")) {
77
+ errors.push(`Step "${step.id}" produces path contains disallowed '..': ${p}`);
78
+ }
79
+ }
80
+ }
81
+ // iterate: optional, but if present must conform to IterateConfig shape
82
+ if (step.iterate !== undefined) {
83
+ const it = step.iterate;
84
+ const sid = typeof step.id === "string" ? step.id : `index ${i}`;
85
+ if (it == null || typeof it !== "object" || Array.isArray(it)) {
86
+ errors.push(`Step "${sid}" iterate must be an object with "source" and "pattern" fields`);
87
+ }
88
+ else {
89
+ const itObj = it;
90
+ if (typeof itObj.source !== "string" || itObj.source.trim() === "") {
91
+ errors.push(`Step "${sid}" iterate.source must be a non-empty string`);
92
+ }
93
+ else if (itObj.source.includes("..")) {
94
+ errors.push(`Step "${sid}" iterate.source contains disallowed '..' path traversal`);
95
+ }
96
+ if (typeof itObj.pattern !== "string" || itObj.pattern.trim() === "") {
97
+ errors.push(`Step "${sid}" iterate.pattern must be a non-empty string`);
98
+ }
99
+ else {
100
+ const pat = itObj.pattern;
101
+ let regexValid = true;
102
+ try {
103
+ new RegExp(pat);
104
+ }
105
+ catch {
106
+ regexValid = false;
107
+ errors.push(`Step "${sid}" iterate.pattern is not a valid regex: ${pat}`);
108
+ }
109
+ if (regexValid && !/\((?!\?)/.test(pat)) {
110
+ errors.push(`Step "${sid}" iterate.pattern must contain at least one capture group`);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ // verify: optional, but if present must conform to VerifyPolicy shape
116
+ if (step.verify !== undefined) {
117
+ const v = step.verify;
118
+ const sid = typeof step.id === "string" ? step.id : `index ${i}`;
119
+ if (v == null || typeof v !== "object" || Array.isArray(v)) {
120
+ errors.push(`Step "${sid}" verify must be an object with a "policy" field`);
121
+ }
122
+ else {
123
+ const vObj = v;
124
+ const VALID_POLICIES = ["content-heuristic", "shell-command", "prompt-verify", "human-review"];
125
+ if (typeof vObj.policy !== "string" || !VALID_POLICIES.includes(vObj.policy)) {
126
+ errors.push(`Step "${sid}" verify.policy must be one of: ${VALID_POLICIES.join(", ")}`);
127
+ }
128
+ else {
129
+ // Policy-specific required field checks
130
+ if (vObj.policy === "shell-command") {
131
+ if (typeof vObj.command !== "string" || vObj.command.trim() === "") {
132
+ errors.push(`Step "${sid}" verify policy "shell-command" requires a non-empty "command" field`);
133
+ }
134
+ }
135
+ if (vObj.policy === "prompt-verify") {
136
+ if (typeof vObj.prompt !== "string" || vObj.prompt.trim() === "") {
137
+ errors.push(`Step "${sid}" verify policy "prompt-verify" requires a non-empty "prompt" field`);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ // ─── Graph-level validations (only when all step IDs are valid) ────
145
+ if (allStepIdsValid) {
146
+ const steps = def.steps;
147
+ // 1. Duplicate step ID check
148
+ const idCounts = new Map();
149
+ for (const step of steps) {
150
+ const id = step.id;
151
+ idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
152
+ }
153
+ for (const [id, count] of idCounts) {
154
+ if (count > 1) {
155
+ errors.push(`Duplicate step id: ${id}`);
156
+ }
157
+ }
158
+ // Build valid ID set for remaining checks
159
+ const validIds = new Set(steps.map((s) => s.id));
160
+ // 2. Dangling dependency check + 3. Self-referencing dependency check
161
+ for (const step of steps) {
162
+ const sid = step.id;
163
+ const deps = Array.isArray(step.requires)
164
+ ? step.requires
165
+ : Array.isArray(step.depends_on)
166
+ ? step.depends_on
167
+ : [];
168
+ for (const depId of deps) {
169
+ if (depId === sid) {
170
+ errors.push(`Step '${sid}' depends on itself`);
171
+ }
172
+ else if (!validIds.has(depId)) {
173
+ errors.push(`Step '${sid}' requires unknown step '${depId}'`);
174
+ }
175
+ }
176
+ }
177
+ // 4. Cycle detection (DFS) — only when no duplicate IDs
178
+ if (![...idCounts.values()].some((c) => c > 1)) {
179
+ // Build adjacency list: step → its dependencies
180
+ const adj = new Map();
181
+ for (const step of steps) {
182
+ const sid = step.id;
183
+ const deps = Array.isArray(step.requires)
184
+ ? step.requires
185
+ : Array.isArray(step.depends_on)
186
+ ? step.depends_on
187
+ : [];
188
+ adj.set(sid, deps.filter((d) => validIds.has(d) && d !== sid));
189
+ }
190
+ const WHITE = 0, GRAY = 1, BLACK = 2;
191
+ const color = new Map();
192
+ for (const id of validIds)
193
+ color.set(id, WHITE);
194
+ const parent = new Map();
195
+ function dfs(node) {
196
+ color.set(node, GRAY);
197
+ for (const dep of adj.get(node) ?? []) {
198
+ if (color.get(dep) === GRAY) {
199
+ // Back edge found — reconstruct cycle path
200
+ const cycle = [dep, node];
201
+ let cur = node;
202
+ while (parent.has(cur) && parent.get(cur) !== null && parent.get(cur) !== dep) {
203
+ cur = parent.get(cur);
204
+ cycle.push(cur);
205
+ }
206
+ cycle.push(dep);
207
+ cycle.reverse();
208
+ return cycle;
209
+ }
210
+ if (color.get(dep) === WHITE) {
211
+ parent.set(dep, node);
212
+ const result = dfs(dep);
213
+ if (result)
214
+ return result;
215
+ }
216
+ }
217
+ color.set(node, BLACK);
218
+ return null;
219
+ }
220
+ for (const id of validIds) {
221
+ if (color.get(id) === WHITE) {
222
+ parent.set(id, null);
223
+ const cycle = dfs(id);
224
+ if (cycle) {
225
+ errors.push(`Cycle detected: ${cycle.join(" → ")}`);
226
+ break; // One cycle error is enough
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+ }
233
+ return { valid: errors.length === 0, errors };
234
+ }
235
+ // ─── Loading ─────────────────────────────────────────────────────────────
236
+ /**
237
+ * Load and validate a YAML workflow definition from the filesystem.
238
+ *
239
+ * Reads `<defsDir>/<name>.yaml`, parses YAML, validates the V1 schema,
240
+ * and converts snake_case YAML keys to camelCase TypeScript types.
241
+ *
242
+ * @param defsDir — directory containing definition YAML files
243
+ * @param name — definition filename without extension
244
+ * @returns Parsed and validated WorkflowDefinition
245
+ * @throws Error if file is missing, YAML is malformed, or schema is invalid
246
+ */
247
+ export function loadDefinition(defsDir, name) {
248
+ const filePath = join(defsDir, `${name}.yaml`);
249
+ if (!existsSync(filePath)) {
250
+ throw new Error(`Definition file not found: ${filePath}`);
251
+ }
252
+ const raw = readFileSync(filePath, "utf-8");
253
+ let parsed;
254
+ try {
255
+ parsed = parse(raw);
256
+ }
257
+ catch (e) {
258
+ const msg = e instanceof Error ? e.message : String(e);
259
+ throw new Error(`Failed to parse YAML in ${filePath}: ${msg}`);
260
+ }
261
+ const { valid, errors } = validateDefinition(parsed);
262
+ if (!valid) {
263
+ throw new Error(`Invalid workflow definition in ${filePath}:\n - ${errors.join("\n - ")}`);
264
+ }
265
+ // Convert snake_case YAML → camelCase TypeScript
266
+ const yamlDef = parsed;
267
+ const yamlSteps = yamlDef.steps;
268
+ return {
269
+ version: yamlDef.version,
270
+ name: yamlDef.name,
271
+ description: typeof yamlDef.description === "string" ? yamlDef.description : undefined,
272
+ params: yamlDef.params != null && typeof yamlDef.params === "object"
273
+ ? Object.fromEntries(Object.entries(yamlDef.params).map(([k, v]) => [k, String(v)]))
274
+ : undefined,
275
+ steps: yamlSteps.map((s) => ({
276
+ id: s.id,
277
+ name: s.name,
278
+ prompt: s.prompt,
279
+ requires: Array.isArray(s.requires)
280
+ ? s.requires
281
+ : Array.isArray(s.depends_on)
282
+ ? s.depends_on
283
+ : [],
284
+ produces: Array.isArray(s.produces) ? s.produces : [],
285
+ contextFrom: Array.isArray(s.context_from) ? s.context_from : undefined,
286
+ verify: s.verify,
287
+ iterate: (s.iterate != null && typeof s.iterate === "object")
288
+ ? s.iterate
289
+ : undefined,
290
+ })),
291
+ };
292
+ }
293
+ // ─── Parameter Substitution ──────────────────────────────────────────────
294
+ /** Regex matching `{{key}}` placeholders — captures the key name. */
295
+ const PARAM_PATTERN = /\{\{(\w+)\}\}/g;
296
+ /**
297
+ * Replace `{{key}}` placeholders in a single prompt string.
298
+ *
299
+ * Exported for use by the engine on iteration-instance prompts that live
300
+ * in GRAPH.yaml (outside the definition's step list).
301
+ *
302
+ * @throws Error if any merged param value contains `..` (path-traversal guard)
303
+ */
304
+ export function substitutePromptString(prompt, merged) {
305
+ return prompt.replace(PARAM_PATTERN, (match, key) => {
306
+ const value = merged[key];
307
+ return value !== undefined ? value : match;
308
+ });
309
+ }
310
+ /**
311
+ * Replace `{{key}}` placeholders in all step prompts with param values.
312
+ *
313
+ * Merge order: `definition.params` (defaults) ← `overrides` (CLI wins).
314
+ * Returns a **new** WorkflowDefinition — the input is never mutated.
315
+ *
316
+ * @throws Error if any param value contains `..` (path-traversal guard)
317
+ * @throws Error if any `{{key}}` remains unresolved after substitution
318
+ */
319
+ export function substituteParams(definition, overrides) {
320
+ const merged = {
321
+ ...(definition.params ?? {}),
322
+ ...(overrides ?? {}),
323
+ };
324
+ // Path-traversal guard: reject any value containing ".."
325
+ for (const [key, value] of Object.entries(merged)) {
326
+ if (value.includes("..")) {
327
+ throw new Error(`Parameter "${key}" contains disallowed '..' (path traversal): ${value}`);
328
+ }
329
+ }
330
+ // Substitute in each step prompt
331
+ const substitutedSteps = definition.steps.map((step) => ({
332
+ ...step,
333
+ prompt: substitutePromptString(step.prompt, merged),
334
+ }));
335
+ // Check for unresolved placeholders
336
+ const unresolved = new Set();
337
+ for (const step of substitutedSteps) {
338
+ let m;
339
+ const re = new RegExp(PARAM_PATTERN.source, "g");
340
+ while ((m = re.exec(step.prompt)) !== null) {
341
+ unresolved.add(m[1]);
342
+ }
343
+ }
344
+ if (unresolved.size > 0) {
345
+ const keys = [...unresolved].sort().join(", ");
346
+ throw new Error(`Unresolved parameter(s) in step prompts: ${keys}`);
347
+ }
348
+ return {
349
+ ...definition,
350
+ steps: substitutedSteps,
351
+ };
352
+ }