gsd-pi 2.29.0-dev.7612840 → 2.29.0-dev.77f06e2

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 (62) hide show
  1. package/README.md +24 -17
  2. package/dist/resources/extensions/bg-shell/process-manager.ts +13 -0
  3. package/dist/resources/extensions/gsd/auto-dashboard.ts +186 -65
  4. package/dist/resources/extensions/gsd/auto-post-unit.ts +6 -3
  5. package/dist/resources/extensions/gsd/auto-recovery.ts +16 -22
  6. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +7 -6
  7. package/dist/resources/extensions/gsd/commands-handlers.ts +20 -1
  8. package/dist/resources/extensions/gsd/commands-logs.ts +13 -14
  9. package/dist/resources/extensions/gsd/commands-prefs-wizard.ts +44 -14
  10. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +544 -0
  11. package/dist/resources/extensions/gsd/commands.ts +53 -21
  12. package/dist/resources/extensions/gsd/json-persistence.ts +16 -1
  13. package/dist/resources/extensions/gsd/prompts/workflow-start.md +28 -0
  14. package/dist/resources/extensions/gsd/queue-order.ts +10 -11
  15. package/dist/resources/extensions/gsd/session-status-io.ts +23 -41
  16. package/dist/resources/extensions/gsd/tests/extension-selector-separator.test.ts +60 -38
  17. package/dist/resources/extensions/gsd/tests/workflow-templates.test.ts +173 -0
  18. package/dist/resources/extensions/gsd/workflow-templates/bugfix.md +87 -0
  19. package/dist/resources/extensions/gsd/workflow-templates/dep-upgrade.md +74 -0
  20. package/dist/resources/extensions/gsd/workflow-templates/full-project.md +41 -0
  21. package/dist/resources/extensions/gsd/workflow-templates/hotfix.md +45 -0
  22. package/dist/resources/extensions/gsd/workflow-templates/refactor.md +83 -0
  23. package/dist/resources/extensions/gsd/workflow-templates/registry.json +85 -0
  24. package/dist/resources/extensions/gsd/workflow-templates/security-audit.md +73 -0
  25. package/dist/resources/extensions/gsd/workflow-templates/small-feature.md +81 -0
  26. package/dist/resources/extensions/gsd/workflow-templates/spike.md +69 -0
  27. package/dist/resources/extensions/gsd/workflow-templates.ts +241 -0
  28. package/dist/resources/extensions/mcp-client/index.ts +459 -0
  29. package/package.json +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  31. package/packages/pi-coding-agent/dist/core/extensions/loader.js +13 -0
  32. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  33. package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -0
  34. package/src/resources/extensions/bg-shell/process-manager.ts +13 -0
  35. package/src/resources/extensions/gsd/auto-dashboard.ts +186 -65
  36. package/src/resources/extensions/gsd/auto-post-unit.ts +6 -3
  37. package/src/resources/extensions/gsd/auto-recovery.ts +16 -22
  38. package/src/resources/extensions/gsd/auto-worktree-sync.ts +7 -6
  39. package/src/resources/extensions/gsd/commands-handlers.ts +20 -1
  40. package/src/resources/extensions/gsd/commands-logs.ts +13 -14
  41. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +44 -14
  42. package/src/resources/extensions/gsd/commands-workflow-templates.ts +544 -0
  43. package/src/resources/extensions/gsd/commands.ts +53 -21
  44. package/src/resources/extensions/gsd/json-persistence.ts +16 -1
  45. package/src/resources/extensions/gsd/prompts/workflow-start.md +28 -0
  46. package/src/resources/extensions/gsd/queue-order.ts +10 -11
  47. package/src/resources/extensions/gsd/session-status-io.ts +23 -41
  48. package/src/resources/extensions/gsd/tests/extension-selector-separator.test.ts +60 -38
  49. package/src/resources/extensions/gsd/tests/workflow-templates.test.ts +173 -0
  50. package/src/resources/extensions/gsd/workflow-templates/bugfix.md +87 -0
  51. package/src/resources/extensions/gsd/workflow-templates/dep-upgrade.md +74 -0
  52. package/src/resources/extensions/gsd/workflow-templates/full-project.md +41 -0
  53. package/src/resources/extensions/gsd/workflow-templates/hotfix.md +45 -0
  54. package/src/resources/extensions/gsd/workflow-templates/refactor.md +83 -0
  55. package/src/resources/extensions/gsd/workflow-templates/registry.json +85 -0
  56. package/src/resources/extensions/gsd/workflow-templates/security-audit.md +73 -0
  57. package/src/resources/extensions/gsd/workflow-templates/small-feature.md +81 -0
  58. package/src/resources/extensions/gsd/workflow-templates/spike.md +69 -0
  59. package/src/resources/extensions/gsd/workflow-templates.ts +241 -0
  60. package/src/resources/extensions/mcp-client/index.ts +459 -0
  61. package/dist/resources/extensions/mcporter/index.ts +0 -525
  62. package/src/resources/extensions/mcporter/index.ts +0 -525
@@ -22,8 +22,6 @@ import {
22
22
  getProjectGSDPreferencesPath,
23
23
  loadEffectiveGSDPreferences,
24
24
  } from "./preferences.js";
25
- import { loadPrompt } from "./prompt-loader.js";
26
-
27
25
  import { handleRemote } from "../remote-questions/mod.js";
28
26
  import { handleQuick } from "./quick.js";
29
27
  import { handleHistory } from "./history.js";
@@ -45,26 +43,9 @@ import { handleInspect } from "./commands-inspect.js";
45
43
  import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
46
44
  import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
47
45
  import { handleLogs } from "./commands-logs.js";
46
+ import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
48
47
 
49
48
 
50
- export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
51
- const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
52
- const workflow = readFileSync(workflowPath, "utf-8");
53
- const prompt = loadPrompt("doctor-heal", {
54
- doctorSummary: reportText,
55
- structuredIssues,
56
- scopeLabel: scope ?? "active milestone / blocking scope",
57
- doctorCommandSuffix: scope ? ` ${scope}` : "",
58
- });
59
-
60
- const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`;
61
-
62
- pi.sendMessage(
63
- { customType: "gsd-doctor-heal", content, display: false },
64
- { triggerTurn: true },
65
- );
66
- }
67
-
68
49
  /** Resolve the effective project root, accounting for worktree paths. */
69
50
  export function projectRoot(): string {
70
51
  const root = resolveProjectRoot(process.cwd());
@@ -74,7 +55,7 @@ export function projectRoot(): string {
74
55
 
75
56
  export function registerGSDCommand(pi: ExtensionAPI): void {
76
57
  pi.registerCommand("gsd", {
77
- description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel|update",
58
+ description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel|update",
78
59
  getArgumentCompletions: (prefix: string) => {
79
60
  const subcommands = [
80
61
  { cmd: "help", desc: "Categorized command reference with descriptions" },
@@ -117,6 +98,8 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
117
98
  { cmd: "park", desc: "Park a milestone — skip without deleting" },
118
99
  { cmd: "unpark", desc: "Reactivate a parked milestone" },
119
100
  { cmd: "update", desc: "Update GSD to the latest version" },
101
+ { cmd: "start", desc: "Start a workflow template (bugfix, spike, feature, etc.)" },
102
+ { cmd: "templates", desc: "List available workflow templates" },
120
103
  ];
121
104
  const parts = prefix.trim().split(/\s+/);
122
105
 
@@ -302,6 +285,42 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
302
285
  .map((s) => ({ value: `knowledge ${s.cmd}`, label: s.cmd, description: s.desc }));
303
286
  }
304
287
 
288
+ if (parts[0] === "start" && parts.length <= 2) {
289
+ const subPrefix = parts[1] ?? "";
290
+ const subs = [
291
+ { cmd: "bugfix", desc: "Triage, fix, test, and ship a bug fix" },
292
+ { cmd: "small-feature", desc: "Lightweight feature with optional discussion" },
293
+ { cmd: "spike", desc: "Research, prototype, and document findings" },
294
+ { cmd: "hotfix", desc: "Minimal: fix it, test it, ship it" },
295
+ { cmd: "refactor", desc: "Inventory, plan waves, migrate, verify" },
296
+ { cmd: "security-audit", desc: "Scan, triage, remediate, re-scan" },
297
+ { cmd: "dep-upgrade", desc: "Assess, upgrade, fix breaks, verify" },
298
+ { cmd: "full-project", desc: "Complete GSD workflow with full ceremony" },
299
+ { cmd: "resume", desc: "Resume an in-progress workflow" },
300
+ { cmd: "--list", desc: "List all available templates" },
301
+ { cmd: "--dry-run", desc: "Preview workflow without executing" },
302
+ ];
303
+ return subs
304
+ .filter((s) => s.cmd.startsWith(subPrefix))
305
+ .map((s) => ({ value: `start ${s.cmd}`, label: s.cmd, description: s.desc }));
306
+ }
307
+
308
+ if (parts[0] === "templates" && parts.length <= 2) {
309
+ const subPrefix = parts[1] ?? "";
310
+ const subs = [
311
+ { cmd: "info", desc: "Show detailed template info" },
312
+ ];
313
+ return subs
314
+ .filter((s) => s.cmd.startsWith(subPrefix))
315
+ .map((s) => ({ value: `templates ${s.cmd}`, label: s.cmd, description: s.desc }));
316
+ }
317
+
318
+ if (parts[0] === "templates" && parts[1] === "info" && parts.length <= 3) {
319
+ const namePrefix = parts[2] ?? "";
320
+ return getTemplateCompletions(namePrefix)
321
+ .map((c) => ({ value: `templates ${c.value}`, label: c.label, description: c.description }));
322
+ }
323
+
305
324
  if (parts[0] === "doctor") {
306
325
  const modePrefix = parts[1] ?? "";
307
326
  const modes = [
@@ -793,6 +812,17 @@ Examples:
793
812
  return;
794
813
  }
795
814
 
815
+ // ─── Workflow Templates ────────────────────────────────────────
816
+ if (trimmed === "start" || trimmed.startsWith("start ")) {
817
+ await handleStart(trimmed.replace(/^start\s*/, "").trim(), ctx, pi);
818
+ return;
819
+ }
820
+
821
+ if (trimmed === "templates" || trimmed.startsWith("templates ")) {
822
+ await handleTemplates(trimmed.replace(/^templates\s*/, "").trim(), ctx);
823
+ return;
824
+ }
825
+
796
826
  if (trimmed === "") {
797
827
  // Bare /gsd defaults to step mode
798
828
  await startAuto(ctx, pi, projectRoot(), false, { step: true });
@@ -811,6 +841,8 @@ function showHelp(ctx: ExtensionCommandContext): void {
811
841
  const lines = [
812
842
  "GSD — Get Shit Done\n",
813
843
  "WORKFLOW",
844
+ " /gsd start <tpl> Start a workflow template (bugfix, spike, feature, hotfix, etc.)",
845
+ " /gsd templates List available workflow templates [info <name>]",
814
846
  " /gsd Run next unit in step mode (same as /gsd next)",
815
847
  " /gsd next Execute next task, then pause [--dry-run] [--verbose]",
816
848
  " /gsd auto Run all queued units continuously [--verbose]",
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
 
4
4
  /**
@@ -50,3 +50,18 @@ export function saveJsonFile<T>(filePath: string, data: T): void {
50
50
  // Non-fatal — don't let persistence failures break operation
51
51
  }
52
52
  }
53
+
54
+ /**
55
+ * Write a JSON file atomically (write to .tmp, then rename).
56
+ * Creates parent directories as needed. Non-fatal on error.
57
+ */
58
+ export function writeJsonFileAtomic<T>(filePath: string, data: T): void {
59
+ try {
60
+ mkdirSync(dirname(filePath), { recursive: true });
61
+ const tmp = filePath + ".tmp";
62
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
63
+ renameSync(tmp, filePath);
64
+ } catch {
65
+ // Non-fatal — don't let persistence failures break operation
66
+ }
67
+ }
@@ -0,0 +1,28 @@
1
+ # Workflow Template: {{templateName}}
2
+
3
+ You are executing a **{{templateName}}** workflow (template: `{{templateId}}`).
4
+
5
+ ## Context
6
+
7
+ - **Description:** {{description}}
8
+ - **Issue reference:** {{issueRef}}
9
+ - **Date:** {{date}}
10
+ - **Branch:** {{branch}}
11
+ - **Artifact directory:** {{artifactDir}}
12
+ - **Phases:** {{phases}}
13
+ - **Complexity:** {{complexity}}
14
+
15
+ ## Workflow Definition
16
+
17
+ Follow the workflow defined below. Execute each phase in order, completing one before moving to the next. At each phase gate, confirm with the user before proceeding.
18
+
19
+ {{workflowContent}}
20
+
21
+ ## Execution Rules
22
+
23
+ 1. **Follow the phases in order.** Do not skip phases unless the workflow explicitly allows it.
24
+ 2. **Artifact discipline.** If an artifact directory is specified, write all planning/summary documents there.
25
+ 3. **Atomic commits.** Commit working code after each meaningful change. Use conventional commit format: `<type>(<scope>): <description>`.
26
+ 4. **Verify before shipping.** Run the project's test suite and build before marking the workflow complete.
27
+ 5. **Gate between phases.** After each phase, summarize what was done and ask the user to confirm before moving to the next phase.
28
+ 6. **Stay focused.** This is a {{complexity}}-complexity workflow. Match your ceremony level to the task — don't over-engineer or under-deliver.
@@ -9,10 +9,10 @@
9
9
  * survives branch switches and is shared across sessions.
10
10
  */
11
11
 
12
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
13
12
  import { join } from "node:path";
14
13
  import { gsdRoot } from "./paths.js";
15
14
  import { milestoneIdSort } from "./milestone-ids.js";
15
+ import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
16
16
 
17
17
  // ─── Types ───────────────────────────────────────────────────────────────────
18
18
 
@@ -45,6 +45,12 @@ function queueOrderPath(basePath: string): string {
45
45
  return join(gsdRoot(basePath), "QUEUE-ORDER.json");
46
46
  }
47
47
 
48
+ // ─── Type Guards ─────────────────────────────────────────────────────────────
49
+
50
+ function isQueueOrderFile(data: unknown): data is QueueOrderFile {
51
+ return data !== null && typeof data === "object" && "order" in data! && Array.isArray((data as QueueOrderFile).order);
52
+ }
53
+
48
54
  // ─── Read / Write ────────────────────────────────────────────────────────────
49
55
 
50
56
  /**
@@ -52,15 +58,8 @@ function queueOrderPath(basePath: string): string {
52
58
  * the file is corrupt/unreadable.
53
59
  */
54
60
  export function loadQueueOrder(basePath: string): string[] | null {
55
- const p = queueOrderPath(basePath);
56
- if (!existsSync(p)) return null;
57
- try {
58
- const data: QueueOrderFile = JSON.parse(readFileSync(p, "utf-8"));
59
- if (!Array.isArray(data.order)) return null;
60
- return data.order;
61
- } catch {
62
- return null;
63
- }
61
+ const data = loadJsonFileOrNull(queueOrderPath(basePath), isQueueOrderFile);
62
+ return data?.order ?? null;
64
63
  }
65
64
 
66
65
  /**
@@ -71,7 +70,7 @@ export function saveQueueOrder(basePath: string, order: string[]): void {
71
70
  order,
72
71
  updatedAt: new Date().toISOString(),
73
72
  };
74
- writeFileSync(queueOrderPath(basePath), JSON.stringify(data, null, 2) + "\n", "utf-8");
73
+ saveJsonFile(queueOrderPath(basePath), data);
75
74
  }
76
75
 
77
76
  // ─── Sorting ─────────────────────────────────────────────────────────────────
@@ -11,9 +11,6 @@
11
11
  */
12
12
 
13
13
  import {
14
- writeFileSync,
15
- readFileSync,
16
- renameSync,
17
14
  unlinkSync,
18
15
  readdirSync,
19
16
  mkdirSync,
@@ -21,6 +18,7 @@ import {
21
18
  } from "node:fs";
22
19
  import { join } from "node:path";
23
20
  import { gsdRoot } from "./paths.js";
21
+ import { loadJsonFileOrNull, writeJsonFileAtomic } from "./json-persistence.js";
24
22
 
25
23
  // ─── Types ─────────────────────────────────────────────────────────────────
26
24
 
@@ -49,9 +47,16 @@ export interface SignalMessage {
49
47
  const PARALLEL_DIR = "parallel";
50
48
  const STATUS_SUFFIX = ".status.json";
51
49
  const SIGNAL_SUFFIX = ".signal.json";
52
- const TMP_SUFFIX = ".tmp";
53
50
  const DEFAULT_STALE_TIMEOUT_MS = 30_000;
54
51
 
52
+ function isSessionStatus(data: unknown): data is SessionStatus {
53
+ return data !== null && typeof data === "object" && "milestoneId" in data && "pid" in data;
54
+ }
55
+
56
+ function isSignalMessage(data: unknown): data is SignalMessage {
57
+ return data !== null && typeof data === "object" && "signal" in data && "sentAt" in data;
58
+ }
59
+
55
60
  // ─── Helpers ───────────────────────────────────────────────────────────────
56
61
 
57
62
  function parallelDir(basePath: string): string {
@@ -86,25 +91,13 @@ function isPidAlive(pid: number): boolean {
86
91
 
87
92
  /** Write session status atomically (write to .tmp, then rename). */
88
93
  export function writeSessionStatus(basePath: string, status: SessionStatus): void {
89
- try {
90
- ensureParallelDir(basePath);
91
- const dest = statusPath(basePath, status.milestoneId);
92
- const tmp = dest + TMP_SUFFIX;
93
- writeFileSync(tmp, JSON.stringify(status, null, 2), "utf-8");
94
- renameSync(tmp, dest);
95
- } catch { /* non-fatal */ }
94
+ ensureParallelDir(basePath);
95
+ writeJsonFileAtomic(statusPath(basePath, status.milestoneId), status);
96
96
  }
97
97
 
98
98
  /** Read a specific milestone's session status. */
99
99
  export function readSessionStatus(basePath: string, milestoneId: string): SessionStatus | null {
100
- try {
101
- const p = statusPath(basePath, milestoneId);
102
- if (!existsSync(p)) return null;
103
- const raw = readFileSync(p, "utf-8");
104
- return JSON.parse(raw) as SessionStatus;
105
- } catch {
106
- return null;
107
- }
100
+ return loadJsonFileOrNull(statusPath(basePath, milestoneId), isSessionStatus);
108
101
  }
109
102
 
110
103
  /** Read all session status files from .gsd/parallel/. */
@@ -114,13 +107,10 @@ export function readAllSessionStatuses(basePath: string): SessionStatus[] {
114
107
 
115
108
  const results: SessionStatus[] = [];
116
109
  try {
117
- const entries = readdirSync(dir);
118
- for (const entry of entries) {
110
+ for (const entry of readdirSync(dir)) {
119
111
  if (!entry.endsWith(STATUS_SUFFIX)) continue;
120
- try {
121
- const raw = readFileSync(join(dir, entry), "utf-8");
122
- results.push(JSON.parse(raw) as SessionStatus);
123
- } catch { /* skip corrupt files */ }
112
+ const status = loadJsonFileOrNull(join(dir, entry), isSessionStatus);
113
+ if (status) results.push(status);
124
114
  }
125
115
  } catch { /* non-fatal */ }
126
116
  return results;
@@ -138,27 +128,19 @@ export function removeSessionStatus(basePath: string, milestoneId: string): void
138
128
 
139
129
  /** Write a signal file for a worker to consume. */
140
130
  export function sendSignal(basePath: string, milestoneId: string, signal: SessionSignal): void {
141
- try {
142
- ensureParallelDir(basePath);
143
- const dest = signalPath(basePath, milestoneId);
144
- const tmp = dest + TMP_SUFFIX;
145
- const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" };
146
- writeFileSync(tmp, JSON.stringify(msg, null, 2), "utf-8");
147
- renameSync(tmp, dest);
148
- } catch { /* non-fatal */ }
131
+ ensureParallelDir(basePath);
132
+ const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" };
133
+ writeJsonFileAtomic(signalPath(basePath, milestoneId), msg);
149
134
  }
150
135
 
151
136
  /** Read and delete a signal file (atomic consume). Returns null if no signal pending. */
152
137
  export function consumeSignal(basePath: string, milestoneId: string): SignalMessage | null {
153
- try {
154
- const p = signalPath(basePath, milestoneId);
155
- if (!existsSync(p)) return null;
156
- const raw = readFileSync(p, "utf-8");
157
- unlinkSync(p);
158
- return JSON.parse(raw) as SignalMessage;
159
- } catch {
160
- return null;
138
+ const p = signalPath(basePath, milestoneId);
139
+ const msg = loadJsonFileOrNull(p, isSignalMessage);
140
+ if (msg) {
141
+ try { unlinkSync(p); } catch { /* non-fatal */ }
161
142
  }
143
+ return msg;
162
144
  }
163
145
 
164
146
  // ─── Stale Detection ───────────────────────────────────────────────────────
@@ -1,4 +1,6 @@
1
- // Tests for the SEPARATOR_PREFIX convention used by ExtensionSelectorComponent.
1
+ // Tests for the SEPARATOR_PREFIX convention used by ExtensionSelectorComponent
2
+ // and the two-step provider→model picker in configureModels.
3
+ //
2
4
  // We cannot import the component directly in node:test because its transitive
3
5
  // dependency (countdown-timer.ts) uses TypeScript parameter properties which
4
6
  // are unsupported under --experimental-strip-types. Instead we duplicate the
@@ -69,16 +71,17 @@ describe("separator detection", () => {
69
71
  });
70
72
  });
71
73
 
72
- describe("model grouping", () => {
73
- test("groups models by provider with separator headers", () => {
74
- // Simulate the grouping logic from configureModels
75
- const availableModels = [
76
- { id: "claude-opus-4-6", provider: "anthropic" },
77
- { id: "gpt-4o", provider: "openai" },
78
- { id: "claude-sonnet-4-5", provider: "anthropic" },
79
- { id: "o3-mini", provider: "openai" },
80
- ];
74
+ describe("two-step provider→model picker", () => {
75
+ // Simulate the grouping logic from configureModels
76
+ const availableModels = [
77
+ { id: "claude-opus-4-6", provider: "anthropic" },
78
+ { id: "gpt-4o", provider: "openai" },
79
+ { id: "claude-sonnet-4-5", provider: "anthropic" },
80
+ { id: "o3-mini", provider: "openai" },
81
+ { id: "claude-haiku-4-5", provider: "anthropic" },
82
+ ];
81
83
 
84
+ function buildProviderGroups() {
82
85
  const byProvider = new Map<string, typeof availableModels>();
83
86
  for (const m of availableModels) {
84
87
  let group = byProvider.get(m.provider);
@@ -89,34 +92,53 @@ describe("model grouping", () => {
89
92
  group.push(m);
90
93
  }
91
94
  const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b));
92
-
93
- const modelOptions: string[] = [];
94
- for (const provider of providers) {
95
- const group = byProvider.get(provider)!;
96
- modelOptions.push(`${SEPARATOR_PREFIX} ${provider} (${group.length}) ${SEPARATOR_PREFIX}`);
97
- for (const m of group) {
98
- modelOptions.push(`${m.id} · ${m.provider}`);
99
- }
95
+ for (const group of byProvider.values()) {
96
+ group.sort((a, b) => a.id.localeCompare(b.id));
100
97
  }
101
- modelOptions.push("(keep current)", "(clear)");
102
-
103
- // Verify structure
104
- assert.strictEqual(modelOptions[0], `${SEPARATOR_PREFIX} anthropic (2) ${SEPARATOR_PREFIX}`);
105
- assert.strictEqual(modelOptions[1], "claude-opus-4-6 · anthropic");
106
- assert.strictEqual(modelOptions[2], "claude-sonnet-4-5 · anthropic");
107
- assert.strictEqual(modelOptions[3], `${SEPARATOR_PREFIX} openai (2) ${SEPARATOR_PREFIX}`);
108
- assert.strictEqual(modelOptions[4], "gpt-4o · openai");
109
- assert.strictEqual(modelOptions[5], "o3-mini · openai");
110
- assert.strictEqual(modelOptions[6], "(keep current)");
111
- assert.strictEqual(modelOptions[7], "(clear)");
112
-
113
- // Verify separators are correctly detected
114
- assert.ok(isSeparator(modelOptions, 0));
115
- assert.ok(!isSeparator(modelOptions, 1));
116
- assert.ok(isSeparator(modelOptions, 3));
117
- assert.ok(!isSeparator(modelOptions, 6));
118
-
119
- // Verify first selectable is index 1, not the separator at 0
120
- assert.strictEqual(nextSelectable(modelOptions, 0, 1), 1);
98
+ return { byProvider, providers };
99
+ }
100
+
101
+ test("provider menu lists providers with model counts", () => {
102
+ const { providers, byProvider } = buildProviderGroups();
103
+ const providerOptions = providers.map(p => {
104
+ const count = byProvider.get(p)!.length;
105
+ return `${p} (${count} models)`;
106
+ });
107
+ providerOptions.push("(keep current)", "(clear)", "(type manually)");
108
+
109
+ assert.strictEqual(providerOptions[0], "anthropic (3 models)");
110
+ assert.strictEqual(providerOptions[1], "openai (2 models)");
111
+ assert.strictEqual(providerOptions[2], "(keep current)");
112
+ assert.strictEqual(providerOptions[3], "(clear)");
113
+ assert.strictEqual(providerOptions[4], "(type manually)");
114
+ });
115
+
116
+ test("model menu for a provider is sorted alphabetically", () => {
117
+ const { byProvider } = buildProviderGroups();
118
+ const anthropicModels = byProvider.get("anthropic")!;
119
+ const modelOptions = anthropicModels.map(m => m.id);
120
+
121
+ assert.strictEqual(modelOptions[0], "claude-haiku-4-5");
122
+ assert.strictEqual(modelOptions[1], "claude-opus-4-6");
123
+ assert.strictEqual(modelOptions[2], "claude-sonnet-4-5");
124
+ });
125
+
126
+ test("provider name is extracted correctly from choice string", () => {
127
+ const choice = "anthropic (3 models)";
128
+ const providerName = choice.replace(/ \(\d+ models?\)$/, "");
129
+ assert.strictEqual(providerName, "anthropic");
130
+
131
+ const singleChoice = "ollama (1 model)";
132
+ const singleProvider = singleChoice.replace(/ \(\d+ models?\)$/, "");
133
+ assert.strictEqual(singleProvider, "ollama");
134
+ });
135
+
136
+ test("openai models are sorted within their group", () => {
137
+ const { byProvider } = buildProviderGroups();
138
+ const openaiModels = byProvider.get("openai")!;
139
+ const modelOptions = openaiModels.map(m => m.id);
140
+
141
+ assert.strictEqual(modelOptions[0], "gpt-4o");
142
+ assert.strictEqual(modelOptions[1], "o3-mini");
121
143
  });
122
144
  });
@@ -0,0 +1,173 @@
1
+ // GSD Workflow Templates — Unit Tests
2
+ //
3
+ // Tests registry loading, template resolution, auto-detection, and listing.
4
+
5
+ import { createTestContext } from './test-helpers.ts';
6
+ import {
7
+ loadRegistry,
8
+ resolveByName,
9
+ autoDetect,
10
+ listTemplates,
11
+ getTemplateInfo,
12
+ loadWorkflowTemplate,
13
+ } from '../workflow-templates.ts';
14
+
15
+ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
16
+
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+ // Registry Loading
19
+ // ═══════════════════════════════════════════════════════════════════════════
20
+
21
+ console.log('\n── Registry Loading ──');
22
+
23
+ {
24
+ const registry = loadRegistry();
25
+ assertTrue(registry !== null, 'Registry should load');
26
+ assertEq(registry.version, 1, 'Registry version should be 1');
27
+ assertTrue(Object.keys(registry.templates).length >= 8, 'Should have at least 8 templates');
28
+
29
+ // Verify required template keys exist
30
+ const expectedIds = ['full-project', 'bugfix', 'small-feature', 'refactor', 'spike', 'hotfix', 'security-audit', 'dep-upgrade'];
31
+ for (const id of expectedIds) {
32
+ assertTrue(id in registry.templates, `Template "${id}" should exist in registry`);
33
+ }
34
+
35
+ // Verify each template has required fields
36
+ for (const [id, entry] of Object.entries(registry.templates)) {
37
+ assertTrue(typeof entry.name === 'string' && entry.name.length > 0, `${id}: name should be non-empty string`);
38
+ assertTrue(typeof entry.description === 'string' && entry.description.length > 0, `${id}: description should be non-empty`);
39
+ assertTrue(typeof entry.file === 'string' && entry.file.endsWith('.md'), `${id}: file should be a .md path`);
40
+ assertTrue(Array.isArray(entry.phases) && entry.phases.length > 0, `${id}: phases should be non-empty array`);
41
+ assertTrue(Array.isArray(entry.triggers) && entry.triggers.length > 0, `${id}: triggers should be non-empty array`);
42
+ }
43
+ }
44
+
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+ // Resolve by Name
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+
49
+ console.log('\n── Resolve by Name ──');
50
+
51
+ {
52
+ // Exact match
53
+ const bugfix = resolveByName('bugfix');
54
+ assertTrue(bugfix !== null, 'Should resolve "bugfix"');
55
+ assertEq(bugfix!.id, 'bugfix', 'ID should be "bugfix"');
56
+ assertEq(bugfix!.confidence, 'exact', 'Exact name should have exact confidence');
57
+
58
+ // Case-insensitive name match
59
+ const spike = resolveByName('Research Spike');
60
+ assertTrue(spike !== null, 'Should resolve "Research Spike" by name');
61
+ assertEq(spike!.id, 'spike', 'Should resolve to spike');
62
+
63
+ // Alias match
64
+ const bug = resolveByName('bug');
65
+ assertTrue(bug !== null, 'Should resolve "bug" alias');
66
+ assertEq(bug!.id, 'bugfix', 'Alias "bug" should map to bugfix');
67
+
68
+ const feat = resolveByName('feat');
69
+ assertTrue(feat !== null, 'Should resolve "feat" alias');
70
+ assertEq(feat!.id, 'small-feature', 'Alias "feat" should map to small-feature');
71
+
72
+ const deps = resolveByName('deps');
73
+ assertTrue(deps !== null, 'Should resolve "deps" alias');
74
+ assertEq(deps!.id, 'dep-upgrade', 'Alias "deps" should map to dep-upgrade');
75
+
76
+ // No match
77
+ const missing = resolveByName('nonexistent-template');
78
+ assertTrue(missing === null, 'Should return null for unknown template');
79
+ }
80
+
81
+ // ═══════════════════════════════════════════════════════════════════════════
82
+ // Auto-Detection
83
+ // ═══════════════════════════════════════════════════════════════════════════
84
+
85
+ console.log('\n── Auto-Detection ──');
86
+
87
+ {
88
+ // Should detect bugfix from "fix" keyword
89
+ const fixMatches = autoDetect('fix the login button');
90
+ assertTrue(fixMatches.length > 0, 'Should detect matches for "fix the login button"');
91
+ assertTrue(fixMatches.some(m => m.id === 'bugfix'), 'Should include bugfix in matches');
92
+
93
+ // Should detect spike from "research" keyword
94
+ const researchMatches = autoDetect('research authentication libraries');
95
+ assertTrue(researchMatches.length > 0, 'Should detect matches for "research"');
96
+ assertTrue(researchMatches.some(m => m.id === 'spike'), 'Should include spike in matches');
97
+
98
+ // Should detect hotfix from "urgent" keyword
99
+ const urgentMatches = autoDetect('urgent production is down');
100
+ assertTrue(urgentMatches.length > 0, 'Should detect matches for "urgent"');
101
+ assertTrue(urgentMatches.some(m => m.id === 'hotfix'), 'Should include hotfix in matches');
102
+
103
+ // Should detect dep-upgrade from "upgrade" keyword
104
+ const upgradeMatches = autoDetect('upgrade react to v19');
105
+ assertTrue(upgradeMatches.length > 0, 'Should detect matches for "upgrade"');
106
+ assertTrue(upgradeMatches.some(m => m.id === 'dep-upgrade'), 'Should include dep-upgrade in matches');
107
+
108
+ // Multi-word triggers should score higher
109
+ const projectMatches = autoDetect('create a new project from scratch');
110
+ const projectMatch = projectMatches.find(m => m.id === 'full-project');
111
+ assertTrue(projectMatch !== undefined, 'Should detect full-project for "from scratch"');
112
+
113
+ // Empty input should return no matches
114
+ const emptyMatches = autoDetect('');
115
+ assertEq(emptyMatches.length, 0, 'Empty input should return no matches');
116
+ }
117
+
118
+ // ═══════════════════════════════════════════════════════════════════════════
119
+ // List Templates
120
+ // ═══════════════════════════════════════════════════════════════════════════
121
+
122
+ console.log('\n── List Templates ──');
123
+
124
+ {
125
+ const output = listTemplates();
126
+ assertTrue(output.includes('Workflow Templates'), 'Should have header');
127
+ assertTrue(output.includes('bugfix'), 'Should list bugfix');
128
+ assertTrue(output.includes('spike'), 'Should list spike');
129
+ assertTrue(output.includes('hotfix'), 'Should list hotfix');
130
+ assertTrue(output.includes('/gsd start'), 'Should include usage hint');
131
+ }
132
+
133
+ // ═══════════════════════════════════════════════════════════════════════════
134
+ // Template Info
135
+ // ═══════════════════════════════════════════════════════════════════════════
136
+
137
+ console.log('\n── Template Info ──');
138
+
139
+ {
140
+ const info = getTemplateInfo('bugfix');
141
+ assertTrue(info !== null, 'Should return info for bugfix');
142
+ assertTrue(info!.includes('Bug Fix'), 'Should include template name');
143
+ assertTrue(info!.includes('triage'), 'Should include phase names');
144
+ assertTrue(info!.includes('Triggers'), 'Should include triggers section');
145
+
146
+ const missing = getTemplateInfo('nonexistent');
147
+ assertTrue(missing === null, 'Should return null for unknown template');
148
+ }
149
+
150
+ // ═══════════════════════════════════════════════════════════════════════════
151
+ // Load Workflow Template Content
152
+ // ═══════════════════════════════════════════════════════════════════════════
153
+
154
+ console.log('\n── Load Workflow Template ──');
155
+
156
+ {
157
+ const content = loadWorkflowTemplate('bugfix');
158
+ assertTrue(content !== null, 'Should load bugfix template');
159
+ assertTrue(content!.includes('Bugfix Workflow'), 'Should contain workflow title');
160
+ assertTrue(content!.includes('Phase 1: Triage'), 'Should contain triage phase');
161
+ assertTrue(content!.includes('Phase 4: Ship'), 'Should contain ship phase');
162
+
163
+ const hotfixContent = loadWorkflowTemplate('hotfix');
164
+ assertTrue(hotfixContent !== null, 'Should load hotfix template');
165
+ assertTrue(hotfixContent!.includes('Hotfix Workflow'), 'Should contain hotfix title');
166
+
167
+ const missingContent = loadWorkflowTemplate('nonexistent');
168
+ assertTrue(missingContent === null, 'Should return null for unknown template');
169
+ }
170
+
171
+ // ═══════════════════════════════════════════════════════════════════════════
172
+
173
+ report();