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,180 @@
1
+ /**
2
+ * run-manager.ts — Create and list isolated workflow run directories.
3
+ *
4
+ * Each run lives under `.gsd/workflow-runs/<name>/<timestamp>/` and contains:
5
+ * - DEFINITION.yaml — frozen snapshot of the workflow definition at run-creation time
6
+ * - GRAPH.yaml — initialized step graph with all steps pending
7
+ * - PARAMS.json — (optional) parameter overrides used for this run
8
+ *
9
+ * Observability:
10
+ * - All run state is on disk in human-readable YAML/JSON — inspectable with cat/less.
11
+ * - `listRuns()` returns structured metadata including step counts and overall status.
12
+ * - Timestamp directory names are filesystem-safe (ISO with hyphens replacing colons).
13
+ * - Errors include the full path context for diagnosis.
14
+ */
15
+
16
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { stringify } from "yaml";
19
+ import { loadDefinition, substituteParams } from "./definition-loader.js";
20
+ import { initializeGraph, writeGraph, readGraph } from "./graph.js";
21
+ import type { WorkflowDefinition } from "./definition-loader.js";
22
+ import type { WorkflowGraph } from "./graph.js";
23
+
24
+ // ─── Types ───────────────────────────────────────────────────────────────
25
+
26
+ export interface RunMetadata {
27
+ /** Workflow definition name. */
28
+ name: string;
29
+ /** Filesystem-safe timestamp string used as dir name. */
30
+ timestamp: string;
31
+ /** Full path to the run directory. */
32
+ runDir: string;
33
+ /** Step counts derived from GRAPH.yaml. */
34
+ steps: { total: number; completed: number; pending: number; active: number };
35
+ /** Overall status derived from step states. */
36
+ status: "pending" | "running" | "complete";
37
+ }
38
+
39
+ // ─── Constants ───────────────────────────────────────────────────────────
40
+
41
+ const RUNS_DIR = "workflow-runs";
42
+ const DEFS_DIR = "workflow-defs";
43
+
44
+ // ─── Helpers ─────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Generate a filesystem-safe timestamp: `YYYY-MM-DDTHH-MM-SS`.
48
+ * Replaces colons with hyphens so the string is safe as a directory name
49
+ * on all platforms (Windows forbids colons in paths).
50
+ */
51
+ function makeTimestamp(date: Date = new Date()): string {
52
+ return date.toISOString().replace(/:/g, "-").replace(/\.\d{3}Z$/, "");
53
+ }
54
+
55
+ /**
56
+ * Derive overall status from a graph's step statuses.
57
+ */
58
+ function deriveStatus(graph: WorkflowGraph): "pending" | "running" | "complete" {
59
+ const hasActive = graph.steps.some((s) => s.status === "active");
60
+ const allDone = graph.steps.every(
61
+ (s) => s.status === "complete" || s.status === "expanded",
62
+ );
63
+ if (allDone) return "complete";
64
+ if (hasActive) return "running";
65
+ return "pending";
66
+ }
67
+
68
+ // ─── Public API ──────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Create a new isolated run directory for a workflow definition.
72
+ *
73
+ * 1. Loads the definition from `<basePath>/.gsd/workflow-defs/<defName>.yaml`
74
+ * 2. Applies parameter substitution if overrides are provided
75
+ * 3. Creates `<basePath>/.gsd/workflow-runs/<defName>/<timestamp>/`
76
+ * 4. Writes frozen DEFINITION.yaml, initialized GRAPH.yaml, and optional PARAMS.json
77
+ *
78
+ * @param basePath — project root directory
79
+ * @param defName — definition filename (without .yaml extension)
80
+ * @param overrides — optional parameter overrides (merged with definition defaults)
81
+ * @returns Full path to the created run directory
82
+ * @throws Error if the definition file doesn't exist or is invalid
83
+ */
84
+ export function createRun(
85
+ basePath: string,
86
+ defName: string,
87
+ overrides?: Record<string, string>,
88
+ ): string {
89
+ const defsDir = join(basePath, ".gsd", DEFS_DIR);
90
+
91
+ // Load and validate the definition
92
+ const rawDef = loadDefinition(defsDir, defName);
93
+
94
+ // Apply parameter substitution if overrides provided
95
+ const def: WorkflowDefinition = overrides
96
+ ? substituteParams(rawDef, overrides)
97
+ : substituteParams(rawDef); // still resolve default params if any
98
+
99
+ // Create the run directory
100
+ const timestamp = makeTimestamp();
101
+ const runDir = join(basePath, ".gsd", RUNS_DIR, defName, timestamp);
102
+ mkdirSync(runDir, { recursive: true });
103
+
104
+ // Freeze the definition as DEFINITION.yaml
105
+ writeFileSync(join(runDir, "DEFINITION.yaml"), stringify(def), "utf-8");
106
+
107
+ // Initialize and write GRAPH.yaml
108
+ const graph = initializeGraph(def);
109
+ writeGraph(runDir, graph);
110
+
111
+ // Write PARAMS.json if overrides were provided
112
+ if (overrides && Object.keys(overrides).length > 0) {
113
+ writeFileSync(
114
+ join(runDir, "PARAMS.json"),
115
+ JSON.stringify(overrides, null, 2),
116
+ "utf-8",
117
+ );
118
+ }
119
+
120
+ return runDir;
121
+ }
122
+
123
+ /**
124
+ * List existing workflow runs with metadata.
125
+ *
126
+ * Scans `<basePath>/.gsd/workflow-runs/` for run directories. Each run's
127
+ * GRAPH.yaml is read to derive step counts and overall status.
128
+ *
129
+ * @param basePath — project root directory
130
+ * @param defName — optional filter: only list runs for this definition name
131
+ * @returns Array of run metadata, sorted newest-first within each definition
132
+ */
133
+ export function listRuns(basePath: string, defName?: string): RunMetadata[] {
134
+ const runsRoot = join(basePath, ".gsd", RUNS_DIR);
135
+ if (!existsSync(runsRoot)) return [];
136
+
137
+ const results: RunMetadata[] = [];
138
+
139
+ // Get workflow name directories
140
+ const nameDirs = defName ? [defName] : readdirSync(runsRoot).filter((entry) => {
141
+ const full = join(runsRoot, entry);
142
+ return statSync(full).isDirectory();
143
+ });
144
+
145
+ for (const name of nameDirs) {
146
+ const nameDir = join(runsRoot, name);
147
+ if (!existsSync(nameDir)) continue;
148
+
149
+ const timestamps = readdirSync(nameDir).filter((entry) => {
150
+ const full = join(nameDir, entry);
151
+ return statSync(full).isDirectory();
152
+ });
153
+
154
+ // Sort newest-first (ISO strings sort lexicographically)
155
+ timestamps.sort().reverse();
156
+
157
+ for (const ts of timestamps) {
158
+ const runDir = join(nameDir, ts);
159
+ try {
160
+ const graph = readGraph(runDir);
161
+ const total = graph.steps.length;
162
+ const completed = graph.steps.filter((s) => s.status === "complete").length;
163
+ const pending = graph.steps.filter((s) => s.status === "pending").length;
164
+ const active = graph.steps.filter((s) => s.status === "active").length;
165
+
166
+ results.push({
167
+ name,
168
+ timestamp: ts,
169
+ runDir,
170
+ steps: { total, completed, pending, active },
171
+ status: deriveStatus(graph),
172
+ });
173
+ } catch {
174
+ // Skip runs with invalid/missing GRAPH.yaml
175
+ }
176
+ }
177
+ }
178
+
179
+ return results;
180
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Bundled workflow definition validation tests.
3
+ *
4
+ * Verifies that every example YAML in src/resources/skills/create-workflow/templates/
5
+ * passes validateDefinition() from definition-loader.ts with { valid: true, errors: [] }.
6
+ *
7
+ * Also validates scaffold template and structural properties of each example
8
+ * (step counts, feature usage) to guard against accidental regressions.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { readFileSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { parse } from "yaml";
17
+
18
+ import { validateDefinition } from "../definition-loader.ts";
19
+
20
+ // ─── Path resolution ─────────────────────────────────────────────────────
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ // Navigate from tests/ → extensions/gsd/ → extensions/ → resources/ → skills/create-workflow/templates/
24
+ const templatesDir = join(
25
+ __dirname,
26
+ "..",
27
+ "..",
28
+ "..",
29
+ "skills",
30
+ "create-workflow",
31
+ "templates",
32
+ );
33
+
34
+ function loadYaml(filename: string): unknown {
35
+ const raw = readFileSync(join(templatesDir, filename), "utf-8");
36
+ return parse(raw);
37
+ }
38
+
39
+ // ─── Scaffold template ──────────────────────────────────────────────────
40
+
41
+ test("scaffold template (workflow-definition.yaml) passes validation", () => {
42
+ const parsed = loadYaml("workflow-definition.yaml");
43
+ const result = validateDefinition(parsed);
44
+ assert.equal(result.valid, true, `Scaffold invalid: ${result.errors.join("; ")}`);
45
+ assert.equal(result.errors.length, 0);
46
+ });
47
+
48
+ // ─── blog-post-pipeline.yaml ────────────────────────────────────────────
49
+
50
+ test("blog-post-pipeline.yaml passes validation", () => {
51
+ const parsed = loadYaml("blog-post-pipeline.yaml");
52
+ const result = validateDefinition(parsed);
53
+ assert.equal(result.valid, true, `Invalid: ${result.errors.join("; ")}`);
54
+ assert.equal(result.errors.length, 0);
55
+ });
56
+
57
+ test("blog-post-pipeline.yaml: 3 steps, context_from, params, content-heuristic", () => {
58
+ const parsed = loadYaml("blog-post-pipeline.yaml") as Record<string, unknown>;
59
+ const steps = parsed.steps as Array<Record<string, unknown>>;
60
+
61
+ // 3 steps
62
+ assert.equal(steps.length, 3, "Expected 3 steps");
63
+
64
+ // params defined
65
+ assert.ok(parsed.params, "Expected params to be defined");
66
+ const params = parsed.params as Record<string, string>;
67
+ assert.ok("topic" in params, "Expected 'topic' param");
68
+ assert.ok("audience" in params, "Expected 'audience' param");
69
+
70
+ // At least one step uses context_from
71
+ const hasContextFrom = steps.some(
72
+ (s) => Array.isArray(s.context_from) && s.context_from.length > 0,
73
+ );
74
+ assert.ok(hasContextFrom, "Expected at least one step with context_from");
75
+
76
+ // All steps use content-heuristic verify
77
+ for (const step of steps) {
78
+ const verify = step.verify as Record<string, unknown> | undefined;
79
+ assert.ok(verify, `Step "${step.id}" missing verify`);
80
+ assert.equal(verify.policy, "content-heuristic", `Step "${step.id}" should use content-heuristic`);
81
+ }
82
+ });
83
+
84
+ // ─── code-audit.yaml ────────────────────────────────────────────────────
85
+
86
+ test("code-audit.yaml passes validation", () => {
87
+ const parsed = loadYaml("code-audit.yaml");
88
+ const result = validateDefinition(parsed);
89
+ assert.equal(result.valid, true, `Invalid: ${result.errors.join("; ")}`);
90
+ assert.equal(result.errors.length, 0);
91
+ });
92
+
93
+ test("code-audit.yaml: iterate with capture group and shell-command verify", () => {
94
+ const parsed = loadYaml("code-audit.yaml") as Record<string, unknown>;
95
+ const steps = parsed.steps as Array<Record<string, unknown>>;
96
+
97
+ // Find step with iterate
98
+ const iterateStep = steps.find((s) => s.iterate != null);
99
+ assert.ok(iterateStep, "Expected a step with iterate config");
100
+
101
+ const iterate = iterateStep.iterate as Record<string, unknown>;
102
+ assert.equal(typeof iterate.source, "string", "iterate.source must be a string");
103
+ assert.equal(typeof iterate.pattern, "string", "iterate.pattern must be a string");
104
+
105
+ // Pattern has a capture group
106
+ const pattern = iterate.pattern as string;
107
+ assert.ok(/\((?!\?)/.test(pattern), "iterate.pattern must contain a capture group");
108
+
109
+ // Pattern is valid regex
110
+ assert.doesNotThrow(() => new RegExp(pattern), "iterate.pattern must be valid regex");
111
+
112
+ // Has shell-command verify
113
+ const verify = iterateStep.verify as Record<string, unknown>;
114
+ assert.equal(verify.policy, "shell-command");
115
+ assert.equal(typeof verify.command, "string");
116
+ });
117
+
118
+ // ─── release-checklist.yaml ─────────────────────────────────────────────
119
+
120
+ test("release-checklist.yaml passes validation", () => {
121
+ const parsed = loadYaml("release-checklist.yaml");
122
+ const result = validateDefinition(parsed);
123
+ assert.equal(result.valid, true, `Invalid: ${result.errors.join("; ")}`);
124
+ assert.equal(result.errors.length, 0);
125
+ });
126
+
127
+ test("release-checklist.yaml: diamond dependencies and human-review", () => {
128
+ const parsed = loadYaml("release-checklist.yaml") as Record<string, unknown>;
129
+ const steps = parsed.steps as Array<Record<string, unknown>>;
130
+
131
+ // 4 steps
132
+ assert.equal(steps.length, 4, "Expected 4 steps");
133
+
134
+ // Diamond pattern: two steps depend on the same parent
135
+ const changelog = steps.find((s) => s.id === "changelog");
136
+ const versionBump = steps.find((s) => s.id === "version-bump");
137
+ const testSuite = steps.find((s) => s.id === "test-suite");
138
+ const publish = steps.find((s) => s.id === "publish");
139
+
140
+ assert.ok(changelog, "Expected 'changelog' step");
141
+ assert.ok(versionBump, "Expected 'version-bump' step");
142
+ assert.ok(testSuite, "Expected 'test-suite' step");
143
+ assert.ok(publish, "Expected 'publish' step");
144
+
145
+ // Both version-bump and test-suite depend on changelog
146
+ const vbReqs = versionBump.requires as string[];
147
+ const tsReqs = testSuite.requires as string[];
148
+ assert.ok(vbReqs.includes("changelog"), "version-bump should require changelog");
149
+ assert.ok(tsReqs.includes("changelog"), "test-suite should require changelog");
150
+
151
+ // publish depends on both (diamond join)
152
+ const pubReqs = publish.requires as string[];
153
+ assert.ok(pubReqs.includes("version-bump"), "publish should require version-bump");
154
+ assert.ok(pubReqs.includes("test-suite"), "publish should require test-suite");
155
+
156
+ // publish uses human-review
157
+ const verify = publish.verify as Record<string, unknown>;
158
+ assert.equal(verify.policy, "human-review");
159
+ });
160
+
161
+ // ─── Cross-cutting: no path traversal in produces ───────────────────────
162
+
163
+ test("no produces path contains '..'", () => {
164
+ const files = [
165
+ "blog-post-pipeline.yaml",
166
+ "code-audit.yaml",
167
+ "release-checklist.yaml",
168
+ ];
169
+
170
+ for (const file of files) {
171
+ const parsed = loadYaml(file) as Record<string, unknown>;
172
+ const steps = parsed.steps as Array<Record<string, unknown>>;
173
+ for (const step of steps) {
174
+ const produces = (step.produces as string[]) ?? [];
175
+ for (const p of produces) {
176
+ assert.ok(!p.includes(".."), `${file} step "${step.id}" produces path contains '..': ${p}`);
177
+ }
178
+ }
179
+ }
180
+ });
@@ -0,0 +1,283 @@
1
+ /**
2
+ * commands-workflow-custom.test.ts — Tests for `/gsd workflow` subcommands
3
+ * and catalog completions.
4
+ *
5
+ * Uses real temp directories with actual definition YAML files.
6
+ */
7
+
8
+ import { describe, it, afterEach, before } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import {
11
+ mkdtempSync,
12
+ rmSync,
13
+ mkdirSync,
14
+ writeFileSync,
15
+ existsSync,
16
+ } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import { getGsdArgumentCompletions, TOP_LEVEL_SUBCOMMANDS } from "../commands/catalog.ts";
21
+
22
+ // ─── Helpers ─────────────────────────────────────────────────────────────
23
+
24
+ const tmpDirs: string[] = [];
25
+ let savedCwd: string;
26
+
27
+ function makeTmpBase(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "wf-cmd-test-"));
29
+ tmpDirs.push(dir);
30
+ return dir;
31
+ }
32
+
33
+ afterEach(() => {
34
+ // Restore cwd if changed during tests
35
+ if (savedCwd && process.cwd() !== savedCwd) {
36
+ process.chdir(savedCwd);
37
+ }
38
+ for (const d of tmpDirs) {
39
+ try { rmSync(d, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
40
+ }
41
+ tmpDirs.length = 0;
42
+ });
43
+
44
+ before(() => {
45
+ savedCwd = process.cwd();
46
+ });
47
+
48
+ function createMockCtx() {
49
+ const notifications: { message: string; level: string }[] = [];
50
+ return {
51
+ notifications,
52
+ ui: {
53
+ notify(message: string, level: string) {
54
+ notifications.push({ message, level });
55
+ },
56
+ custom: async () => {},
57
+ },
58
+ shutdown: async () => {},
59
+ sessionManager: {
60
+ getSessionFile: () => null,
61
+ },
62
+ };
63
+ }
64
+
65
+ function createMockPi() {
66
+ return {
67
+ registerCommand() {},
68
+ registerTool() {},
69
+ registerShortcut() {},
70
+ on() {},
71
+ sendMessage() {},
72
+ };
73
+ }
74
+
75
+ /** Write a minimal valid workflow definition YAML to the expected location. */
76
+ function writeDefinition(basePath: string, name: string, content: string): void {
77
+ const defsDir = join(basePath, ".gsd", "workflow-defs");
78
+ mkdirSync(defsDir, { recursive: true });
79
+ writeFileSync(join(defsDir, `${name}.yaml`), content, "utf-8");
80
+ }
81
+
82
+ const SIMPLE_DEF = `
83
+ version: 1
84
+ name: test-workflow
85
+ description: A test workflow
86
+ steps:
87
+ - id: step-1
88
+ name: First Step
89
+ prompt: Do step 1
90
+ requires: []
91
+ produces: []
92
+ `;
93
+
94
+ const INVALID_DEF = `
95
+ version: 2
96
+ name: bad-workflow
97
+ steps: []
98
+ `;
99
+
100
+ // ─── Catalog Registration ────────────────────────────────────────────────
101
+
102
+ describe("workflow catalog registration", () => {
103
+ it("workflow appears in TOP_LEVEL_SUBCOMMANDS", () => {
104
+ const entry = TOP_LEVEL_SUBCOMMANDS.find((c) => c.cmd === "workflow");
105
+ assert.ok(entry, "workflow should be in TOP_LEVEL_SUBCOMMANDS");
106
+ assert.ok(entry!.desc.includes("new"), "description should mention new");
107
+ assert.ok(entry!.desc.includes("run"), "description should mention run");
108
+ });
109
+
110
+ it("getGsdArgumentCompletions('workflow ') returns six subcommands", () => {
111
+ const completions = getGsdArgumentCompletions("workflow ");
112
+ const labels = completions.map((c: any) => c.label);
113
+ for (const sub of ["new", "run", "list", "validate", "pause", "resume"]) {
114
+ assert.ok(labels.includes(sub), `missing completion: ${sub}`);
115
+ }
116
+ assert.equal(labels.length, 6, "should have exactly 6 subcommands");
117
+ });
118
+
119
+ it("getGsdArgumentCompletions('workflow r') filters to run and resume", () => {
120
+ const completions = getGsdArgumentCompletions("workflow r");
121
+ const labels = completions.map((c: any) => c.label);
122
+ assert.ok(labels.includes("run"), "should include run");
123
+ assert.ok(labels.includes("resume"), "should include resume");
124
+ assert.ok(!labels.includes("list"), "should not include list");
125
+ });
126
+
127
+ it("getGsdArgumentCompletions('workflow run ') returns definition names", () => {
128
+ const base = makeTmpBase();
129
+ writeDefinition(base, "deploy-pipeline", SIMPLE_DEF);
130
+ writeDefinition(base, "test-suite", SIMPLE_DEF);
131
+
132
+ // Change cwd so the completion scanner can find `.gsd/workflow-defs/`
133
+ process.chdir(base);
134
+
135
+ const completions = getGsdArgumentCompletions("workflow run ");
136
+ const labels = completions.map((c: any) => c.label);
137
+ assert.ok(labels.includes("deploy-pipeline"), "should include deploy-pipeline");
138
+ assert.ok(labels.includes("test-suite"), "should include test-suite");
139
+ });
140
+
141
+ it("getGsdArgumentCompletions('workflow validate ') returns definition names", () => {
142
+ const base = makeTmpBase();
143
+ writeDefinition(base, "my-workflow", SIMPLE_DEF);
144
+
145
+ process.chdir(base);
146
+
147
+ const completions = getGsdArgumentCompletions("workflow validate ");
148
+ const labels = completions.map((c: any) => c.label);
149
+ assert.ok(labels.includes("my-workflow"), "should include my-workflow");
150
+ });
151
+
152
+ it("getGsdArgumentCompletions('workflow run d') filters by prefix", () => {
153
+ const base = makeTmpBase();
154
+ writeDefinition(base, "deploy-pipeline", SIMPLE_DEF);
155
+ writeDefinition(base, "test-suite", SIMPLE_DEF);
156
+
157
+ process.chdir(base);
158
+
159
+ const completions = getGsdArgumentCompletions("workflow run d");
160
+ const labels = completions.map((c: any) => c.label);
161
+ assert.ok(labels.includes("deploy-pipeline"), "should include deploy-pipeline");
162
+ assert.ok(!labels.includes("test-suite"), "should not include test-suite");
163
+ });
164
+ });
165
+
166
+ // ─── Command Handler Tests ───────────────────────────────────────────────
167
+
168
+ describe("workflow command handler", () => {
169
+ // Dynamically import the handler so module-level side effects
170
+ // don't break when auto.ts pulls in heavy runtime deps.
171
+ // We test the pure routing logic by calling handleWorkflowCommand directly.
172
+
173
+ async function callHandler(trimmed: string) {
174
+ const { handleWorkflowCommand } = await import("../commands/handlers/workflow.ts");
175
+ const ctx = createMockCtx();
176
+ const pi = createMockPi();
177
+ const handled = await handleWorkflowCommand(trimmed, ctx as any, pi as any);
178
+ return { handled, notifications: ctx.notifications };
179
+ }
180
+
181
+ it("bare '/gsd workflow' shows usage", async () => {
182
+ const { handled, notifications } = await callHandler("workflow");
183
+ assert.ok(handled, "should be handled");
184
+ assert.ok(
185
+ notifications.some((n) => n.message.includes("Usage: /gsd workflow")),
186
+ "should show usage",
187
+ );
188
+ });
189
+
190
+ it("'/gsd workflow new' shows skill invocation message", async () => {
191
+ const { handled, notifications } = await callHandler("workflow new");
192
+ assert.ok(handled, "should be handled");
193
+ assert.ok(
194
+ notifications.some((n) => n.message.includes("create-workflow")),
195
+ "should mention create-workflow skill",
196
+ );
197
+ });
198
+
199
+ it("'/gsd workflow run' without name shows usage warning", async () => {
200
+ const { handled, notifications } = await callHandler("workflow run");
201
+ assert.ok(handled, "should be handled");
202
+ assert.ok(
203
+ notifications.some((n) => n.level === "warning" && n.message.includes("Usage")),
204
+ "should show usage warning",
205
+ );
206
+ });
207
+
208
+ it("'/gsd workflow run nonexistent' shows error for missing definition", async () => {
209
+ const { handled, notifications } = await callHandler("workflow run nonexistent-def-12345");
210
+ assert.ok(handled, "should be handled");
211
+ assert.ok(
212
+ notifications.some((n) => n.level === "error" && n.message.includes("not found")),
213
+ "should show definition-not-found error",
214
+ );
215
+ });
216
+
217
+ it("'/gsd workflow validate' without name shows usage warning", async () => {
218
+ const { handled, notifications } = await callHandler("workflow validate");
219
+ assert.ok(handled, "should be handled");
220
+ assert.ok(
221
+ notifications.some((n) => n.level === "warning" && n.message.includes("Usage")),
222
+ "should show usage warning",
223
+ );
224
+ });
225
+
226
+ it("'/gsd workflow validate nonexistent' shows definition not found", async () => {
227
+ const { handled, notifications } = await callHandler("workflow validate nonexistent-def-12345");
228
+ assert.ok(handled, "should be handled");
229
+ assert.ok(
230
+ notifications.some((n) => n.level === "error" && n.message.includes("not found")),
231
+ "should show not-found error",
232
+ );
233
+ });
234
+
235
+ it("'/gsd workflow pause' without custom engine shows warning", async () => {
236
+ const { handled, notifications } = await callHandler("workflow pause");
237
+ assert.ok(handled, "should be handled");
238
+ assert.ok(
239
+ notifications.some((n) => n.level === "warning"),
240
+ "should show warning when no custom workflow is running",
241
+ );
242
+ });
243
+
244
+ it("'/gsd workflow resume' without custom engine shows warning", async () => {
245
+ const { handled, notifications } = await callHandler("workflow resume");
246
+ assert.ok(handled, "should be handled");
247
+ assert.ok(
248
+ notifications.some((n) => n.level === "warning"),
249
+ "should show warning when no custom workflow to resume",
250
+ );
251
+ });
252
+
253
+ it("'/gsd workflow unknown-sub' shows unknown subcommand", async () => {
254
+ const { handled, notifications } = await callHandler("workflow blurble");
255
+ assert.ok(handled, "should be handled");
256
+ assert.ok(
257
+ notifications.some((n) => n.message.includes("Unknown workflow subcommand")),
258
+ "should show unknown subcommand message",
259
+ );
260
+ });
261
+
262
+ it("'/gsd workflow list' with no runs shows empty message", async () => {
263
+ const { handled, notifications } = await callHandler("workflow list");
264
+ assert.ok(handled, "should be handled");
265
+ assert.ok(
266
+ notifications.some((n) => n.message.includes("No workflow runs found")),
267
+ "should show no runs message",
268
+ );
269
+ });
270
+
271
+ it("non-workflow commands are not intercepted by custom workflow routing", async () => {
272
+ const { handleWorkflowCommand } = await import("../commands/handlers/workflow.ts");
273
+ const ctx = createMockCtx();
274
+ const pi = createMockPi();
275
+ // "queue" does not start with "workflow" so the custom routing should not handle it.
276
+ // The function may still handle it via its existing dev-workflow routing, but it
277
+ // should not be captured by the custom workflow `if` block.
278
+ // We verify this by checking that a clearly non-workflow command like "somethingelse"
279
+ // returns false (unhandled).
280
+ const handled = await handleWorkflowCommand("somethingelse", ctx as any, pi as any);
281
+ assert.equal(handled, false, "non-workflow commands should return false");
282
+ });
283
+ });