stoa-mcp 0.1.0

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 (167) hide show
  1. package/LICENSE +65 -0
  2. package/README.md +397 -0
  3. package/dist/cli/build.d.ts +39 -0
  4. package/dist/cli/build.d.ts.map +1 -0
  5. package/dist/cli/build.js +288 -0
  6. package/dist/cli/build.js.map +1 -0
  7. package/dist/cli/review-loop.d.ts +2 -0
  8. package/dist/cli/review-loop.d.ts.map +1 -0
  9. package/dist/cli/review-loop.js +97 -0
  10. package/dist/cli/review-loop.js.map +1 -0
  11. package/dist/cli/scenarios-runner.d.ts +12 -0
  12. package/dist/cli/scenarios-runner.d.ts.map +1 -0
  13. package/dist/cli/scenarios-runner.js +158 -0
  14. package/dist/cli/scenarios-runner.js.map +1 -0
  15. package/dist/cli/verify.d.ts +13 -0
  16. package/dist/cli/verify.d.ts.map +1 -0
  17. package/dist/cli/verify.js +149 -0
  18. package/dist/cli/verify.js.map +1 -0
  19. package/dist/cli.d.ts +3 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +1135 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/core/index.d.ts +3 -0
  24. package/dist/core/index.d.ts.map +1 -0
  25. package/dist/core/index.js +2 -0
  26. package/dist/core/index.js.map +1 -0
  27. package/dist/core/parsers.d.ts +29 -0
  28. package/dist/core/parsers.d.ts.map +1 -0
  29. package/dist/core/parsers.js +296 -0
  30. package/dist/core/parsers.js.map +1 -0
  31. package/dist/core/parsers.test.d.ts +2 -0
  32. package/dist/core/parsers.test.d.ts.map +1 -0
  33. package/dist/core/parsers.test.js +198 -0
  34. package/dist/core/parsers.test.js.map +1 -0
  35. package/dist/core/prompts.d.ts +30 -0
  36. package/dist/core/prompts.d.ts.map +1 -0
  37. package/dist/core/prompts.js +346 -0
  38. package/dist/core/prompts.js.map +1 -0
  39. package/dist/core/refine.d.ts +38 -0
  40. package/dist/core/refine.d.ts.map +1 -0
  41. package/dist/core/refine.js +233 -0
  42. package/dist/core/refine.js.map +1 -0
  43. package/dist/core/spec-score.d.ts +17 -0
  44. package/dist/core/spec-score.d.ts.map +1 -0
  45. package/dist/core/spec-score.js +59 -0
  46. package/dist/core/spec-score.js.map +1 -0
  47. package/dist/formatters/index.d.ts +2 -0
  48. package/dist/formatters/index.d.ts.map +1 -0
  49. package/dist/formatters/index.js +2 -0
  50. package/dist/formatters/index.js.map +1 -0
  51. package/dist/formatters/stage-formatters.d.ts +10 -0
  52. package/dist/formatters/stage-formatters.d.ts.map +1 -0
  53. package/dist/formatters/stage-formatters.js +100 -0
  54. package/dist/formatters/stage-formatters.js.map +1 -0
  55. package/dist/formatters/stage-formatters.test.d.ts +2 -0
  56. package/dist/formatters/stage-formatters.test.d.ts.map +1 -0
  57. package/dist/formatters/stage-formatters.test.js +107 -0
  58. package/dist/formatters/stage-formatters.test.js.map +1 -0
  59. package/dist/guardrails/index.d.ts +2 -0
  60. package/dist/guardrails/index.d.ts.map +1 -0
  61. package/dist/guardrails/index.js +2 -0
  62. package/dist/guardrails/index.js.map +1 -0
  63. package/dist/guardrails/loader.d.ts +9 -0
  64. package/dist/guardrails/loader.d.ts.map +1 -0
  65. package/dist/guardrails/loader.js +56 -0
  66. package/dist/guardrails/loader.js.map +1 -0
  67. package/dist/guardrails/refine.d.ts +53 -0
  68. package/dist/guardrails/refine.d.ts.map +1 -0
  69. package/dist/guardrails/refine.js +184 -0
  70. package/dist/guardrails/refine.js.map +1 -0
  71. package/dist/index.d.ts +6 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +196 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/storage/change-detection.d.ts +2 -0
  76. package/dist/storage/change-detection.d.ts.map +1 -0
  77. package/dist/storage/change-detection.js +47 -0
  78. package/dist/storage/change-detection.js.map +1 -0
  79. package/dist/storage/change-detection.test.d.ts +2 -0
  80. package/dist/storage/change-detection.test.d.ts.map +1 -0
  81. package/dist/storage/change-detection.test.js +81 -0
  82. package/dist/storage/change-detection.test.js.map +1 -0
  83. package/dist/storage/index.d.ts +9 -0
  84. package/dist/storage/index.d.ts.map +1 -0
  85. package/dist/storage/index.js +6 -0
  86. package/dist/storage/index.js.map +1 -0
  87. package/dist/storage/moodboard-describe.d.ts +14 -0
  88. package/dist/storage/moodboard-describe.d.ts.map +1 -0
  89. package/dist/storage/moodboard-describe.js +185 -0
  90. package/dist/storage/moodboard-describe.js.map +1 -0
  91. package/dist/storage/moodboard-sync.d.ts +11 -0
  92. package/dist/storage/moodboard-sync.d.ts.map +1 -0
  93. package/dist/storage/moodboard-sync.js +205 -0
  94. package/dist/storage/moodboard-sync.js.map +1 -0
  95. package/dist/storage/moodboard.d.ts +4 -0
  96. package/dist/storage/moodboard.d.ts.map +1 -0
  97. package/dist/storage/moodboard.js +68 -0
  98. package/dist/storage/moodboard.js.map +1 -0
  99. package/dist/storage/moodboard.test.d.ts +2 -0
  100. package/dist/storage/moodboard.test.d.ts.map +1 -0
  101. package/dist/storage/moodboard.test.js +133 -0
  102. package/dist/storage/moodboard.test.js.map +1 -0
  103. package/dist/storage/project-scan.d.ts +12 -0
  104. package/dist/storage/project-scan.d.ts.map +1 -0
  105. package/dist/storage/project-scan.js +118 -0
  106. package/dist/storage/project-scan.js.map +1 -0
  107. package/dist/storage/project.d.ts +10 -0
  108. package/dist/storage/project.d.ts.map +1 -0
  109. package/dist/storage/project.js +101 -0
  110. package/dist/storage/project.js.map +1 -0
  111. package/dist/storage/roles-refine.d.ts +59 -0
  112. package/dist/storage/roles-refine.d.ts.map +1 -0
  113. package/dist/storage/roles-refine.js +223 -0
  114. package/dist/storage/roles-refine.js.map +1 -0
  115. package/dist/storage/roles.d.ts +6 -0
  116. package/dist/storage/roles.d.ts.map +1 -0
  117. package/dist/storage/roles.js +41 -0
  118. package/dist/storage/roles.js.map +1 -0
  119. package/dist/storage/scenarios-refine.d.ts +47 -0
  120. package/dist/storage/scenarios-refine.d.ts.map +1 -0
  121. package/dist/storage/scenarios-refine.js +311 -0
  122. package/dist/storage/scenarios-refine.js.map +1 -0
  123. package/dist/storage/scenarios.d.ts +12 -0
  124. package/dist/storage/scenarios.d.ts.map +1 -0
  125. package/dist/storage/scenarios.js +37 -0
  126. package/dist/storage/scenarios.js.map +1 -0
  127. package/dist/storage/specs.d.ts +17 -0
  128. package/dist/storage/specs.d.ts.map +1 -0
  129. package/dist/storage/specs.js +104 -0
  130. package/dist/storage/specs.js.map +1 -0
  131. package/dist/tools/index.d.ts +2 -0
  132. package/dist/tools/index.d.ts.map +1 -0
  133. package/dist/tools/index.js +2 -0
  134. package/dist/tools/index.js.map +1 -0
  135. package/dist/tools/rerefine.d.ts +8 -0
  136. package/dist/tools/rerefine.d.ts.map +1 -0
  137. package/dist/tools/rerefine.js +153 -0
  138. package/dist/tools/rerefine.js.map +1 -0
  139. package/dist/tools/rerefine.test.d.ts +2 -0
  140. package/dist/tools/rerefine.test.d.ts.map +1 -0
  141. package/dist/tools/rerefine.test.js +123 -0
  142. package/dist/tools/rerefine.test.js.map +1 -0
  143. package/dist/utils/index.d.ts +3 -0
  144. package/dist/utils/index.d.ts.map +1 -0
  145. package/dist/utils/index.js +3 -0
  146. package/dist/utils/index.js.map +1 -0
  147. package/dist/utils/slug.d.ts +3 -0
  148. package/dist/utils/slug.d.ts.map +1 -0
  149. package/dist/utils/slug.js +33 -0
  150. package/dist/utils/slug.js.map +1 -0
  151. package/dist/utils/spec-helpers.d.ts +12 -0
  152. package/dist/utils/spec-helpers.d.ts.map +1 -0
  153. package/dist/utils/spec-helpers.js +95 -0
  154. package/dist/utils/spec-helpers.js.map +1 -0
  155. package/dist/utils/spec-helpers.test.d.ts +2 -0
  156. package/dist/utils/spec-helpers.test.d.ts.map +1 -0
  157. package/dist/utils/spec-helpers.test.js +114 -0
  158. package/dist/utils/spec-helpers.test.js.map +1 -0
  159. package/package.json +53 -0
  160. package/templates/guardrails/ask-when-unclear.md +3 -0
  161. package/templates/guardrails/dont-delete-code.md +3 -0
  162. package/templates/guardrails/explain-changes.md +3 -0
  163. package/templates/guardrails/run-tests.md +3 -0
  164. package/templates/guardrails/small-changes.md +3 -0
  165. package/templates/roles/builder.md +3 -0
  166. package/templates/roles/fixer.md +3 -0
  167. package/templates/roles/planner.md +3 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1135 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import { readFile, writeFile, mkdir, access } from "node:fs/promises";
6
+ import { basename, join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { existsSync, readFileSync, constants } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import { refinePipeline } from "./core/refine.js";
11
+ import { computeSpecScore } from "./core/spec-score.js";
12
+ import { initProject, loadConfig } from "./storage/project.js";
13
+ import { listGuardrails, addGuardrail, showGuardrail, removeGuardrail, } from "./guardrails/loader.js";
14
+ import { refinePipeline as refineGuardrail } from "./guardrails/refine.js";
15
+ import { refinePipeline as refineRole } from "./storage/roles-refine.js";
16
+ import { toSlug, resolveSlug } from "./utils/slug.js";
17
+ import { readMoodboard, writeSpecFiles, writeRefineMeta, listSpecs, showSpec } from "./storage/index.js";
18
+ import { listRoles, addRole, showRole, removeRole, loadRole, } from "./storage/roles.js";
19
+ import { listScenarios, showScenarios, addScenario, removeScenario, } from "./storage/scenarios.js";
20
+ import { generateScenarios } from "./storage/scenarios-refine.js";
21
+ import { resolveSpecName, snapshotSpecFiles } from "./utils/spec-helpers.js";
22
+ import { runReviewLoop } from "./cli/review-loop.js";
23
+ import { composePrompt, runBuild, parseSubtasks, promptSubtaskChoice } from "./cli/build.js";
24
+ import { runVerify } from "./cli/verify.js";
25
+ import { detectChanges } from "./storage/change-detection.js";
26
+ import { promptAndRerun } from "./tools/rerefine.js";
27
+ import { scanProject } from "./storage/project-scan.js";
28
+ import { syncMoodboard } from "./storage/moodboard-sync.js";
29
+ import { describeMoodboard } from "./storage/moodboard-describe.js";
30
+ import { runSpecScenarios } from "./cli/scenarios-runner.js";
31
+ import { spawn } from "node:child_process";
32
+ // ── Constants ─────────────────────────────────────────────────────────
33
+ const STAGE_NAMES = ["clarify", "structure", "score", "harden", "finalize"];
34
+ const STAGE_NAME_TO_NUMBER = {
35
+ clarify: 1,
36
+ structure: 2,
37
+ score: 3,
38
+ harden: 4,
39
+ finalize: 5,
40
+ };
41
+ const STAGE_DISPLAY_NAMES = {
42
+ 1: "Problem Statement",
43
+ 2: "Acceptance Criteria",
44
+ 3: "Constraints",
45
+ 4: "Decomposition",
46
+ 5: "Evaluation Design",
47
+ };
48
+ const GLOBAL_CONFIG_DIR = join(homedir(), ".stoa");
49
+ const GLOBAL_CONFIG_PATH = join(GLOBAL_CONFIG_DIR, "config.json");
50
+ // ── Helpers ───────────────────────────────────────────────────────────
51
+ function writeln(text = "") {
52
+ process.stdout.write(text + "\n");
53
+ }
54
+ function colorScore(score) {
55
+ if (score >= 4)
56
+ return chalk.green(`${score}`);
57
+ if (score >= 3)
58
+ return chalk.yellow(`${score}`);
59
+ return chalk.red(`${score}`);
60
+ }
61
+ function printScoreBadge(score) {
62
+ const label = ` Spec Score: ${colorScore(score)} / 5 `;
63
+ const rawLabel = ` Spec Score: ${score} / 5 `;
64
+ const width = rawLabel.length;
65
+ const top = "┌" + "─".repeat(width) + "┐";
66
+ const bottom = "└" + "─".repeat(width) + "┘";
67
+ writeln();
68
+ writeln(top);
69
+ writeln("│" + label + "│");
70
+ writeln(bottom);
71
+ }
72
+ function printStageHeader(index, total, stage) {
73
+ const line = "─".repeat(37);
74
+ writeln();
75
+ writeln(line);
76
+ writeln(chalk.bold(`Stage ${index} / ${total} — ${STAGE_DISPLAY_NAMES[stage] ?? `Stage ${stage}`}`));
77
+ writeln(line);
78
+ }
79
+ function formatStageOutput(output) {
80
+ if (output == null)
81
+ return "(no output)";
82
+ if (typeof output === "string") {
83
+ // Detect JSON array strings (e.g. Stage 2 acceptance criteria)
84
+ const trimmed = output.trim();
85
+ if (trimmed.startsWith("[")) {
86
+ try {
87
+ const parsed = JSON.parse(trimmed);
88
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
89
+ return parsed.map((item, i) => `${i + 1}. ${item}`).join("\n");
90
+ }
91
+ }
92
+ catch {
93
+ // Not valid JSON — fall through
94
+ }
95
+ }
96
+ return output;
97
+ }
98
+ if (Array.isArray(output) && output.every((item) => typeof item === "string")) {
99
+ return output.map((item, i) => `${i + 1}. ${item}`).join("\n");
100
+ }
101
+ return JSON.stringify(output, null, 2);
102
+ }
103
+ async function readConfig() {
104
+ try {
105
+ const raw = await readFile(GLOBAL_CONFIG_PATH, "utf-8");
106
+ return JSON.parse(raw);
107
+ }
108
+ catch {
109
+ return {};
110
+ }
111
+ }
112
+ async function writeConfig(config) {
113
+ if (!existsSync(GLOBAL_CONFIG_DIR)) {
114
+ await mkdir(GLOBAL_CONFIG_DIR, { recursive: true });
115
+ }
116
+ await writeFile(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
117
+ }
118
+ function maskApiKey(value) {
119
+ if (value.length <= 4)
120
+ return value;
121
+ return value.slice(0, 3) + "..." + value.slice(-4);
122
+ }
123
+ function copyToClipboard(text) {
124
+ try {
125
+ const child = spawn("pbcopy", { stdio: ["pipe", "ignore", "ignore"] });
126
+ child.stdin.write(text);
127
+ child.stdin.end();
128
+ }
129
+ catch {
130
+ // Clipboard unavailable — skip silently
131
+ }
132
+ }
133
+ async function printPostRefineOutput(slug, specScore) {
134
+ const specsDir = join(process.cwd(), ".stoa", "specs");
135
+ const descPath = join(specsDir, slug, "01-problem-statement.md");
136
+ const scenariosPath = join(specsDir, slug, "05-evaluation-design.md");
137
+ // Copy Stage 1 to clipboard
138
+ let copiedToClipboard = false;
139
+ try {
140
+ const descContent = await readFile(descPath, "utf-8");
141
+ copyToClipboard(descContent);
142
+ copiedToClipboard = true;
143
+ }
144
+ catch {
145
+ // description file missing — skip
146
+ }
147
+ // Check if scenarios exist
148
+ let hasScenarios = false;
149
+ try {
150
+ const scenariosContent = await readFile(scenariosPath, "utf-8");
151
+ hasScenarios = scenariosContent.trim().length > 0;
152
+ }
153
+ catch {
154
+ // no scenarios file
155
+ }
156
+ writeln();
157
+ writeln(chalk.cyan(`Spec saved to .stoa/specs/${slug}/`));
158
+ printScoreBadge(specScore);
159
+ if (copiedToClipboard) {
160
+ writeln();
161
+ writeln(chalk.green("→ Stage 1 description copied to clipboard"));
162
+ writeln(chalk.dim(" Paste into Lovable, Bolt, v0, or any AI tool"));
163
+ }
164
+ writeln();
165
+ writeln(chalk.green("→ Build prompt for Claude Code:"));
166
+ writeln(chalk.dim(` Read the spec in .stoa/specs/${slug}/ and build it. Follow all constraints and subtasks.`));
167
+ if (hasScenarios) {
168
+ writeln();
169
+ writeln(chalk.green(`→ Scenarios saved. Run: ${chalk.white(`stoa scenarios run ${slug}`)}`));
170
+ }
171
+ }
172
+ function resolveStages(stagesArg) {
173
+ const names = stagesArg.split(",").map((s) => s.trim());
174
+ return names.map((name) => {
175
+ const num = STAGE_NAME_TO_NUMBER[name];
176
+ if (!num) {
177
+ const valid = STAGE_NAMES.join(", ");
178
+ process.stderr.write(chalk.red(`Unknown stage: "${name}". Valid stages: ${valid}`) + "\n");
179
+ process.exit(1);
180
+ }
181
+ return num;
182
+ });
183
+ }
184
+ // ── Read version from package.json ────────────────────────────────────
185
+ const __pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
186
+ const pkg = JSON.parse(readFileSync(__pkgPath, "utf-8"));
187
+ // ── Program ───────────────────────────────────────────────────────────
188
+ const program = new Command();
189
+ program
190
+ .name("stoa")
191
+ .description("Stoa — spec refinement CLI")
192
+ .version(pkg.version);
193
+ // ── stoa refine ───────────────────────────────────────────────────────
194
+ program
195
+ .command("refine")
196
+ .description("Run the spec refinement pipeline on a task description")
197
+ .argument("<description>", "Task description to refine")
198
+ .option("--stages <stages>", "Comma-separated stage names to run (e.g. clarify,structure)")
199
+ .option("--role <role>", "Role context for the pipeline (e.g. 'Backend Dev')")
200
+ .option("--mode <mode>", "Execution mode (clipboard|api|claude-code)")
201
+ .action(async (description, opts) => {
202
+ const validModes = ["clipboard", "api", "claude-code"];
203
+ if (opts.mode && !validModes.includes(opts.mode)) {
204
+ process.stderr.write(chalk.red(`Invalid mode: "${opts.mode}". Valid modes: ${validModes.join(", ")}`) + "\n");
205
+ process.exit(1);
206
+ }
207
+ const stageNumbers = opts.stages ? resolveStages(opts.stages) : undefined;
208
+ const totalStages = stageNumbers ? stageNumbers.length : 5;
209
+ let stageIndex = 0;
210
+ // Load guardrails
211
+ const guardrailItems = listGuardrails();
212
+ const guardrails = guardrailItems.map((g) => g.title);
213
+ // Resolve role
214
+ let roleName = opts.role;
215
+ if (!roleName) {
216
+ try {
217
+ const config = await loadConfig();
218
+ roleName = config.defaultRole;
219
+ }
220
+ catch (err) {
221
+ if (err instanceof SyntaxError) {
222
+ process.stderr.write(chalk.red("Error: .stoa/config.json contains invalid JSON — fix or delete the file.") + "\n");
223
+ process.exit(1);
224
+ }
225
+ // File not found — roleName stays undefined
226
+ }
227
+ }
228
+ let roleContent;
229
+ if (roleName) {
230
+ try {
231
+ roleContent = loadRole(toSlug(roleName));
232
+ }
233
+ catch {
234
+ const rolePath = `.stoa/roles/${toSlug(roleName)}.md`;
235
+ process.stderr.write(chalk.red(`Error: Role file not found: ${rolePath}`) + "\n");
236
+ process.exit(1);
237
+ }
238
+ }
239
+ // Build prompt options
240
+ const promptOptions = {
241
+ ...(guardrails.length > 0 ? { guardrails } : {}),
242
+ ...(roleContent ? { role: roleContent } : {}),
243
+ };
244
+ const hasPromptOptions = guardrails.length > 0 || roleContent;
245
+ // Sync moodboard tokens before loading (ensures tokens.json is up to date)
246
+ try {
247
+ syncMoodboard(process.cwd());
248
+ }
249
+ catch {
250
+ // Non-critical — notes.md may not exist
251
+ }
252
+ // Load moodboard design context (optional — continue without it on error)
253
+ let moodboard;
254
+ try {
255
+ const result = await readMoodboard(process.cwd());
256
+ if (result) {
257
+ moodboard = result;
258
+ }
259
+ }
260
+ catch {
261
+ // Non-critical — proceed without design context
262
+ }
263
+ // Scan project for context (optional — continue without it on error)
264
+ let projectCtx;
265
+ try {
266
+ projectCtx = scanProject(process.cwd());
267
+ }
268
+ catch {
269
+ // Non-critical — proceed without project context
270
+ }
271
+ const executionMode = opts.mode;
272
+ // In clipboard mode, skip spinner
273
+ if (executionMode === "clipboard") {
274
+ const stagesToRun = stageNumbers ?? [1, 2, 3, 4, 5];
275
+ try {
276
+ const result = await refinePipeline({
277
+ title: description,
278
+ description,
279
+ role: opts.role,
280
+ guardrails,
281
+ moodboard,
282
+ projectCtx,
283
+ promptOptions: hasPromptOptions ? promptOptions : undefined,
284
+ }, {
285
+ executionMode: "clipboard",
286
+ stages: stageNumbers,
287
+ onStageComplete: (stage, stageResult) => {
288
+ stageIndex++;
289
+ printStageHeader(stageIndex, totalStages, stage);
290
+ writeln(formatStageOutput(stageResult.output || stageResult.rawResponse));
291
+ },
292
+ });
293
+ // Write spec files
294
+ const specsDir = join(process.cwd(), ".stoa", "specs");
295
+ const slug = await resolveSlug(specsDir, toSlug(description));
296
+ const stagesRun = {};
297
+ for (const sr of result.stages) {
298
+ stagesRun[sr.stage] = sr.rawResponse;
299
+ }
300
+ await writeSpecFiles(specsDir, slug, stagesRun);
301
+ await writeRefineMeta(specsDir, slug, result.stages.map((s) => s.stage), result.executionMode, pkg.version);
302
+ await printPostRefineOutput(slug, result.finalSpecScore);
303
+ }
304
+ catch (err) {
305
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
306
+ process.exit(1);
307
+ }
308
+ return;
309
+ }
310
+ const spinner = ora({ text: "", color: "cyan" });
311
+ const stagesToRun = stageNumbers ?? [1, 2, 3, 4, 5];
312
+ spinner.start(`Running stage: ${STAGE_DISPLAY_NAMES[stagesToRun[0]]}...`);
313
+ try {
314
+ const result = await refinePipeline({
315
+ title: description,
316
+ description,
317
+ role: opts.role,
318
+ guardrails,
319
+ moodboard,
320
+ projectCtx,
321
+ promptOptions: hasPromptOptions ? promptOptions : undefined,
322
+ }, {
323
+ ...(executionMode ? { executionMode } : {}),
324
+ stages: stageNumbers,
325
+ onStageComplete: (stage, stageResult) => {
326
+ spinner.stop();
327
+ stageIndex++;
328
+ printStageHeader(stageIndex, totalStages, stage);
329
+ writeln(formatStageOutput(stageResult.output || stageResult.rawResponse));
330
+ // Start spinner for next stage if there are more
331
+ if (stageIndex < totalStages) {
332
+ const nextStage = stagesToRun[stageIndex];
333
+ spinner.start(`Running stage: ${STAGE_DISPLAY_NAMES[nextStage]}...`);
334
+ }
335
+ },
336
+ });
337
+ // Write spec files
338
+ const specsDir = join(process.cwd(), ".stoa", "specs");
339
+ const slug = await resolveSlug(specsDir, toSlug(description));
340
+ const stagesRun = {};
341
+ for (const sr of result.stages) {
342
+ stagesRun[sr.stage] = sr.rawResponse;
343
+ }
344
+ await writeSpecFiles(specsDir, slug, stagesRun);
345
+ await writeRefineMeta(specsDir, slug, result.stages.map((s) => s.stage), result.executionMode, pkg.version);
346
+ await printPostRefineOutput(slug, result.finalSpecScore);
347
+ }
348
+ catch (err) {
349
+ spinner.fail(err instanceof Error ? err.message : String(err));
350
+ process.exit(1);
351
+ }
352
+ });
353
+ // ── stoa score ────────────────────────────────────────────────────────
354
+ program
355
+ .command("score")
356
+ .description("Score a task description without running the full pipeline")
357
+ .argument("<description>", "Task description to score")
358
+ .action((description) => {
359
+ const result = computeSpecScore({
360
+ hasDescription: description.length > 0,
361
+ descriptionLength: description.length,
362
+ wasRefined: false,
363
+ hasAcceptanceCriteria: false,
364
+ hasGuardrails: false,
365
+ hasRole: false,
366
+ hasScenarios: false,
367
+ hasSubtasks: false,
368
+ });
369
+ writeln(`Level: ${chalk.bold(result.level)}`);
370
+ printScoreBadge(result.score);
371
+ if (result.missing.length > 0) {
372
+ writeln();
373
+ writeln(chalk.dim("Missing:"));
374
+ for (const item of result.missing) {
375
+ writeln(chalk.dim(` - ${item}`));
376
+ }
377
+ }
378
+ });
379
+ // ── stoa specs ────────────────────────────────────────────────────────
380
+ const specsCmd = program
381
+ .command("specs")
382
+ .description("Manage saved specs (.stoa/specs/)");
383
+ specsCmd
384
+ .command("list")
385
+ .description("List all saved specs with date")
386
+ .action(async () => {
387
+ const specsDir = join(process.cwd(), ".stoa", "specs");
388
+ const specs = await listSpecs(specsDir);
389
+ if (specs.length === 0) {
390
+ writeln(chalk.dim("No specs found. Run stoa refine <task> first."));
391
+ return;
392
+ }
393
+ for (const spec of specs) {
394
+ const dateStr = spec.date.toISOString().slice(0, 10);
395
+ writeln(`${chalk.white(spec.name)} ${chalk.dim(dateStr)} ${chalk.dim(`${spec.stages} stage(s)`)}`);
396
+ }
397
+ });
398
+ specsCmd
399
+ .command("show")
400
+ .description("Show a saved spec's content")
401
+ .argument("<name>", "Spec name (slug)")
402
+ .action(async (name) => {
403
+ const specsDir = join(process.cwd(), ".stoa", "specs");
404
+ try {
405
+ const content = await showSpec(specsDir, name);
406
+ process.stdout.write(content);
407
+ }
408
+ catch (err) {
409
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
410
+ process.exit(1);
411
+ }
412
+ });
413
+ // ── stoa config ───────────────────────────────────────────────────────
414
+ const configCmd = program
415
+ .command("config")
416
+ .description("Manage global Stoa configuration (~/.stoa/config.json)");
417
+ configCmd
418
+ .command("set")
419
+ .description("Set a configuration value")
420
+ .argument("<key>", "Configuration key")
421
+ .argument("<value>", "Configuration value")
422
+ .action(async (key, value) => {
423
+ const config = await readConfig();
424
+ config[key] = value;
425
+ await writeConfig(config);
426
+ writeln(chalk.green(`Set ${key}`));
427
+ });
428
+ configCmd
429
+ .command("get")
430
+ .description("Get a configuration value")
431
+ .argument("<key>", "Configuration key")
432
+ .action(async (key) => {
433
+ const config = await readConfig();
434
+ const value = config[key];
435
+ if (value === undefined) {
436
+ writeln(chalk.dim(`(not set)`));
437
+ return;
438
+ }
439
+ if (key.toLowerCase().endsWith("api_key")) {
440
+ writeln(maskApiKey(value));
441
+ }
442
+ else {
443
+ writeln(value);
444
+ }
445
+ });
446
+ // ── stoa edit ─────────────────────────────────────────────────────────
447
+ const EDITABLE_FILES = {
448
+ moodboard: ".stoa/moodboard/notes.md",
449
+ context: ".stoa/context.md",
450
+ lessons: ".stoa/lessons.md",
451
+ };
452
+ program
453
+ .command("edit")
454
+ .description("Open a .stoa/ file in your editor")
455
+ .argument("<file>", `File to edit (${Object.keys(EDITABLE_FILES).join(", ")})`)
456
+ .action((file) => {
457
+ const relativePath = EDITABLE_FILES[file];
458
+ if (!relativePath) {
459
+ process.stderr.write(chalk.red(`Unknown file: "${file}". Valid: ${Object.keys(EDITABLE_FILES).join(", ")}`) + "\n");
460
+ process.exit(1);
461
+ }
462
+ const fullPath = join(process.cwd(), relativePath);
463
+ if (!existsSync(fullPath)) {
464
+ process.stderr.write(chalk.red(`File not found: ${relativePath}. Run 'stoa init' first.`) + "\n");
465
+ process.exit(1);
466
+ }
467
+ const editor = process.env.EDITOR || "nano";
468
+ const child = spawn(editor, [fullPath], { stdio: "inherit" });
469
+ child.on("exit", (code) => {
470
+ process.exit(code ?? 0);
471
+ });
472
+ });
473
+ // ── stoa init ─────────────────────────────────────────────────────────
474
+ program
475
+ .command("init")
476
+ .description("Initialize a .stoa/ directory in the current working directory")
477
+ .option("--name <name>", "Project name (defaults to current directory name)")
478
+ .option("--type <type>", "Project type", "generic")
479
+ .option("--no-templates", "Skip copying starter templates")
480
+ .action(async (opts) => {
481
+ const name = opts.name ?? basename(process.cwd());
482
+ const created = await initProject(name, opts.type, !opts.templates);
483
+ if (!created) {
484
+ process.exit(1);
485
+ }
486
+ writeln(chalk.green("Created .stoa/ with:"));
487
+ if (opts.templates) {
488
+ writeln(chalk.green(" 5 guardrails"));
489
+ writeln(chalk.green(" 3 roles (Builder, Fixer, Planner)"));
490
+ }
491
+ writeln(chalk.green(" moodboard/notes.md — design direction"));
492
+ writeln(chalk.green(" context.md — brand voice, dependencies, conventions"));
493
+ writeln(chalk.green(" lessons.md — project memory (grows automatically)"));
494
+ writeln();
495
+ writeln("Edit moodboard/notes.md and context.md, then run 'stoa refine \"your idea\"'");
496
+ });
497
+ // ── stoa guardrails ──────────────────────────────────────────────────
498
+ const guardrailsCmd = program
499
+ .command("guardrails")
500
+ .description("Manage project guardrails (.stoa/guardrails/)");
501
+ guardrailsCmd
502
+ .command("list")
503
+ .description("List all guardrails")
504
+ .action(() => {
505
+ const items = listGuardrails();
506
+ if (items.length === 0) {
507
+ writeln(chalk.dim("No guardrails found."));
508
+ return;
509
+ }
510
+ for (const item of items) {
511
+ writeln(`${item.slug}: ${item.title}`);
512
+ }
513
+ });
514
+ guardrailsCmd
515
+ .command("add")
516
+ .description("Add a new guardrail")
517
+ .argument("<title>", "Guardrail title")
518
+ .action((title) => {
519
+ try {
520
+ addGuardrail(title);
521
+ writeln(chalk.green(`Added: ${toSlug(title)}`));
522
+ }
523
+ catch (err) {
524
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
525
+ process.exit(1);
526
+ }
527
+ });
528
+ guardrailsCmd
529
+ .command("show")
530
+ .description("Show a guardrail's content")
531
+ .argument("<slug>", "Guardrail slug")
532
+ .action((slug) => {
533
+ try {
534
+ const content = showGuardrail(slug);
535
+ process.stdout.write(content);
536
+ }
537
+ catch (err) {
538
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
539
+ process.exit(1);
540
+ }
541
+ });
542
+ guardrailsCmd
543
+ .command("remove")
544
+ .description("Remove a guardrail")
545
+ .argument("<slug>", "Guardrail slug")
546
+ .action(async (slug) => {
547
+ const { createInterface } = await import("node:readline");
548
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
549
+ rl.question(`Remove ${slug}? (y/N) `, (answer) => {
550
+ rl.close();
551
+ if (answer.toLowerCase() !== "y") {
552
+ return;
553
+ }
554
+ try {
555
+ removeGuardrail(slug);
556
+ writeln(chalk.green(`Removed: ${slug}`));
557
+ }
558
+ catch (err) {
559
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
560
+ process.exit(1);
561
+ }
562
+ });
563
+ });
564
+ guardrailsCmd
565
+ .command("refine")
566
+ .description("Refine a guardrail through a 3-stage pipeline (clarify → verify → examples)")
567
+ .argument("<name>", "Guardrail slug name")
568
+ .option("--mode <mode>", "Execution mode (clipboard|api|claude-code)", "clipboard")
569
+ .action(async (name, opts) => {
570
+ const validModes = ["clipboard", "api", "claude-code"];
571
+ if (!validModes.includes(opts.mode)) {
572
+ process.stderr.write(chalk.red(`Invalid mode: "${opts.mode}". Valid modes: ${validModes.join(", ")}`) + "\n");
573
+ process.exit(1);
574
+ }
575
+ const mode = opts.mode;
576
+ if (mode === "clipboard") {
577
+ try {
578
+ const result = await refineGuardrail({
579
+ name,
580
+ mode,
581
+ onStageComplete: (i, label, output) => {
582
+ const line = "─".repeat(40);
583
+ writeln(`\n${line}`);
584
+ writeln(chalk.bold(label));
585
+ writeln(line);
586
+ writeln(output);
587
+ },
588
+ });
589
+ // result.prompts are printed via onStageComplete
590
+ void result;
591
+ }
592
+ catch (err) {
593
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
594
+ process.exit(1);
595
+ }
596
+ return;
597
+ }
598
+ // api or claude-code mode
599
+ const spinner = ora({ text: "", color: "cyan" });
600
+ spinner.start("Stage 1: Clarify & Tighten...");
601
+ try {
602
+ await refineGuardrail({
603
+ name,
604
+ mode,
605
+ onStageComplete: (i, label) => {
606
+ spinner.succeed(label);
607
+ if (i < 2) {
608
+ const nextLabels = [
609
+ "Stage 2: Add Verification...",
610
+ "Stage 3: Add Examples...",
611
+ ];
612
+ spinner.start(nextLabels[i]);
613
+ }
614
+ },
615
+ });
616
+ writeln(chalk.green(`\nUpdated: .stoa/guardrails/${name}.md`));
617
+ }
618
+ catch (err) {
619
+ spinner.fail(err instanceof Error ? err.message : String(err));
620
+ process.exit(1);
621
+ }
622
+ });
623
+ // ── stoa export ───────────────────────────────────────────────────────
624
+ program
625
+ .command("export")
626
+ .description("Export configuration in a specific format")
627
+ .argument("<format>", "Export format (e.g. claude-md)")
628
+ .action(async (format) => {
629
+ if (format !== "claude-md") {
630
+ process.stderr.write(chalk.red(`Unknown export format: "${format}". Supported: claude-md`) + "\n");
631
+ process.exit(1);
632
+ }
633
+ // Try local config first, then global
634
+ let config = {};
635
+ const localPath = join(process.cwd(), ".stoa", "config.json");
636
+ try {
637
+ const raw = await readFile(localPath, "utf-8");
638
+ config = JSON.parse(raw);
639
+ }
640
+ catch {
641
+ try {
642
+ const raw = await readFile(GLOBAL_CONFIG_PATH, "utf-8");
643
+ config = JSON.parse(raw);
644
+ }
645
+ catch {
646
+ // empty config
647
+ }
648
+ }
649
+ const lines = [
650
+ "# Stoa Configuration",
651
+ "",
652
+ ];
653
+ if (config.stages) {
654
+ lines.push("## Pipeline Stages");
655
+ lines.push("");
656
+ const stages = config.stages;
657
+ for (const stage of stages) {
658
+ lines.push(`- ${stage}`);
659
+ }
660
+ lines.push("");
661
+ }
662
+ // Include any other config keys
663
+ for (const [key, value] of Object.entries(config)) {
664
+ if (key === "stages")
665
+ continue;
666
+ if (typeof key === "string" && key.toLowerCase().endsWith("api_key"))
667
+ continue;
668
+ lines.push(`## ${key}`);
669
+ lines.push("");
670
+ lines.push(typeof value === "string" ? value : JSON.stringify(value, null, 2));
671
+ lines.push("");
672
+ }
673
+ // Read guardrails if present
674
+ const guardrailsDir = join(process.cwd(), ".stoa", "guardrails");
675
+ if (existsSync(guardrailsDir)) {
676
+ const { readdir } = await import("node:fs/promises");
677
+ const files = await readdir(guardrailsDir);
678
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
679
+ if (mdFiles.length > 0) {
680
+ lines.push("## Guardrails");
681
+ lines.push("");
682
+ for (const file of mdFiles) {
683
+ const content = await readFile(join(guardrailsDir, file), "utf-8");
684
+ lines.push(content);
685
+ lines.push("");
686
+ }
687
+ }
688
+ }
689
+ process.stdout.write(lines.join("\n"));
690
+ });
691
+ // ── stoa roles ───────────────────────────────────────────────────────
692
+ const rolesCmd = program
693
+ .command("roles")
694
+ .description("Manage project roles (.stoa/roles/)");
695
+ rolesCmd
696
+ .command("list")
697
+ .description("List all roles")
698
+ .action(() => {
699
+ const slugs = listRoles();
700
+ for (const slug of slugs) {
701
+ writeln(slug);
702
+ }
703
+ });
704
+ rolesCmd
705
+ .command("add")
706
+ .description("Add a new role")
707
+ .argument("<displayName>", "Role display name")
708
+ .action((displayName) => {
709
+ try {
710
+ addRole(displayName);
711
+ }
712
+ catch (err) {
713
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
714
+ process.exit(1);
715
+ }
716
+ });
717
+ rolesCmd
718
+ .command("show")
719
+ .description("Show a role's content")
720
+ .argument("<slug>", "Role slug")
721
+ .action((slug) => {
722
+ try {
723
+ const content = showRole(slug);
724
+ process.stdout.write(content);
725
+ }
726
+ catch (err) {
727
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
728
+ process.exit(1);
729
+ }
730
+ });
731
+ rolesCmd
732
+ .command("remove")
733
+ .description("Remove a role")
734
+ .argument("<slug>", "Role slug")
735
+ .action((slug) => {
736
+ try {
737
+ removeRole(slug);
738
+ }
739
+ catch (err) {
740
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
741
+ process.exit(1);
742
+ }
743
+ });
744
+ rolesCmd
745
+ .command("refine")
746
+ .description("Refine a role through a 3-stage pipeline (sharpen → boundaries → guardrails)")
747
+ .argument("<name>", "Role slug name")
748
+ .option("--mode <mode>", "Execution mode (clipboard|api|claude-code)", "clipboard")
749
+ .action(async (name, opts) => {
750
+ const validModes = ["clipboard", "api", "claude-code"];
751
+ if (!validModes.includes(opts.mode)) {
752
+ process.stderr.write(chalk.red(`Invalid mode: "${opts.mode}". Valid modes: ${validModes.join(", ")}`) + "\n");
753
+ process.exit(1);
754
+ }
755
+ const mode = opts.mode;
756
+ if (mode === "clipboard") {
757
+ try {
758
+ const result = await refineRole({
759
+ name,
760
+ mode,
761
+ onStageComplete: (i, label, output) => {
762
+ const line = "─".repeat(40);
763
+ writeln(`\n${line}`);
764
+ writeln(chalk.bold(label));
765
+ writeln(line);
766
+ writeln(output);
767
+ },
768
+ });
769
+ void result;
770
+ }
771
+ catch (err) {
772
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
773
+ process.exit(1);
774
+ }
775
+ return;
776
+ }
777
+ // api or claude-code mode
778
+ const spinner = ora({ text: "", color: "cyan" });
779
+ spinner.start("Stage 1: Sharpen Identity...");
780
+ try {
781
+ const result = await refineRole({
782
+ name,
783
+ mode,
784
+ onStageComplete: (i, label, output) => {
785
+ spinner.succeed(label);
786
+ if (i === 0) {
787
+ spinner.start("Stage 2: Define Boundaries...");
788
+ }
789
+ else if (i === 1) {
790
+ writeln(chalk.green(`\nUpdated: .stoa/roles/${name}.md`));
791
+ spinner.start("Stage 3: Suggest Guardrails...");
792
+ }
793
+ else if (i === 2) {
794
+ // Stage 3: print guardrail suggestions to terminal only
795
+ const line = "─".repeat(40);
796
+ writeln(`\n${line}`);
797
+ writeln(chalk.bold("Suggested Guardrails (review & create manually):"));
798
+ writeln(line);
799
+ writeln(output);
800
+ }
801
+ },
802
+ });
803
+ void result;
804
+ }
805
+ catch (err) {
806
+ spinner.fail(err instanceof Error ? err.message : String(err));
807
+ process.exit(1);
808
+ }
809
+ });
810
+ // ── stoa scenarios ───────────────────────────────────────────────────
811
+ const scenariosCmd = program
812
+ .command("scenarios")
813
+ .description("Manage project scenarios (.stoa/scenarios/)");
814
+ scenariosCmd
815
+ .command("list")
816
+ .description("List all scenarios for a task")
817
+ .argument("<name>", "Scenario set name")
818
+ .action((name) => {
819
+ const scenarios = listScenarios(name);
820
+ for (let i = 0; i < scenarios.length; i++) {
821
+ writeln(`[${i}] title: ${scenarios[i].title}`);
822
+ }
823
+ });
824
+ scenariosCmd
825
+ .command("show")
826
+ .description("Show scenarios with details")
827
+ .argument("<name>", "Scenario set name")
828
+ .action((name) => {
829
+ try {
830
+ const scenarios = showScenarios(name);
831
+ for (let i = 0; i < scenarios.length; i++) {
832
+ writeln(`[${i}] GIVEN: ${scenarios[i].given}`);
833
+ writeln(` EXPECTED: ${scenarios[i].expected}`);
834
+ }
835
+ }
836
+ catch (err) {
837
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
838
+ process.exit(1);
839
+ }
840
+ });
841
+ scenariosCmd
842
+ .command("add")
843
+ .description("Add a scenario")
844
+ .argument("<name>", "Scenario set name")
845
+ .requiredOption("--title <string>", "Scenario title")
846
+ .requiredOption("--given <string>", "Given condition")
847
+ .requiredOption("--expected <string>", "Expected outcome")
848
+ .action((name, opts) => {
849
+ addScenario(name, { title: opts.title, given: opts.given, expected: opts.expected });
850
+ });
851
+ scenariosCmd
852
+ .command("remove")
853
+ .description("Remove a scenario by index")
854
+ .argument("<name>", "Scenario set name")
855
+ .requiredOption("--index <number>", "Index to remove", parseInt)
856
+ .action((name, opts) => {
857
+ try {
858
+ removeScenario(name, opts.index);
859
+ }
860
+ catch (err) {
861
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
862
+ process.exit(1);
863
+ }
864
+ });
865
+ scenariosCmd
866
+ .command("run")
867
+ .description("Interactively walk through scenarios for a spec")
868
+ .argument("[specName]", "Spec name (defaults to most recent)")
869
+ .action(async (specNameArg) => {
870
+ try {
871
+ await runSpecScenarios(specNameArg);
872
+ }
873
+ catch (err) {
874
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
875
+ process.exit(1);
876
+ }
877
+ });
878
+ scenariosCmd
879
+ .command("generate")
880
+ .description("Generate structured scenarios from a task spec (.stoa/specs/<taskName>.json)")
881
+ .argument("<taskName>", "Task name (matches spec filename)")
882
+ .option("--mode <mode>", "Execution mode (api|clipboard|cli)", "clipboard")
883
+ .action(async (taskName, opts) => {
884
+ const validModes = ["api", "clipboard", "cli"];
885
+ if (!validModes.includes(opts.mode)) {
886
+ process.stderr.write(chalk.red(`Invalid mode: "${opts.mode}". Valid modes: ${validModes.join(", ")}`) + "\n");
887
+ process.exit(1);
888
+ }
889
+ await generateScenarios(taskName, opts.mode);
890
+ });
891
+ // ── stoa moodboard ──────────────────────────────────────────────────
892
+ const moodboardCmd = program
893
+ .command("moodboard")
894
+ .description("Manage project moodboard (.stoa/moodboard/)");
895
+ moodboardCmd
896
+ .command("sync")
897
+ .description("Parse notes.md and generate tokens.json")
898
+ .action(() => {
899
+ try {
900
+ const tokens = syncMoodboard(process.cwd());
901
+ writeln(chalk.green("Generated .stoa/moodboard/tokens.json"));
902
+ writeln();
903
+ const entries = Object.entries(tokens);
904
+ if (entries.length === 0) {
905
+ writeln(chalk.dim("No values found. Add content to .stoa/moodboard/notes.md first."));
906
+ }
907
+ else {
908
+ writeln("Extracted:");
909
+ for (const [key, value] of entries) {
910
+ if (typeof value === "string") {
911
+ writeln(` ${key}: ${value}`);
912
+ }
913
+ else if (value && typeof value === "object") {
914
+ const count = Object.keys(value).length;
915
+ writeln(` ${key}: ${count} value(s)`);
916
+ }
917
+ }
918
+ }
919
+ }
920
+ catch (err) {
921
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
922
+ process.exit(1);
923
+ }
924
+ });
925
+ moodboardCmd
926
+ .command("describe")
927
+ .description("AI-extract design system from screenshots in moodboard/")
928
+ .option("--mode <mode>", "Execution mode (api|claude-code|clipboard)")
929
+ .option("--overwrite", "Overwrite notes.md without prompting")
930
+ .action(async (opts) => {
931
+ const validModes = ["api", "claude-code", "clipboard"];
932
+ if (opts.mode && !validModes.includes(opts.mode)) {
933
+ process.stderr.write(chalk.red(`Invalid mode: "${opts.mode}". Valid: ${validModes.join(", ")}`) + "\n");
934
+ process.exit(1);
935
+ }
936
+ // Auto-detect mode if not specified
937
+ let mode;
938
+ if (opts.mode) {
939
+ mode = opts.mode;
940
+ }
941
+ else if (process.env.ANTHROPIC_API_KEY) {
942
+ mode = "api";
943
+ }
944
+ else {
945
+ mode = "clipboard";
946
+ }
947
+ const spinner = mode === "api" ? ora({ text: "Analyzing screenshots...", color: "cyan" }) : null;
948
+ try {
949
+ if (spinner)
950
+ spinner.start();
951
+ const result = await describeMoodboard(process.cwd(), {
952
+ mode,
953
+ overwrite: opts.overwrite,
954
+ apiKey: process.env.ANTHROPIC_API_KEY,
955
+ onConfirm: async () => {
956
+ if (spinner)
957
+ spinner.stop();
958
+ const { createInterface } = await import("node:readline");
959
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
960
+ return new Promise((resolve) => {
961
+ rl.question("notes.md has content. Overwrite? [y/N] ", (answer) => {
962
+ rl.close();
963
+ const yes = answer.trim().toLowerCase() === "y";
964
+ if (yes && spinner)
965
+ spinner.start();
966
+ resolve(yes);
967
+ });
968
+ });
969
+ },
970
+ });
971
+ if (spinner)
972
+ spinner.stop();
973
+ if (result.written) {
974
+ writeln(chalk.green(`Extracted design system from ${result.imageCount} image(s).`));
975
+ writeln(chalk.green("Updated notes.md and tokens.json."));
976
+ }
977
+ else {
978
+ writeln(result.output);
979
+ }
980
+ }
981
+ catch (err) {
982
+ if (spinner)
983
+ spinner.fail();
984
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
985
+ process.exit(1);
986
+ }
987
+ });
988
+ // ── stoa review ──────────────────────────────────────────────────────
989
+ program
990
+ .command("review")
991
+ .description("Review a refined spec interactively, then optionally re-run affected stages")
992
+ .argument("[specName]", "Spec name (defaults to most recent)")
993
+ .action(async (specNameArg) => {
994
+ let specName;
995
+ try {
996
+ specName = await resolveSpecName(specNameArg);
997
+ }
998
+ catch (err) {
999
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
1000
+ process.exit(1);
1001
+ }
1002
+ const specDir = join(process.cwd(), ".stoa", "specs", specName);
1003
+ // 1. Capture snapshot
1004
+ const snapshot = await snapshotSpecFiles(specDir);
1005
+ // 2. Run interactive review loop
1006
+ await runReviewLoop(specDir);
1007
+ // 3. Detect changes made during review
1008
+ const affectedStages = await detectChanges(specDir, snapshot);
1009
+ // 4. Prompt user about re-running affected stages
1010
+ const success = await promptAndRerun(affectedStages, specDir);
1011
+ if (success) {
1012
+ // 5. Write .approved marker
1013
+ const approvedPath = join(specDir, ".approved");
1014
+ await writeFile(approvedPath, new Date().toISOString(), "utf-8");
1015
+ writeln(chalk.green(`Approved: ${specName}`));
1016
+ }
1017
+ else {
1018
+ process.stderr.write(chalk.red(`Review failed for spec "${specName}". Spec was not approved.`) + "\n");
1019
+ process.exit(1);
1020
+ }
1021
+ });
1022
+ // ── stoa build ───────────────────────────────────────────────────────
1023
+ program
1024
+ .command("build")
1025
+ .description("Launch a build from an approved spec")
1026
+ .argument("[specName]", "Spec name to build (defaults to most recent approved spec)")
1027
+ .option("--fix <number>", "Apply a fix spec before building (reads .stoa/specs/<name>/fixes/fix-<NNN>.md)")
1028
+ .action(async (specNameArg, opts) => {
1029
+ let specName;
1030
+ try {
1031
+ specName = await resolveSpecName(specNameArg);
1032
+ }
1033
+ catch (err) {
1034
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
1035
+ process.exit(1);
1036
+ }
1037
+ const approvedPath = join(process.cwd(), ".stoa", "specs", specName, ".approved");
1038
+ try {
1039
+ await access(approvedPath, constants.F_OK);
1040
+ }
1041
+ catch {
1042
+ process.stderr.write(chalk.red(`Error: Spec "${specName}" has not been reviewed. Run \`stoa review ${specName}\` first.`) + "\n");
1043
+ process.exit(1);
1044
+ }
1045
+ // Handle --fix N: read the fix file and use it as the prompt
1046
+ let fixPrompt;
1047
+ if (opts.fix) {
1048
+ const fixNum = parseInt(opts.fix, 10);
1049
+ const padded = String(fixNum).padStart(3, "0");
1050
+ const fixPath = join(process.cwd(), ".stoa", "specs", specName, "fixes", `fix-${padded}.md`);
1051
+ if (!existsSync(fixPath)) {
1052
+ process.stderr.write(chalk.red(`Error: Fix file not found: .stoa/specs/${specName}/fixes/fix-${padded}.md`) + "\n");
1053
+ process.exit(1);
1054
+ }
1055
+ fixPrompt = readFileSync(fixPath, "utf-8");
1056
+ writeln(chalk.cyan(`Applying fix ${fixNum} for spec: ${chalk.white(specName)}`));
1057
+ }
1058
+ const config = await loadConfig();
1059
+ const role = config.defaultRole ?? "builder";
1060
+ writeln(chalk.cyan(`Building from spec: ${chalk.white(specName)}`));
1061
+ writeln(chalk.dim(`Role: ${role}`));
1062
+ const basePrompt = composePrompt(specName, { role });
1063
+ // If --fix, append fix content to the prompt
1064
+ const prompt = fixPrompt
1065
+ ? basePrompt + "\n\n# Fix Spec\n" + fixPrompt
1066
+ : basePrompt;
1067
+ // Check for subtasks in the spec
1068
+ const subtasksPath = join(process.cwd(), ".stoa", "specs", specName, "04-decomposition.md");
1069
+ const subtasksContent = existsSync(subtasksPath) ? readFileSync(subtasksPath, "utf-8") : "";
1070
+ const subtasks = parseSubtasks(subtasksContent);
1071
+ try {
1072
+ if (fixPrompt || subtasks.length === 0) {
1073
+ // --fix mode or no subtasks: single build
1074
+ await runBuild(specName, prompt);
1075
+ }
1076
+ else {
1077
+ writeln(chalk.cyan(`\nFound ${subtasks.length} subtask(s):`));
1078
+ for (const st of subtasks) {
1079
+ writeln(chalk.dim(` ${st.index}. ${st.text.split("\n")[0]}`));
1080
+ }
1081
+ writeln("");
1082
+ const choice = await promptSubtaskChoice(subtasks);
1083
+ if (choice === "q") {
1084
+ process.exit(0);
1085
+ }
1086
+ else if (choice === "all") {
1087
+ for (const st of subtasks) {
1088
+ writeln(chalk.cyan(`\n── Subtask ${st.index}/${subtasks.length} ──\n`));
1089
+ await runBuild(specName, prompt + `\n\n# Current Subtask\n${st.text}`);
1090
+ }
1091
+ }
1092
+ else {
1093
+ const num = parseInt(choice, 10);
1094
+ const picked = subtasks.find((s) => s.index === num);
1095
+ if (!picked) {
1096
+ process.stderr.write(chalk.red(`Invalid choice: "${choice}". Expected all, 1-${subtasks.length}, or q.\n`));
1097
+ process.exit(1);
1098
+ }
1099
+ writeln(chalk.cyan(`\n── Subtask ${picked.index} ──\n`));
1100
+ await runBuild(specName, prompt + `\n\n# Current Subtask\n${picked.text}`);
1101
+ }
1102
+ }
1103
+ // If --fix mode, run verify automatically after build completes
1104
+ if (fixPrompt) {
1105
+ writeln(chalk.cyan("\nRunning verification..."));
1106
+ await runVerify(specName);
1107
+ }
1108
+ }
1109
+ catch (err) {
1110
+ if (err instanceof Error && err.code === "ENOENT") {
1111
+ process.exit(1);
1112
+ }
1113
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
1114
+ process.exit(1);
1115
+ }
1116
+ });
1117
+ // ── stoa verify ─────────────────────────────────────────────────────
1118
+ program
1119
+ .command("verify")
1120
+ .description("Interactively verify scenarios for a spec")
1121
+ .argument("[specName]", "Spec name to verify (defaults to most recent spec)")
1122
+ .action(async (specNameArg) => {
1123
+ let specName;
1124
+ try {
1125
+ specName = await resolveSpecName(specNameArg);
1126
+ }
1127
+ catch (err) {
1128
+ process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
1129
+ process.exit(1);
1130
+ }
1131
+ await runVerify(specName);
1132
+ });
1133
+ // ── Parse ─────────────────────────────────────────────────────────────
1134
+ program.parse();
1135
+ //# sourceMappingURL=cli.js.map