ai-spec-dev 0.1.0 → 0.14.1

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 (60) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/README.md +1211 -146
  3. package/RELEASE_LOG.md +1444 -0
  4. package/cli/index.ts +1961 -0
  5. package/cli/welcome.ts +151 -0
  6. package/core/code-generator.ts +740 -0
  7. package/core/combined-generator.ts +63 -0
  8. package/core/constitution-consolidator.ts +141 -0
  9. package/core/constitution-generator.ts +89 -0
  10. package/core/context-loader.ts +453 -0
  11. package/core/contract-bridge.ts +217 -0
  12. package/core/dsl-extractor.ts +337 -0
  13. package/core/dsl-types.ts +166 -0
  14. package/core/dsl-validator.ts +450 -0
  15. package/core/error-feedback.ts +354 -0
  16. package/core/frontend-context-loader.ts +602 -0
  17. package/core/global-constitution.ts +88 -0
  18. package/core/key-store.ts +49 -0
  19. package/core/knowledge-memory.ts +171 -0
  20. package/core/mock-server-generator.ts +571 -0
  21. package/core/openapi-exporter.ts +361 -0
  22. package/core/requirement-decomposer.ts +198 -0
  23. package/core/reviewer.ts +259 -0
  24. package/core/spec-assessor.ts +99 -0
  25. package/core/spec-generator.ts +428 -0
  26. package/core/spec-refiner.ts +89 -0
  27. package/core/spec-updater.ts +227 -0
  28. package/core/spec-versioning.ts +213 -0
  29. package/core/task-generator.ts +174 -0
  30. package/core/test-generator.ts +273 -0
  31. package/core/workspace-loader.ts +256 -0
  32. package/dist/cli/index.js +6717 -672
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/index.mjs +6717 -670
  35. package/dist/cli/index.mjs.map +1 -1
  36. package/dist/index.d.mts +147 -27
  37. package/dist/index.d.ts +147 -27
  38. package/dist/index.js +2337 -286
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2329 -285
  41. package/dist/index.mjs.map +1 -1
  42. package/git/worktree.ts +109 -0
  43. package/index.ts +9 -0
  44. package/package.json +4 -28
  45. package/prompts/codegen.prompt.ts +259 -0
  46. package/prompts/consolidate.prompt.ts +73 -0
  47. package/prompts/constitution.prompt.ts +63 -0
  48. package/prompts/decompose.prompt.ts +168 -0
  49. package/prompts/dsl.prompt.ts +203 -0
  50. package/prompts/frontend-spec.prompt.ts +191 -0
  51. package/prompts/global-constitution.prompt.ts +61 -0
  52. package/prompts/spec-assess.prompt.ts +53 -0
  53. package/prompts/spec.prompt.ts +102 -0
  54. package/prompts/tasks.prompt.ts +35 -0
  55. package/prompts/testgen.prompt.ts +84 -0
  56. package/prompts/update.prompt.ts +131 -0
  57. package/purpose.docx +0 -0
  58. package/purpose.md +444 -0
  59. package/tsconfig.json +14 -0
  60. package/tsup.config.ts +10 -0
package/cli/index.ts ADDED
@@ -0,0 +1,1961 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import * as path from "path";
4
+ import * as fs from "fs-extra";
5
+ import chalk from "chalk";
6
+ import * as dotenv from "dotenv";
7
+ import { input, confirm, select, checkbox } from "@inquirer/prompts";
8
+
9
+ dotenv.config();
10
+
11
+ import {
12
+ SpecGenerator,
13
+ createProvider,
14
+ DEFAULT_MODELS,
15
+ ENV_KEY_MAP,
16
+ SUPPORTED_PROVIDERS,
17
+ PROVIDER_CATALOG,
18
+ AIProvider,
19
+ } from "../core/spec-generator";
20
+ import { ContextLoader, isFrontendDeps } from "../core/context-loader";
21
+ import { SpecRefiner } from "../core/spec-refiner";
22
+ import { CodeGenerator, CodeGenMode } from "../core/code-generator";
23
+ import { CodeReviewer } from "../core/reviewer";
24
+ import { GitWorktreeManager } from "../git/worktree";
25
+ import { ConstitutionGenerator, CONSTITUTION_FILE, printConstitutionHint } from "../core/constitution-generator";
26
+ import { ConstitutionConsolidator } from "../core/constitution-consolidator";
27
+ import { TaskGenerator, printTasks } from "../core/task-generator";
28
+ import { generateSpecWithTasks } from "../core/combined-generator";
29
+ import {
30
+ slugify,
31
+ findLatestVersion,
32
+ nextVersionPath,
33
+ computeDiff,
34
+ printDiff,
35
+ printDiffSummary,
36
+ } from "../core/spec-versioning";
37
+ import { DslExtractor } from "../core/dsl-extractor";
38
+ import { TestGenerator } from "../core/test-generator";
39
+ import { runErrorFeedback } from "../core/error-feedback";
40
+ import { assessSpec, printSpecAssessment } from "../core/spec-assessor";
41
+ import { accumulateReviewKnowledge } from "../core/knowledge-memory";
42
+ import {
43
+ WorkspaceLoader,
44
+ WorkspaceConfig,
45
+ RepoConfig,
46
+ WORKSPACE_CONFIG_FILE,
47
+ detectRepoType,
48
+ } from "../core/workspace-loader";
49
+ import {
50
+ loadGlobalConstitution,
51
+ saveGlobalConstitution,
52
+ GLOBAL_CONSTITUTION_FILE,
53
+ } from "../core/global-constitution";
54
+ import { getSavedKey, saveKey, clearAllKeys, clearKey, KEY_STORE_FILE } from "../core/key-store";
55
+ import { printWelcome } from "./welcome";
56
+ import {
57
+ globalConstitutionSystemPrompt,
58
+ buildGlobalConstitutionPrompt,
59
+ } from "../prompts/global-constitution.prompt";
60
+ import { RequirementDecomposer, DecompositionResult, RepoRequirement } from "../core/requirement-decomposer";
61
+ import { buildFrontendApiContract, buildContractContextSection } from "../core/contract-bridge";
62
+ import { loadFrontendContext, buildFrontendContextSection } from "../core/frontend-context-loader";
63
+ import { buildFrontendSpecPrompt, frontendSpecSystemPrompt } from "../prompts/frontend-spec.prompt";
64
+ import { SpecDSL } from "../core/dsl-types";
65
+ import { generateMockAssets, findLatestDslFile } from "../core/mock-server-generator";
66
+ import { SpecUpdater } from "../core/spec-updater";
67
+ import { exportOpenApi } from "../core/openapi-exporter";
68
+
69
+ // ─── Config File ──────────────────────────────────────────────────────────────
70
+
71
+ interface AiSpecConfig {
72
+ provider?: string;
73
+ model?: string;
74
+ codegen?: CodeGenMode;
75
+ codegenProvider?: string;
76
+ codegenModel?: string;
77
+ }
78
+
79
+ const CONFIG_FILE = ".ai-spec.json";
80
+
81
+ async function loadConfig(dir: string): Promise<AiSpecConfig> {
82
+ const p = path.join(dir, CONFIG_FILE);
83
+ if (await fs.pathExists(p)) {
84
+ return fs.readJson(p);
85
+ }
86
+ return {};
87
+ }
88
+
89
+ // ─── API Key Resolution ────────────────────────────────────────────────────────
90
+
91
+ async function resolveApiKey(
92
+ providerName: string,
93
+ cliKey?: string
94
+ ): Promise<string> {
95
+ if (cliKey) return cliKey;
96
+
97
+ const envVar = ENV_KEY_MAP[providerName];
98
+ if (envVar && process.env[envVar]) return process.env[envVar]!;
99
+
100
+ // Check saved key and offer reuse
101
+ const savedKey = await getSavedKey(providerName);
102
+ if (savedKey) {
103
+ const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);
104
+ const choice = await select({
105
+ message: `${providerName} API key (saved: ${masked}):`,
106
+ choices: [
107
+ { name: "Use saved key", value: "reuse" },
108
+ { name: "Enter a new key", value: "new" },
109
+ ],
110
+ });
111
+ if (choice === "reuse") return savedKey;
112
+ }
113
+
114
+ // Fresh input — save for next time
115
+ const newKey = await input({
116
+ message: `Enter your ${providerName} API key${envVar ? ` (or set ${envVar} env var)` : ""}:`,
117
+ validate: (v) => v.trim().length > 0 || "API key cannot be empty",
118
+ });
119
+ await saveKey(providerName, newKey.trim());
120
+ console.log(chalk.gray(` Key saved to ${KEY_STORE_FILE}`));
121
+ return newKey.trim();
122
+ }
123
+
124
+ // ─── Banner ───────────────────────────────────────────────────────────────────
125
+
126
+ function printBanner(opts: {
127
+ specProvider: string;
128
+ specModel: string;
129
+ codegenMode: string;
130
+ codegenProvider: string;
131
+ codegenModel: string;
132
+ }) {
133
+ console.log(chalk.blue("\n" + "─".repeat(52)));
134
+ console.log(chalk.bold(" ai-spec — AI-driven Development Orchestrator"));
135
+ console.log(chalk.blue("─".repeat(52)));
136
+ console.log(chalk.gray(` Spec : ${opts.specProvider} / ${opts.specModel}`));
137
+ console.log(
138
+ chalk.gray(
139
+ ` Codegen : ${opts.codegenMode} (${opts.codegenProvider} / ${opts.codegenModel})`
140
+ )
141
+ );
142
+ console.log(chalk.blue("─".repeat(52) + "\n"));
143
+ }
144
+
145
+ // ─── Program ──────────────────────────────────────────────────────────────────
146
+
147
+ const program = new Command();
148
+
149
+ program
150
+ .name("ai-spec")
151
+ .description("AI-driven Development Orchestrator — spec, generate, review")
152
+ .version("0.14.1");
153
+
154
+ // ═══════════════════════════════════════════════════════════════════════════════
155
+ // Command: create
156
+ // ═══════════════════════════════════════════════════════════════════════════════
157
+
158
+ program
159
+ .command("create")
160
+ .description("Generate a feature spec and kick off code generation")
161
+ .argument("[idea]", "Feature idea in natural language (prompted if omitted)")
162
+ .option(
163
+ "--provider <name>",
164
+ `AI provider for spec generation (${SUPPORTED_PROVIDERS.join("|")})`,
165
+ undefined
166
+ )
167
+ .option("--model <name>", "Model name for spec generation")
168
+ .option("-k, --key <apiKey>", "API key (overrides env var)")
169
+ .option(
170
+ "--codegen <mode>",
171
+ "Code generation mode: claude-code | api | plan",
172
+ undefined
173
+ )
174
+ .option(
175
+ "--codegen-provider <name>",
176
+ "AI provider for code generation (defaults to --provider)"
177
+ )
178
+ .option("--codegen-model <name>", "Model for code generation")
179
+ .option("--codegen-key <key>", "API key for code generation (if different)")
180
+ .option("--skip-worktree", "Skip git worktree creation (auto-set for frontend projects)")
181
+ .option("--worktree", "Force git worktree creation even for frontend projects")
182
+ .option("--skip-review", "Skip automated code review")
183
+ .option("--skip-tasks", "Skip task generation (just generate spec)")
184
+ .option("--auto", "Run claude non-interactively via -p flag (saves tokens)")
185
+ .option("--fast", "Skip interactive spec refinement, proceed immediately with initial spec")
186
+ .option("--resume", "Resume an interrupted run — skip tasks already marked as done")
187
+ .option("--skip-dsl", "Skip DSL extraction step")
188
+ .option("--skip-tests", "Skip test skeleton generation")
189
+ .option("--skip-error-feedback", "Skip error feedback loop (test/lint auto-fix)")
190
+ .option("--tdd", "TDD mode: generate failing tests first, then generate implementation to pass them")
191
+ .option("--skip-assessment", "Skip spec quality pre-assessment before the Approval Gate")
192
+ .action(async (idea: string | undefined, opts) => {
193
+ const currentDir = process.cwd();
194
+ const config = await loadConfig(currentDir);
195
+
196
+ // ── Resolve idea ──────────────────────────────────────────────────────────
197
+ if (!idea) {
198
+ idea = await input({
199
+ message: "What feature do you want to build?",
200
+ validate: (v) => v.trim().length > 0 || "Please describe your feature",
201
+ });
202
+ }
203
+
204
+ // ── Detect workspace mode ─────────────────────────────────────────────────
205
+ const workspaceLoader = new WorkspaceLoader(currentDir);
206
+ const workspaceConfig = await workspaceLoader.load();
207
+
208
+ if (workspaceConfig) {
209
+ console.log(chalk.cyan(`\n[Workspace] Detected workspace: ${workspaceConfig.name}`));
210
+ console.log(chalk.gray(` Repos: ${workspaceConfig.repos.map((r) => r.name).join(", ")}`));
211
+ await runMultiRepoPipeline(idea!, workspaceConfig, opts, currentDir, config);
212
+ return;
213
+ }
214
+
215
+ // ── Resolve spec provider ─────────────────────────────────────────────────
216
+ const specProviderName = opts.provider || config.provider || "gemini";
217
+ const specModelName =
218
+ opts.model || config.model || DEFAULT_MODELS[specProviderName];
219
+ const specApiKey = await resolveApiKey(specProviderName, opts.key);
220
+
221
+ // ── Resolve codegen ───────────────────────────────────────────────────────
222
+ const codegenMode: CodeGenMode =
223
+ (opts.codegen as CodeGenMode) || config.codegen || "claude-code";
224
+ const codegenProviderName =
225
+ opts.codegenProvider || config.codegenProvider || specProviderName;
226
+ const codegenModelName =
227
+ opts.codegenModel ||
228
+ config.codegenModel ||
229
+ DEFAULT_MODELS[codegenProviderName];
230
+ const codegenApiKey =
231
+ codegenProviderName === specProviderName
232
+ ? specApiKey
233
+ : await resolveApiKey(codegenProviderName, opts.codegenKey);
234
+
235
+ printBanner({
236
+ specProvider: specProviderName,
237
+ specModel: specModelName,
238
+ codegenMode,
239
+ codegenProvider: codegenProviderName,
240
+ codegenModel: codegenModelName,
241
+ });
242
+
243
+ // ── Step 1: Context ───────────────────────────────────────────────────────
244
+ console.log(chalk.blue("[1/6] Loading project context..."));
245
+ const loader = new ContextLoader(currentDir);
246
+ const context = await loader.loadProjectContext();
247
+ const { type: detectedRepoType } = await detectRepoType(currentDir);
248
+ console.log(chalk.gray(` Tech stack : ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
249
+ console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
250
+ console.log(chalk.gray(` API files : ${context.apiStructure.length} files`));
251
+ if (context.schema) {
252
+ console.log(chalk.gray(` Prisma schema: found`));
253
+ }
254
+ if (context.constitution) {
255
+ console.log(chalk.green(` Constitution : found (.ai-spec-constitution.md)`));
256
+ } else {
257
+ // Auto-run init: generate constitution before proceeding
258
+ console.log(chalk.yellow(" Constitution : not found — auto-generating..."));
259
+ try {
260
+ const constitutionGen = new ConstitutionGenerator(
261
+ createProvider(specProviderName, specApiKey, specModelName)
262
+ );
263
+ const constitutionContent = await constitutionGen.generate(currentDir);
264
+ await constitutionGen.saveConstitution(currentDir, constitutionContent);
265
+ context.constitution = constitutionContent;
266
+ console.log(chalk.green(` Constitution : ✔ generated and saved (.ai-spec-constitution.md)`));
267
+ } catch (err) {
268
+ console.log(chalk.yellow(` Constitution : ⚠ auto-generation failed (${(err as Error).message}), continuing without it.`));
269
+ }
270
+ }
271
+
272
+ // ── Step 2: Spec + Tasks Generation (single AI call) ─────────────────────
273
+ console.log(chalk.blue(`\n[2/6] Generating spec with ${specProviderName}/${specModelName}...`));
274
+ const specProvider = createProvider(specProviderName, specApiKey, specModelName);
275
+
276
+ let initialSpec: string;
277
+ let initialTasks: import("../core/task-generator").SpecTask[] = [];
278
+
279
+ try {
280
+ if (opts.skipTasks) {
281
+ // Tasks skipped: use SpecGenerator alone
282
+ const generator = new SpecGenerator(specProvider);
283
+ initialSpec = await generator.generateSpec(idea, context);
284
+ console.log(chalk.green(" ✔ Spec generated."));
285
+ } else {
286
+ // Combined: spec + tasks in one call
287
+ const result = await generateSpecWithTasks(specProvider, idea, context);
288
+ initialSpec = result.spec;
289
+ initialTasks = result.tasks;
290
+ console.log(chalk.green(` ✔ Spec generated.`));
291
+ if (initialTasks.length > 0) {
292
+ console.log(chalk.green(` ✔ ${initialTasks.length} tasks generated (combined call).`));
293
+ } else {
294
+ console.log(chalk.yellow(" ⚠ Tasks not parsed from response — will retry separately after refinement."));
295
+ }
296
+ }
297
+ } catch (err) {
298
+ console.error(chalk.red(" ✘ Spec generation failed:"), err);
299
+ process.exit(1);
300
+ }
301
+
302
+ // ── Step 3: Interactive Refinement ────────────────────────────────────────
303
+ let finalSpec: string;
304
+ if (opts.fast) {
305
+ console.log(chalk.gray("\n[3/6] Skipping refinement (--fast)."));
306
+ finalSpec = initialSpec;
307
+ } else {
308
+ console.log(chalk.blue("\n[3/6] Interactive spec refinement..."));
309
+ const refiner = new SpecRefiner(specProvider);
310
+ finalSpec = await refiner.refineLoop(initialSpec);
311
+ }
312
+
313
+ // Compute slug once — used in Approval Gate diff preview and versioned file save
314
+ const featureSlug = slugify(idea!);
315
+
316
+ // ── Step 3.4: Spec Quality Pre-Assessment ────────────────────────────────
317
+ // Advisory only — never blocks the flow. Skipped in --auto mode and with --skip-assessment.
318
+ if (!opts.auto && !opts.skipAssessment) {
319
+ console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
320
+ const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? undefined);
321
+ if (assessment) {
322
+ printSpecAssessment(assessment);
323
+ } else {
324
+ console.log(chalk.gray(" (Assessment skipped — AI call failed or timed out)"));
325
+ }
326
+ }
327
+
328
+ // ── Step 3.5: Approval Gate ───────────────────────────────────────────────
329
+ if (!opts.auto) {
330
+ console.log(chalk.blue("\n[3.5/6] Approval Gate — review before code generation"));
331
+
332
+ // Show spec stats
333
+ const specLines = finalSpec.split("\n").length;
334
+ const specWords = finalSpec.split(/\s+/).length;
335
+ const taskCountHint = initialTasks.length > 0 ? ` Tasks generated : ${initialTasks.length}` : "";
336
+ console.log(chalk.gray(` Spec length : ${specLines} lines / ${specWords} words`));
337
+ if (taskCountHint) console.log(chalk.gray(taskCountHint));
338
+
339
+ // Show diff vs previous version if one exists
340
+ const previewSpecsDir = path.join(currentDir, "specs");
341
+ const slug = featureSlug;
342
+ const prevVersion = await findLatestVersion(previewSpecsDir, slug);
343
+ if (prevVersion) {
344
+ console.log(chalk.gray(` Previous version: v${prevVersion.version} (${prevVersion.filePath})`));
345
+ const diff = computeDiff(prevVersion.content, finalSpec);
346
+ console.log(chalk.cyan("\n ── Changes vs previous version ──────────────"));
347
+ printDiffSummary(diff, `v${prevVersion.version} → v${prevVersion.version + 1}`);
348
+ printDiff(diff);
349
+ console.log(chalk.cyan(" ────────────────────────────────────────────"));
350
+ }
351
+
352
+ const gate = await select({
353
+ message: "Ready to proceed to code generation?",
354
+ choices: [
355
+ { name: "✅ Proceed — start code generation", value: "proceed" },
356
+ { name: "📋 View full spec", value: "view" },
357
+ { name: "❌ Abort", value: "abort" },
358
+ ],
359
+ });
360
+
361
+ if (gate === "view") {
362
+ console.log(chalk.cyan("\n" + "─".repeat(52)));
363
+ console.log(finalSpec);
364
+ console.log(chalk.cyan("─".repeat(52) + "\n"));
365
+
366
+ const confirm2 = await select({
367
+ message: "Proceed to code generation?",
368
+ choices: [
369
+ { name: "✅ Proceed", value: "proceed" },
370
+ { name: "❌ Abort", value: "abort" },
371
+ ],
372
+ });
373
+ if (confirm2 === "abort") {
374
+ console.log(chalk.yellow(" Aborted. Spec was NOT saved."));
375
+ process.exit(0);
376
+ }
377
+ } else if (gate === "abort") {
378
+ console.log(chalk.yellow(" Aborted. Spec was NOT saved."));
379
+ process.exit(0);
380
+ }
381
+
382
+ console.log(chalk.green(" ✔ Approved — continuing to code generation."));
383
+ } else {
384
+ console.log(chalk.gray("[3.5/6] Approval Gate: skipped (--auto)."));
385
+ }
386
+
387
+ // ── Step 3.8: DSL Extraction + Validation ─────────────────────────────────
388
+ // Runs after approval, before worktree, so the DSL is saved alongside the spec.
389
+ // We extract from finalSpec (already approved). The DSL file is saved in Step 5.
390
+ let extractedDsl: import("../core/dsl-types").SpecDSL | null = null;
391
+
392
+ if (opts.skipDsl) {
393
+ console.log(chalk.gray("\n[DSL] Skipped (--skip-dsl)."));
394
+ } else {
395
+ console.log(chalk.blue("\n[DSL] Extracting structured DSL from spec..."));
396
+ console.log(chalk.gray(` Provider: ${specProviderName}/${specModelName}`));
397
+ try {
398
+ const isFrontend = isFrontendDeps(context.dependencies);
399
+ if (isFrontend) console.log(chalk.gray(" Frontend project detected — using ComponentSpec extractor"));
400
+ const dslExtractor = new DslExtractor(specProvider);
401
+ extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
402
+ if (extractedDsl) {
403
+ console.log(chalk.green(" ✔ DSL extracted and validated."));
404
+ } else {
405
+ console.log(chalk.yellow(" ⚠ DSL skipped — codegen will use Spec + Tasks only."));
406
+ }
407
+ } catch (err) {
408
+ // Unexpected error (not user abort — that would have called process.exit)
409
+ console.log(chalk.yellow(` ⚠ DSL extraction error: ${(err as Error).message} — continuing without DSL.`));
410
+ }
411
+ }
412
+
413
+ // ── Step 4: Git Worktree ──────────────────────────────────────────────────
414
+ // Frontend projects (React / Vue / Next / React-Native) work directly on a
415
+ // feature branch — no worktree needed. node_modules is not copied into
416
+ // worktrees by default, so running the dev server would break.
417
+ // Auto-skip unless the user explicitly passes --worktree.
418
+ const isFrontendProject = isFrontendDeps(context.dependencies ?? []);
419
+ const skipWorktree = opts.worktree
420
+ ? false
421
+ : opts.skipWorktree || isFrontendProject;
422
+
423
+ let workingDir = currentDir;
424
+ if (!skipWorktree) {
425
+ console.log(chalk.blue("\n[4/6] Setting up git worktree..."));
426
+ const worktreeManager = new GitWorktreeManager(currentDir);
427
+ const worktreePath = await worktreeManager.createWorktree(idea);
428
+ if (worktreePath) workingDir = worktreePath;
429
+ } else {
430
+ const reason = opts.worktree
431
+ ? ""
432
+ : isFrontendProject
433
+ ? " (frontend project — use --worktree to override)"
434
+ : " (--skip-worktree)";
435
+ console.log(chalk.gray(`[4/6] Skipping worktree${reason}.`));
436
+ }
437
+
438
+ // ── Step 5: Save Spec (versioned) + Generate Tasks ────────────────────────
439
+ const specsDir = path.join(workingDir, "specs");
440
+ await fs.ensureDir(specsDir);
441
+
442
+ const { filePath: specFile, version: specVersion } = await nextVersionPath(specsDir, featureSlug);
443
+ await fs.writeFile(specFile, finalSpec, "utf-8");
444
+ console.log(chalk.green(`\n[5/6] ✔ Spec saved: ${specFile}`) + chalk.gray(` (v${specVersion})`));
445
+
446
+ // Save DSL alongside the spec if extraction succeeded
447
+ let savedDslFile: string | null = null;
448
+ if (extractedDsl) {
449
+ const dslExtractor = new DslExtractor(specProvider);
450
+ savedDslFile = await dslExtractor.saveDsl(extractedDsl, specFile);
451
+ console.log(chalk.green(` ✔ DSL saved : ${savedDslFile}`));
452
+ }
453
+
454
+ if (!opts.skipTasks) {
455
+ const taskGen = new TaskGenerator(specProvider);
456
+ let tasksToSave = initialTasks;
457
+
458
+ // If combined call didn't produce tasks (parse failed), retry as a separate call
459
+ if (tasksToSave.length === 0) {
460
+ console.log(chalk.blue(`\n Generating tasks (separate call)...`));
461
+ try {
462
+ tasksToSave = await taskGen.generateTasks(finalSpec, context);
463
+ } catch (err) {
464
+ console.log(chalk.yellow(` ⚠ Task generation failed: ${(err as Error).message}`));
465
+ }
466
+ }
467
+
468
+ if (tasksToSave.length > 0) {
469
+ const sorted = taskGen.sortByLayer(tasksToSave);
470
+ const tasksFile = await taskGen.saveTasks(sorted, specFile);
471
+ printTasks(sorted);
472
+ console.log(chalk.green(` ✔ Tasks saved: ${tasksFile}`));
473
+ } else {
474
+ console.log(chalk.yellow(" ⚠ No tasks generated — code generation will use fallback file planning."));
475
+ }
476
+ }
477
+
478
+ // ── Step 6: Code Generation ───────────────────────────────────────────────
479
+ console.log(chalk.blue(`\n[6/6] Code generation (mode: ${codegenMode})...`));
480
+ const codegenProvider =
481
+ codegenProviderName === specProviderName && codegenApiKey === specApiKey
482
+ ? specProvider
483
+ : createProvider(codegenProviderName, codegenApiKey, codegenModelName);
484
+
485
+ // ── TDD: generate failing tests BEFORE implementation ────────────────────
486
+ let generatedTestFiles: string[] = [];
487
+ if (opts.tdd && extractedDsl) {
488
+ console.log(chalk.cyan("\n[TDD] Generating pre-implementation tests (will fail until code is written)..."));
489
+ const testGen = new TestGenerator(codegenProvider);
490
+ generatedTestFiles = await testGen.generateTdd(extractedDsl, workingDir);
491
+ }
492
+
493
+ const codegen = new CodeGenerator(codegenProvider, codegenMode);
494
+ const generatedFiles = await codegen.generateCode(specFile, workingDir, context, {
495
+ auto: opts.auto,
496
+ resume: opts.resume,
497
+ dslFilePath: savedDslFile ?? undefined,
498
+ repoType: detectedRepoType,
499
+ });
500
+
501
+ // ── Step 7: Test Skeleton Generation (skipped in TDD mode — tests already written) ──
502
+ if (opts.tdd) {
503
+ console.log(chalk.gray("\n[7/9] TDD mode — test files already written pre-implementation."));
504
+ } else if (opts.skipTests) {
505
+ console.log(chalk.gray("\n[7/9] Skipping test generation (--skip-tests)."));
506
+ } else if (!extractedDsl) {
507
+ console.log(chalk.gray("\n[7/9] Skipping test generation (no DSL available)."));
508
+ } else {
509
+ console.log(chalk.blue(`\n[7/9] Test skeleton generation...`));
510
+ const testGen = new TestGenerator(codegenProvider);
511
+ generatedTestFiles = await testGen.generate(extractedDsl, workingDir);
512
+ }
513
+
514
+ // ── Step 8: Error Feedback Loop ───────────────────────────────────────────
515
+ // In TDD mode, the error feedback loop is the primary driver:
516
+ // it runs tests, collects failures, and fixes implementation until tests pass.
517
+ if (opts.skipErrorFeedback) {
518
+ console.log(chalk.gray("[8/9] Skipping error feedback (--skip-error-feedback)."));
519
+ } else {
520
+ if (opts.tdd) {
521
+ console.log(chalk.cyan("[8/9] TDD mode — error feedback loop driving implementation to pass tests..."));
522
+ }
523
+ await runErrorFeedback(codegenProvider, workingDir, extractedDsl, {
524
+ maxCycles: opts.tdd ? 3 : 2, // TDD gets one extra cycle
525
+ });
526
+ }
527
+
528
+ // ── Step 9: Code Review ───────────────────────────────────────────────────
529
+ let reviewResult = "";
530
+ if (!opts.skipReview) {
531
+ console.log(chalk.blue("\n[9/9] Automated code review (2-pass: architecture + implementation)..."));
532
+ const reviewer = new CodeReviewer(specProvider, currentDir);
533
+ const savedSpec = await fs.readFile(specFile, "utf-8");
534
+
535
+ if (codegenMode === "api" && generatedFiles.length > 0) {
536
+ // api mode: review the generated files directly (no git diff available)
537
+ reviewResult = await reviewer.reviewFiles(savedSpec, generatedFiles, workingDir, specFile);
538
+ } else {
539
+ // claude-code / plan mode: review via git diff
540
+ const originalDir = process.cwd();
541
+ try {
542
+ process.chdir(workingDir);
543
+ reviewResult = await reviewer.reviewCode(savedSpec, specFile);
544
+ } finally {
545
+ process.chdir(originalDir);
546
+ }
547
+ }
548
+
549
+ // Knowledge Memory: extract lessons from review and append to constitution §9
550
+ await accumulateReviewKnowledge(specProvider, currentDir, reviewResult);
551
+ }
552
+
553
+ // ── Done ──────────────────────────────────────────────────────────────────
554
+ console.log(chalk.bold.green("\n✔ All done!"));
555
+ console.log(chalk.gray(` Spec : ${specFile}`));
556
+ if (savedDslFile) console.log(chalk.gray(` DSL : ${savedDslFile}`));
557
+ if (generatedTestFiles.length > 0) {
558
+ console.log(chalk.gray(` Tests : ${generatedTestFiles.length} skeleton file(s) generated`));
559
+ }
560
+ console.log(chalk.gray(` Working dir : ${workingDir}`));
561
+ if (workingDir !== currentDir) {
562
+ console.log(chalk.gray(` Run \`cd ${workingDir}\` to enter the worktree.`));
563
+ }
564
+ });
565
+
566
+ // ═══════════════════════════════════════════════════════════════════════════════
567
+ // Command: review
568
+ // ═══════════════════════════════════════════════════════════════════════════════
569
+
570
+ program
571
+ .command("review")
572
+ .description("Run AI code review on current git diff against a spec")
573
+ .argument("[specFile]", "Path to spec file (auto-detects latest in specs/ if omitted)")
574
+ .option(
575
+ "--provider <name>",
576
+ `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
577
+ undefined
578
+ )
579
+ .option("--model <name>", "Model name")
580
+ .option("-k, --key <apiKey>", "API key")
581
+ .action(async (specFile: string | undefined, opts) => {
582
+ const currentDir = process.cwd();
583
+ const config = await loadConfig(currentDir);
584
+
585
+ const providerName = opts.provider || config.provider || "gemini";
586
+ const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
587
+ const apiKey = await resolveApiKey(providerName, opts.key);
588
+
589
+ const provider = createProvider(providerName, apiKey, modelName);
590
+ const reviewer = new CodeReviewer(provider, currentDir);
591
+
592
+ let specContent = "";
593
+ let resolvedSpecFile: string | undefined;
594
+
595
+ if (specFile && (await fs.pathExists(specFile))) {
596
+ specContent = await fs.readFile(specFile, "utf-8");
597
+ resolvedSpecFile = specFile;
598
+ console.log(chalk.gray(`Using spec: ${specFile}`));
599
+ } else {
600
+ // Auto-detect the latest spec in specs/
601
+ const specsDir = path.join(currentDir, "specs");
602
+ if (await fs.pathExists(specsDir)) {
603
+ const files = (await fs.readdir(specsDir))
604
+ .filter((f) => f.endsWith(".md"))
605
+ .sort()
606
+ .reverse();
607
+ if (files.length > 0) {
608
+ const latest = path.join(specsDir, files[0]);
609
+ specContent = await fs.readFile(latest, "utf-8");
610
+ resolvedSpecFile = latest;
611
+ console.log(chalk.gray(`Auto-detected spec: specs/${files[0]}`));
612
+ }
613
+ }
614
+ }
615
+
616
+ if (!specContent) {
617
+ console.log(chalk.yellow("No spec file found. Running review without spec context."));
618
+ }
619
+
620
+ await reviewer.reviewCode(specContent, resolvedSpecFile);
621
+ await reviewer.printScoreTrend();
622
+ });
623
+
624
+ // ═══════════════════════════════════════════════════════════════════════════════
625
+ // Command: init — generate Project Constitution
626
+ // ═══════════════════════════════════════════════════════════════════════════════
627
+
628
+ program
629
+ .command("init")
630
+ .description(`Analyze codebase and generate Project Constitution (${CONSTITUTION_FILE})`)
631
+ .option(
632
+ "--provider <name>",
633
+ `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
634
+ undefined
635
+ )
636
+ .option("--model <name>", "Model name")
637
+ .option("-k, --key <apiKey>", "API key")
638
+ .option("--force", "Overwrite existing constitution")
639
+ .option(
640
+ "--global",
641
+ `Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
642
+ )
643
+ .option("--consolidate", "Consolidate §9 accumulated lessons into §1–§8 core rules (prune & rebase)")
644
+ .option("--dry-run", "Preview consolidation result without writing (use with --consolidate)")
645
+ .action(async (opts) => {
646
+ const currentDir = process.cwd();
647
+ const config = await loadConfig(currentDir);
648
+
649
+ const providerName = opts.provider || config.provider || "gemini";
650
+ const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
651
+ const apiKey = await resolveApiKey(providerName, opts.key);
652
+ const provider = createProvider(providerName, apiKey, modelName);
653
+
654
+ // ── Consolidate mode ─────────────────────────────────────────────────
655
+ if (opts.consolidate) {
656
+ const consolidator = new ConstitutionConsolidator(provider);
657
+ try {
658
+ const result = await consolidator.consolidate(currentDir, {
659
+ dryRun: opts.dryRun,
660
+ auto: opts.auto,
661
+ });
662
+ if (result.written) {
663
+ console.log(chalk.blue("\n Summary:"));
664
+ console.log(chalk.gray(` Lines : ${result.before.totalLines} → ${result.after.totalLines} (${result.before.totalLines - result.after.totalLines > 0 ? "-" : "+"}${Math.abs(result.before.totalLines - result.after.totalLines)})`));
665
+ console.log(chalk.gray(` §9 : ${result.before.lessonCount} → ${result.after.lessonCount} lessons remaining`));
666
+ if (result.backupPath) {
667
+ console.log(chalk.gray(` Backup: ${path.basename(result.backupPath)}`));
668
+ }
669
+ }
670
+ } catch (err) {
671
+ console.error(chalk.red(` ✘ Consolidation failed: ${(err as Error).message}`));
672
+ process.exit(1);
673
+ }
674
+ return;
675
+ }
676
+
677
+ // ── Global constitution mode ──────────────────────────────────────────
678
+ if (opts.global) {
679
+ const existing = await loadGlobalConstitution([currentDir]);
680
+ if (existing && !opts.force) {
681
+ console.log(chalk.yellow(`\n Global constitution already exists at: ${existing.source}`));
682
+ console.log(chalk.gray(" Use --force to overwrite it."));
683
+ return;
684
+ }
685
+
686
+ console.log(chalk.blue("\n─── Generating Global Constitution ──────────────"));
687
+ console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
688
+ console.log(chalk.gray(" Scanning repos in workspace..."));
689
+
690
+ // Build per-repo summaries from sibling directories
691
+ const loader = new ContextLoader(currentDir);
692
+ const ctx = await loader.loadProjectContext();
693
+ const summary = [
694
+ `Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
695
+ `Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`,
696
+ ].join("\n");
697
+
698
+ const prompt = buildGlobalConstitutionPrompt([{ name: path.basename(currentDir), summary }]);
699
+ let globalConstitution: string;
700
+ try {
701
+ globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
702
+ } catch (err) {
703
+ console.error(chalk.red(" ✘ Failed to generate global constitution:"), err);
704
+ process.exit(1);
705
+ }
706
+
707
+ const saved = await saveGlobalConstitution(globalConstitution, currentDir);
708
+ console.log(chalk.green(`\n ✔ Global constitution saved: ${saved}`));
709
+ console.log(chalk.gray(" This will be automatically merged into all project constitutions in this workspace."));
710
+ console.log(chalk.gray(" Project-level rules always override global rules.\n"));
711
+ console.log(chalk.bold(" Preview:"));
712
+ console.log(chalk.gray(globalConstitution.split("\n").slice(0, 12).join("\n")));
713
+ if (globalConstitution.split("\n").length > 12) {
714
+ console.log(chalk.gray(` ... (${globalConstitution.split("\n").length} lines total)`));
715
+ }
716
+ return;
717
+ }
718
+
719
+ // ── Project constitution mode (default) ───────────────────────────────
720
+ const constitutionPath = path.join(currentDir, CONSTITUTION_FILE);
721
+
722
+ if (!opts.force && (await fs.pathExists(constitutionPath))) {
723
+ console.log(chalk.yellow(`\n ${CONSTITUTION_FILE} already exists.`));
724
+ console.log(chalk.gray(" Use --force to overwrite it."));
725
+ console.log(chalk.gray(` Or edit it directly: ${constitutionPath}`));
726
+ return;
727
+ }
728
+
729
+ console.log(chalk.blue("\n─── Generating Project Constitution ─────────────"));
730
+ console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
731
+ console.log(chalk.gray(" Analyzing codebase..."));
732
+
733
+ const generator = new ConstitutionGenerator(provider);
734
+
735
+ let constitution: string;
736
+ try {
737
+ constitution = await generator.generate(currentDir);
738
+ } catch (err) {
739
+ console.error(chalk.red(" ✘ Failed to generate constitution:"), err);
740
+ process.exit(1);
741
+ }
742
+
743
+ const saved = await generator.saveConstitution(currentDir, constitution);
744
+
745
+ // Note if a global constitution will also be applied
746
+ const globalResult = await loadGlobalConstitution([path.dirname(currentDir)]);
747
+ if (globalResult) {
748
+ console.log(chalk.cyan(`\n ℹ Global constitution detected: ${globalResult.source}`));
749
+ console.log(chalk.gray(" It will be merged with this project constitution at runtime."));
750
+ console.log(chalk.gray(" Project rules take priority over global rules."));
751
+ }
752
+
753
+ console.log(chalk.green(`\n ✔ Constitution saved: ${saved}`));
754
+ console.log(chalk.gray(" This file will be automatically used in all future `ai-spec create` runs."));
755
+ console.log(chalk.gray(" Edit it to add custom rules or red lines for your project.\n"));
756
+ console.log(chalk.bold(" Preview:"));
757
+ console.log(chalk.gray(constitution.split("\n").slice(0, 15).join("\n")));
758
+ if (constitution.split("\n").length > 15) {
759
+ console.log(chalk.gray(` ... (${constitution.split("\n").length} lines total)`));
760
+ }
761
+ });
762
+
763
+ // ═══════════════════════════════════════════════════════════════════════════════
764
+ // Command: config
765
+ // ═══════════════════════════════════════════════════════════════════════════════
766
+
767
+ program
768
+ .command("config")
769
+ .description(`Set default configuration for this project (saved to ${CONFIG_FILE})`)
770
+ .option("--provider <name>", "Default AI provider for spec generation")
771
+ .option("--model <name>", "Default model for spec generation")
772
+ .option(
773
+ "--codegen <mode>",
774
+ "Default code generation mode (claude-code|api|plan)"
775
+ )
776
+ .option("--codegen-provider <name>", "Default provider for code generation")
777
+ .option("--codegen-model <name>", "Default model for code generation")
778
+ .option("--show", "Print current configuration")
779
+ .option("--reset", "Reset configuration to empty")
780
+ .option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json")
781
+ .option("--clear-key <provider>", "Delete saved API key for a specific provider")
782
+ .option("--list-keys", "Show which providers have a saved key")
783
+ .action(async (opts) => {
784
+ const currentDir = process.cwd();
785
+ const configPath = path.join(currentDir, CONFIG_FILE);
786
+
787
+ if (opts.clearKeys) {
788
+ await clearAllKeys();
789
+ console.log(chalk.green(`✔ All saved API keys cleared.`));
790
+ return;
791
+ }
792
+
793
+ if (opts.clearKey) {
794
+ await clearKey(opts.clearKey);
795
+ console.log(chalk.green(`✔ Saved key for "${opts.clearKey}" removed.`));
796
+ return;
797
+ }
798
+
799
+ if (opts.listKeys) {
800
+ const store: Record<string, string> = await fs.readJson(KEY_STORE_FILE).catch(() => ({}));
801
+ const providers = Object.keys(store);
802
+ if (providers.length === 0) {
803
+ console.log(chalk.gray("No saved API keys."));
804
+ } else {
805
+ console.log(chalk.bold("Saved API keys:"));
806
+ for (const p of providers) {
807
+ const k = store[p];
808
+ console.log(chalk.gray(` ${p}: ${k.slice(0, 6)}...${k.slice(-4)}`));
809
+ }
810
+ console.log(chalk.gray(`\nFile: ${KEY_STORE_FILE}`));
811
+ }
812
+ return;
813
+ }
814
+
815
+ if (opts.reset) {
816
+ await fs.writeJson(configPath, {}, { spaces: 2 });
817
+ console.log(chalk.green(`✔ Config reset: ${configPath}`));
818
+ return;
819
+ }
820
+
821
+ const existing: AiSpecConfig = await loadConfig(currentDir);
822
+
823
+ if (opts.show) {
824
+ if (Object.keys(existing).length === 0) {
825
+ console.log(chalk.gray("No config file found. Using built-in defaults."));
826
+ } else {
827
+ console.log(chalk.bold(`${configPath}:`));
828
+ console.log(JSON.stringify(existing, null, 2));
829
+ }
830
+ return;
831
+ }
832
+
833
+ const updated: AiSpecConfig = { ...existing };
834
+ if (opts.provider) updated.provider = opts.provider;
835
+ if (opts.model) updated.model = opts.model;
836
+ if (opts.codegen) updated.codegen = opts.codegen as CodeGenMode;
837
+ if (opts.codegenProvider) updated.codegenProvider = opts.codegenProvider;
838
+ if (opts.codegenModel) updated.codegenModel = opts.codegenModel;
839
+
840
+ await fs.writeJson(configPath, updated, { spaces: 2 });
841
+ console.log(chalk.green(`✔ Config saved to ${configPath}`));
842
+ console.log(JSON.stringify(updated, null, 2));
843
+ });
844
+
845
+ // ═══════════════════════════════════════════════════════════════════════════════
846
+ // Command: model — interactive model switcher
847
+ // ═══════════════════════════════════════════════════════════════════════════════
848
+
849
+ program
850
+ .command("model")
851
+ .description("Interactively switch the active AI provider/model and save to .ai-spec.json")
852
+ .option("--list", "List all available providers and models")
853
+ .action(async (opts) => {
854
+ const currentDir = process.cwd();
855
+ const configPath = path.join(currentDir, CONFIG_FILE);
856
+
857
+ // ── --list: just print the catalog ────────────────────────────────────────
858
+ if (opts.list) {
859
+ console.log(chalk.bold("\nAvailable providers & models:\n"));
860
+ for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
861
+ console.log(
862
+ ` ${chalk.bold.cyan(key.padEnd(10))} ${chalk.white(meta.displayName)}`
863
+ );
864
+ console.log(chalk.gray(` ${meta.description}`));
865
+ console.log(
866
+ chalk.gray(
867
+ ` env: ${meta.envKey} | models: ${meta.models.join(", ")}`
868
+ )
869
+ );
870
+ console.log();
871
+ }
872
+ return;
873
+ }
874
+
875
+ const existing: AiSpecConfig = await loadConfig(currentDir);
876
+
877
+ console.log(chalk.blue("\n─── Model Switcher ─────────────────────────────"));
878
+ if (Object.keys(existing).length > 0) {
879
+ console.log(
880
+ chalk.gray(
881
+ ` Current: spec=${existing.provider ?? "gemini"}/${existing.model ?? DEFAULT_MODELS[existing.provider ?? "gemini"]}` +
882
+ (existing.codegenProvider
883
+ ? ` codegen=${existing.codegenProvider}/${existing.codegenModel ?? ""}`
884
+ : "")
885
+ )
886
+ );
887
+ }
888
+ console.log();
889
+
890
+ // ── What to configure ─────────────────────────────────────────────────────
891
+ const target = await select({
892
+ message: "Configure model for:",
893
+ choices: [
894
+ { name: "Spec generation (used for spec writing & refinement)", value: "spec" },
895
+ { name: "Code generation (used when --codegen api is active)", value: "codegen" },
896
+ { name: "Both (same provider/model for all tasks)", value: "both" },
897
+ ],
898
+ });
899
+
900
+ // ── Helper: pick provider + model ─────────────────────────────────────────
901
+ async function pickProviderAndModel(label: string): Promise<{ provider: string; model: string }> {
902
+ const providerKey = await select({
903
+ message: `${label} — select provider:`,
904
+ choices: Object.entries(PROVIDER_CATALOG).map(([key, meta]) => ({
905
+ name: `${meta.displayName.padEnd(22)} ${chalk.gray(meta.description)}`,
906
+ value: key,
907
+ short: meta.displayName,
908
+ })),
909
+ });
910
+
911
+ const meta = PROVIDER_CATALOG[providerKey];
912
+ const modelChoices = [
913
+ ...meta.models.map((m) => ({ name: m, value: m })),
914
+ { name: chalk.italic("✎ Enter custom model name..."), value: "__custom__" },
915
+ ];
916
+
917
+ let chosenModel = await select({
918
+ message: `${label} — select model (${meta.displayName}):`,
919
+ choices: modelChoices,
920
+ });
921
+
922
+ if (chosenModel === "__custom__") {
923
+ chosenModel = await input({
924
+ message: "Enter model name:",
925
+ validate: (v) => v.trim().length > 0 || "Model name cannot be empty",
926
+ });
927
+ }
928
+
929
+ return { provider: providerKey, model: chosenModel };
930
+ }
931
+
932
+ // ── Run picker(s) ─────────────────────────────────────────────────────────
933
+ const updated: AiSpecConfig = { ...existing };
934
+
935
+ if (target === "spec" || target === "both") {
936
+ const { provider, model } = await pickProviderAndModel("Spec");
937
+ updated.provider = provider;
938
+ updated.model = model;
939
+ }
940
+
941
+ if (target === "codegen" || target === "both") {
942
+ if (target === "both") {
943
+ updated.codegenProvider = updated.provider;
944
+ updated.codegenModel = updated.model;
945
+ } else {
946
+ const { provider, model } = await pickProviderAndModel("Codegen");
947
+ updated.codegenProvider = provider;
948
+ updated.codegenModel = model;
949
+ }
950
+
951
+ // claude-code 模式只支持 Claude provider。
952
+ // 如果选了非 claude provider,自动把 codegen 模式改为 api。
953
+ const effectiveCodegenProvider = updated.codegenProvider ?? updated.provider ?? "gemini";
954
+ if (effectiveCodegenProvider !== "claude") {
955
+ if (!updated.codegen || updated.codegen === "claude-code") {
956
+ updated.codegen = "api";
957
+ console.log(
958
+ chalk.yellow(
959
+ `\n ⚠ provider "${effectiveCodegenProvider}" 不支持 "claude-code" 模式。`
960
+ )
961
+ );
962
+ console.log(chalk.gray(` 已自动将 codegen 模式设为 "api"。`));
963
+ }
964
+ }
965
+ }
966
+
967
+ // ── Confirm & save ────────────────────────────────────────────────────────
968
+ console.log(chalk.blue("\n Preview:"));
969
+ console.log(chalk.gray(` spec → ${updated.provider}/${updated.model}`));
970
+ if (updated.codegenProvider) {
971
+ console.log(
972
+ chalk.gray(
973
+ ` codegen → ${updated.codegenProvider}/${updated.codegenModel} (mode: ${updated.codegen ?? "claude-code"})`
974
+ )
975
+ );
976
+ }
977
+
978
+ const ok = await confirm({ message: "Save to .ai-spec.json?", default: true });
979
+ if (!ok) {
980
+ console.log(chalk.gray(" Cancelled."));
981
+ return;
982
+ }
983
+
984
+ await fs.writeJson(configPath, updated, { spaces: 2 });
985
+ console.log(chalk.green(`\n ✔ Saved to ${configPath}`));
986
+
987
+ // Remind about env var if not set
988
+ const providerToCheck = updated.provider ?? "gemini";
989
+ const envKey = ENV_KEY_MAP[providerToCheck];
990
+ if (envKey && !process.env[envKey]) {
991
+ console.log(
992
+ chalk.yellow(
993
+ ` ⚠ Remember to set ${envKey} in your environment or .env file.`
994
+ )
995
+ );
996
+ }
997
+ });
998
+
999
+ // ═══════════════════════════════════════════════════════════════════════════════
1000
+ // Multi-Repo Pipeline
1001
+ // ═══════════════════════════════════════════════════════════════════════════════
1002
+
1003
+ /**
1004
+ * Run a single repo through the full spec→dsl→worktree→codegen→tests→review pipeline.
1005
+ * Returns the extracted DSL (or null) for contract bridging.
1006
+ */
1007
+ async function runSingleRepoPipelineInWorkspace(opts: {
1008
+ idea: string;
1009
+ specProvider: ReturnType<typeof createProvider>;
1010
+ specProviderName: string;
1011
+ specModelName: string;
1012
+ codegenProvider: ReturnType<typeof createProvider>;
1013
+ codegenMode: CodeGenMode;
1014
+ repoAbsPath: string;
1015
+ repoName: string;
1016
+ cliOpts: Record<string, unknown>;
1017
+ contractContextSection?: string;
1018
+ }): Promise<{ dsl: SpecDSL | null; specFile: string | null }> {
1019
+ const {
1020
+ idea,
1021
+ specProvider,
1022
+ specProviderName,
1023
+ specModelName,
1024
+ codegenProvider,
1025
+ codegenMode,
1026
+ repoAbsPath,
1027
+ repoName,
1028
+ cliOpts,
1029
+ contractContextSection,
1030
+ } = opts;
1031
+
1032
+ console.log(chalk.blue(`\n [${repoName}] Loading project context...`));
1033
+ const loader = new ContextLoader(repoAbsPath);
1034
+ let context = await loader.loadProjectContext();
1035
+
1036
+ // Detect repo language so the correct codegen system prompt is used
1037
+ const { type: detectedRepoType } = await detectRepoType(repoAbsPath);
1038
+
1039
+ console.log(chalk.gray(` Tech stack: ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
1040
+ console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
1041
+
1042
+ if (!context.constitution) {
1043
+ console.log(chalk.yellow(` Constitution: not found — auto-generating...`));
1044
+ try {
1045
+ const constitutionGen = new ConstitutionGenerator(specProvider);
1046
+ const constitutionContent = await constitutionGen.generate(repoAbsPath);
1047
+ await constitutionGen.saveConstitution(repoAbsPath, constitutionContent);
1048
+ context.constitution = constitutionContent;
1049
+ console.log(chalk.green(` Constitution: generated`));
1050
+ } catch (err) {
1051
+ console.log(chalk.yellow(` Constitution: auto-generation failed (${(err as Error).message}), continuing.`));
1052
+ }
1053
+ } else {
1054
+ console.log(chalk.green(` Constitution: found`));
1055
+ }
1056
+
1057
+ // Build the spec idea: inject contract context if available
1058
+ let fullIdea = idea;
1059
+ if (contractContextSection) {
1060
+ fullIdea = `${idea}\n\n${contractContextSection}`;
1061
+ }
1062
+
1063
+ console.log(chalk.blue(` [${repoName}] Generating spec...`));
1064
+ let finalSpec: string;
1065
+ try {
1066
+ const result = await generateSpecWithTasks(specProvider, fullIdea, context);
1067
+ finalSpec = result.spec;
1068
+ console.log(chalk.green(` Spec generated.`));
1069
+ } catch (err) {
1070
+ console.error(chalk.red(` Spec generation failed: ${(err as Error).message}`));
1071
+ return { dsl: null, specFile: null };
1072
+ }
1073
+
1074
+ // DSL Extraction
1075
+ let extractedDsl: SpecDSL | null = null;
1076
+ if (!cliOpts.skipDsl) {
1077
+ console.log(chalk.blue(` [${repoName}] Extracting DSL...`));
1078
+ try {
1079
+ const dslExtractor = new DslExtractor(specProvider);
1080
+ const repoIsFrontend = isFrontendDeps(context.dependencies);
1081
+ extractedDsl = await dslExtractor.extract(finalSpec, { auto: true, isFrontend: repoIsFrontend });
1082
+ if (extractedDsl) {
1083
+ console.log(chalk.green(` DSL extracted.`));
1084
+ }
1085
+ } catch (err) {
1086
+ console.log(chalk.yellow(` DSL extraction failed: ${(err as Error).message}`));
1087
+ }
1088
+ }
1089
+
1090
+ // Git Worktree — auto-skip for frontend repos
1091
+ const isFrontendRepo = isFrontendDeps(context.dependencies ?? []);
1092
+ const skipWorktreeForRepo = cliOpts.worktree
1093
+ ? false
1094
+ : cliOpts.skipWorktree || isFrontendRepo;
1095
+
1096
+ let workingDir = repoAbsPath;
1097
+ if (!skipWorktreeForRepo) {
1098
+ console.log(chalk.blue(` [${repoName}] Setting up git worktree...`));
1099
+ try {
1100
+ const worktreeManager = new GitWorktreeManager(repoAbsPath);
1101
+ const worktreePath = await worktreeManager.createWorktree(idea);
1102
+ if (worktreePath) workingDir = worktreePath;
1103
+ } catch (err) {
1104
+ console.log(chalk.yellow(` Worktree setup failed: ${(err as Error).message}. Using main branch.`));
1105
+ }
1106
+ } else {
1107
+ console.log(chalk.gray(` [${repoName}] Skipping worktree${isFrontendRepo ? " (frontend repo)" : ""}.`));
1108
+ }
1109
+
1110
+ // Save Spec
1111
+ const specsDir = path.join(workingDir, "specs");
1112
+ await fs.ensureDir(specsDir);
1113
+ const featureSlug = slugify(idea);
1114
+ const { filePath: specFile } = await nextVersionPath(specsDir, featureSlug);
1115
+ await fs.writeFile(specFile, finalSpec, "utf-8");
1116
+ console.log(chalk.green(` Spec saved: ${path.relative(repoAbsPath, specFile)}`));
1117
+
1118
+ let savedDslFile: string | null = null;
1119
+ if (extractedDsl) {
1120
+ const dslExtractorForSave = new DslExtractor(specProvider);
1121
+ savedDslFile = await dslExtractorForSave.saveDsl(extractedDsl, specFile);
1122
+ console.log(chalk.green(` DSL saved: ${path.relative(repoAbsPath, savedDslFile)}`));
1123
+ }
1124
+
1125
+ // Code Generation
1126
+ console.log(chalk.blue(` [${repoName}] Running code generation (mode: ${codegenMode})...`));
1127
+ try {
1128
+ const codegen = new CodeGenerator(codegenProvider, codegenMode);
1129
+ await codegen.generateCode(specFile, workingDir, context, {
1130
+ auto: true,
1131
+ dslFilePath: savedDslFile ?? undefined,
1132
+ repoType: detectedRepoType,
1133
+ });
1134
+ console.log(chalk.green(` Code generation complete.`));
1135
+ } catch (err) {
1136
+ console.log(chalk.yellow(` Code generation failed: ${(err as Error).message}`));
1137
+ }
1138
+
1139
+ // Test Generation
1140
+ if (!cliOpts.skipTests && extractedDsl) {
1141
+ console.log(chalk.blue(` [${repoName}] Generating test skeletons...`));
1142
+ try {
1143
+ const testGen = new TestGenerator(codegenProvider);
1144
+ const testFiles = await testGen.generate(extractedDsl, workingDir);
1145
+ console.log(chalk.green(` ${testFiles.length} test file(s) generated.`));
1146
+ } catch (err) {
1147
+ console.log(chalk.yellow(` Test generation failed: ${(err as Error).message}`));
1148
+ }
1149
+ }
1150
+
1151
+ // Error Feedback
1152
+ if (!cliOpts.skipErrorFeedback) {
1153
+ try {
1154
+ await runErrorFeedback(codegenProvider, workingDir, extractedDsl, { maxCycles: 1 });
1155
+ } catch (err) {
1156
+ console.log(chalk.yellow(` Error feedback failed: ${(err as Error).message}`));
1157
+ }
1158
+ }
1159
+
1160
+ // Code Review
1161
+ if (!cliOpts.skipReview) {
1162
+ console.log(chalk.blue(` [${repoName}] Running code review...`));
1163
+ try {
1164
+ const reviewer = new CodeReviewer(specProvider);
1165
+ const reviewResult = await reviewer.reviewCode(finalSpec);
1166
+ await accumulateReviewKnowledge(specProvider, repoAbsPath, reviewResult);
1167
+ console.log(chalk.green(` Code review complete.`));
1168
+ } catch (err) {
1169
+ console.log(chalk.yellow(` Code review failed: ${(err as Error).message}`));
1170
+ }
1171
+ }
1172
+
1173
+ return { dsl: extractedDsl, specFile };
1174
+ }
1175
+
1176
+ /**
1177
+ * Multi-repo pipeline: decompose → order repos → run each repo in order → bridge contracts.
1178
+ */
1179
+ async function runMultiRepoPipeline(
1180
+ idea: string,
1181
+ workspace: WorkspaceConfig,
1182
+ opts: Record<string, unknown>,
1183
+ currentDir: string,
1184
+ config: AiSpecConfig
1185
+ ): Promise<void> {
1186
+ // ── Resolve providers ──────────────────────────────────────────────────────
1187
+ const specProviderName = (opts.provider as string) || config.provider || "gemini";
1188
+ const specModelName = (opts.model as string) || config.model || DEFAULT_MODELS[specProviderName];
1189
+ const specApiKey = await resolveApiKey(specProviderName, opts.key as string | undefined);
1190
+ const specProvider = createProvider(specProviderName, specApiKey, specModelName);
1191
+
1192
+ const codegenMode: CodeGenMode = ((opts.codegen as string) as CodeGenMode) || config.codegen || "claude-code";
1193
+ const codegenProviderName = (opts.codegenProvider as string) || config.codegenProvider || specProviderName;
1194
+ const codegenModelName = (opts.codegenModel as string) || config.codegenModel || DEFAULT_MODELS[codegenProviderName];
1195
+ const codegenApiKey =
1196
+ codegenProviderName === specProviderName
1197
+ ? specApiKey
1198
+ : await resolveApiKey(codegenProviderName, opts.codegenKey as string | undefined);
1199
+ const codegenProvider =
1200
+ codegenProviderName === specProviderName && codegenApiKey === specApiKey
1201
+ ? specProvider
1202
+ : createProvider(codegenProviderName, codegenApiKey, codegenModelName);
1203
+
1204
+ printBanner({
1205
+ specProvider: specProviderName,
1206
+ specModel: specModelName,
1207
+ codegenMode,
1208
+ codegenProvider: codegenProviderName,
1209
+ codegenModel: codegenModelName,
1210
+ });
1211
+
1212
+ const workspaceLoader = new WorkspaceLoader(currentDir);
1213
+
1214
+ // ── Step 1: Load per-repo contexts ─────────────────────────────────────────
1215
+ console.log(chalk.blue("\n[W1] Loading per-repo contexts..."));
1216
+ const contexts = new Map<string, import("../core/context-loader").ProjectContext>();
1217
+ const frontendContexts = new Map<string, import("../core/frontend-context-loader").FrontendContext>();
1218
+
1219
+ for (const repo of workspace.repos) {
1220
+ const repoAbsPath = workspaceLoader.resolveAbsPath(repo);
1221
+ try {
1222
+ const loader = new ContextLoader(repoAbsPath);
1223
+ const ctx = await loader.loadProjectContext();
1224
+ contexts.set(repo.name, ctx);
1225
+
1226
+ // Load frontend context for frontend/mobile repos
1227
+ if (repo.role === "frontend" || repo.role === "mobile") {
1228
+ const fctx = await loadFrontendContext(repoAbsPath);
1229
+ frontendContexts.set(repo.name, fctx);
1230
+ console.log(chalk.gray(` ${repo.name}: ${fctx.framework} / ${fctx.httpClient} / hooks:${fctx.hookFiles.length} stores:${fctx.storeFiles.length}`));
1231
+ } else {
1232
+ console.log(chalk.gray(` ${repo.name}: ${ctx.techStack.join(", ") || "unknown"} (${ctx.dependencies.length} deps)`));
1233
+ }
1234
+ } catch (err) {
1235
+ console.log(chalk.yellow(` ${repo.name}: context load failed — ${(err as Error).message}`));
1236
+ }
1237
+ }
1238
+
1239
+ // ── Step 2: Decompose requirement ─────────────────────────────────────────
1240
+ console.log(chalk.blue("\n[W2] Decomposing requirement across repos..."));
1241
+ const decomposer = new RequirementDecomposer(specProvider);
1242
+ let decomposition: DecompositionResult;
1243
+
1244
+ try {
1245
+ decomposition = await decomposer.decompose(idea, workspace, contexts, frontendContexts);
1246
+ console.log(chalk.green(` Summary: ${decomposition.summary}`));
1247
+ console.log(chalk.gray(` Repos affected: ${decomposition.repos.map((r) => r.repoName).join(", ")}`));
1248
+ if (decomposition.coordinationNotes) {
1249
+ console.log(chalk.gray(` Coordination: ${decomposition.coordinationNotes}`));
1250
+ }
1251
+ } catch (err) {
1252
+ console.error(chalk.red(` Decomposition failed: ${(err as Error).message}`));
1253
+ console.log(chalk.yellow(" Falling back to running all repos independently."));
1254
+ // Fallback: create basic requirements for all repos
1255
+ decomposition = {
1256
+ originalRequirement: idea,
1257
+ summary: idea,
1258
+ coordinationNotes: "",
1259
+ repos: workspace.repos.map((repo) => ({
1260
+ repoName: repo.name,
1261
+ role: repo.role,
1262
+ specIdea: idea,
1263
+ isContractProvider: repo.role === "backend",
1264
+ dependsOnRepos: repo.role !== "backend" ? workspace.repos.filter((r) => r.role === "backend").map((r) => r.name) : [],
1265
+ uxDecisions: null,
1266
+ })),
1267
+ };
1268
+ }
1269
+
1270
+ // ── Step 3: Show decomposition preview + confirmation ─────────────────────
1271
+ if (!opts.auto) {
1272
+ console.log(chalk.cyan("\n[W3] Decomposition Preview:"));
1273
+ console.log(chalk.cyan("─".repeat(52)));
1274
+ for (const r of decomposition.repos) {
1275
+ console.log(chalk.bold(` ${r.repoName} (${r.role})`));
1276
+ console.log(chalk.gray(` ${r.specIdea.slice(0, 150)}${r.specIdea.length > 150 ? "..." : ""}`));
1277
+ if (r.uxDecisions) {
1278
+ const ux = r.uxDecisions;
1279
+ const uxSummary = [
1280
+ ux.throttleMs ? `throttle ${ux.throttleMs}ms` : "",
1281
+ ux.debounceMs ? `debounce ${ux.debounceMs}ms` : "",
1282
+ ux.optimisticUpdate ? "optimistic-update" : "",
1283
+ ux.errorRollback ? "rollback" : "",
1284
+ ]
1285
+ .filter(Boolean)
1286
+ .join(", ");
1287
+ if (uxSummary) console.log(chalk.cyan(` UX: ${uxSummary}`));
1288
+ }
1289
+ if (r.dependsOnRepos.length > 0) {
1290
+ console.log(chalk.gray(` Depends on: ${r.dependsOnRepos.join(", ")}`));
1291
+ }
1292
+ }
1293
+ console.log(chalk.cyan("─".repeat(52)));
1294
+
1295
+ const gate = await select({
1296
+ message: "Proceed with multi-repo pipeline?",
1297
+ choices: [
1298
+ { name: "Proceed — run all repos", value: "proceed" },
1299
+ { name: "Abort", value: "abort" },
1300
+ ],
1301
+ });
1302
+
1303
+ if (gate === "abort") {
1304
+ console.log(chalk.yellow(" Aborted."));
1305
+ process.exit(0);
1306
+ }
1307
+ }
1308
+
1309
+ // ── Step 4: Sort repos by dependency order ─────────────────────────────────
1310
+ const sortedRepoRequirements = RequirementDecomposer.sortByDependency(decomposition.repos);
1311
+
1312
+ // Contract accumulator: repoName → extracted DSL
1313
+ const contractDsls = new Map<string, SpecDSL>();
1314
+
1315
+ // ── Step 5: Run each repo's pipeline ──────────────────────────────────────
1316
+ console.log(chalk.blue(`\n[W4] Running pipeline for ${sortedRepoRequirements.length} repo(s)...`));
1317
+
1318
+ const results: Array<{
1319
+ repoName: string;
1320
+ status: "success" | "failed" | "skipped";
1321
+ specFile: string | null;
1322
+ dsl: SpecDSL | null;
1323
+ }> = [];
1324
+
1325
+ for (const repoReq of sortedRepoRequirements) {
1326
+ // Find repo config in workspace
1327
+ const repoConfig = workspace.repos.find((r) => r.name === repoReq.repoName);
1328
+ if (!repoConfig) {
1329
+ console.log(chalk.yellow(` Skipping ${repoReq.repoName} — not found in workspace config.`));
1330
+ results.push({ repoName: repoReq.repoName, status: "skipped", specFile: null, dsl: null });
1331
+ continue;
1332
+ }
1333
+
1334
+ const repoAbsPath = workspaceLoader.resolveAbsPath(repoConfig);
1335
+
1336
+ console.log(chalk.bold.blue(`\n ── ${repoReq.repoName} (${repoReq.role}) ──────────────────────`));
1337
+
1338
+ // Build contract context from upstream repos
1339
+ let contractContextSection: string | undefined;
1340
+ if (repoReq.dependsOnRepos.length > 0) {
1341
+ const contractParts: string[] = [];
1342
+ for (const depName of repoReq.dependsOnRepos) {
1343
+ const depDsl = contractDsls.get(depName);
1344
+ if (depDsl) {
1345
+ console.log(chalk.gray(` Using API contract from: ${depName}`));
1346
+ const contract = buildFrontendApiContract(depDsl);
1347
+ contractParts.push(buildContractContextSection(contract));
1348
+ }
1349
+ }
1350
+ if (contractParts.length > 0) {
1351
+ contractContextSection = contractParts.join("\n\n");
1352
+ }
1353
+ }
1354
+
1355
+ // For frontend repos, also load frontend context and inject UX decisions
1356
+ let specIdea = repoReq.specIdea;
1357
+ if (
1358
+ (repoReq.role === "frontend" || repoReq.role === "mobile") &&
1359
+ repoReq.uxDecisions
1360
+ ) {
1361
+ const frontendCtx = await loadFrontendContext(repoAbsPath);
1362
+ const frontendCtxSection = buildFrontendContextSection(frontendCtx);
1363
+
1364
+ specIdea = buildFrontendSpecPrompt({
1365
+ specIdea: repoReq.specIdea,
1366
+ apiContractSection: contractContextSection,
1367
+ uxDecisions: repoReq.uxDecisions,
1368
+ frontendContext: frontendCtx,
1369
+ });
1370
+
1371
+ // contractContextSection is already injected via buildFrontendSpecPrompt
1372
+ contractContextSection = undefined;
1373
+
1374
+ console.log(chalk.gray(` Frontend context: ${frontendCtx.framework} / ${frontendCtx.httpClient} / ${frontendCtx.uiLibrary}`));
1375
+ }
1376
+
1377
+ try {
1378
+ const { dsl, specFile } = await runSingleRepoPipelineInWorkspace({
1379
+ idea: specIdea,
1380
+ specProvider,
1381
+ specProviderName,
1382
+ specModelName,
1383
+ codegenProvider,
1384
+ codegenMode,
1385
+ repoAbsPath,
1386
+ repoName: repoReq.repoName,
1387
+ cliOpts: opts,
1388
+ contractContextSection,
1389
+ });
1390
+
1391
+ // Store DSL for downstream repos if this is a contract provider
1392
+ if (repoReq.isContractProvider && dsl) {
1393
+ contractDsls.set(repoReq.repoName, dsl);
1394
+ console.log(chalk.green(` Contract stored for downstream repos.`));
1395
+ }
1396
+
1397
+ results.push({ repoName: repoReq.repoName, status: "success", specFile, dsl });
1398
+ console.log(chalk.green(` ✔ ${repoReq.repoName} complete`));
1399
+ } catch (err) {
1400
+ console.error(chalk.red(` ✘ ${repoReq.repoName} failed: ${(err as Error).message}`));
1401
+ results.push({ repoName: repoReq.repoName, status: "failed", specFile: null, dsl: null });
1402
+ // Continue — don't abort other repos
1403
+ }
1404
+ }
1405
+
1406
+ // ── Done ──────────────────────────────────────────────────────────────────
1407
+ console.log(chalk.bold.green("\n✔ Multi-repo pipeline complete!"));
1408
+ console.log(chalk.gray(` Workspace: ${workspace.name}`));
1409
+ console.log(chalk.gray(` Requirement: ${idea}`));
1410
+ console.log();
1411
+ for (const r of results) {
1412
+ const icon = r.status === "success" ? chalk.green("✔") : r.status === "failed" ? chalk.red("✘") : chalk.gray("−");
1413
+ const specInfo = r.specFile ? chalk.gray(` → ${r.specFile}`) : "";
1414
+ console.log(` ${icon} ${r.repoName} (${r.status})${specInfo}`);
1415
+ }
1416
+ }
1417
+
1418
+ // ═══════════════════════════════════════════════════════════════════════════════
1419
+ // Command: workspace
1420
+ // ═══════════════════════════════════════════════════════════════════════════════
1421
+
1422
+ const workspaceCmd = program
1423
+ .command("workspace")
1424
+ .description("Manage multi-repo workspace configuration");
1425
+
1426
+ // workspace init
1427
+ workspaceCmd
1428
+ .command("init")
1429
+ .description(`Interactive workspace setup — creates ${WORKSPACE_CONFIG_FILE}`)
1430
+ .action(async () => {
1431
+ const currentDir = process.cwd();
1432
+ const configPath = path.join(currentDir, WORKSPACE_CONFIG_FILE);
1433
+
1434
+ if (await fs.pathExists(configPath)) {
1435
+ const overwrite = await confirm({
1436
+ message: `${WORKSPACE_CONFIG_FILE} already exists. Overwrite?`,
1437
+ default: false,
1438
+ });
1439
+ if (!overwrite) {
1440
+ console.log(chalk.gray(" Cancelled."));
1441
+ return;
1442
+ }
1443
+ }
1444
+
1445
+ console.log(chalk.blue("\n─── Workspace Setup ────────────────────────────"));
1446
+
1447
+ const workspaceName = await input({
1448
+ message: "Workspace name:",
1449
+ validate: (v) => v.trim().length > 0 || "Name cannot be empty",
1450
+ });
1451
+
1452
+ const repos: RepoConfig[] = [];
1453
+
1454
+ // ── Auto-scan option ──────────────────────────────────────────────────────
1455
+ const useAutoScan = await confirm({
1456
+ message: "Auto-scan sibling directories for repos?",
1457
+ default: true,
1458
+ });
1459
+
1460
+ if (useAutoScan) {
1461
+ const workspaceLoader = new WorkspaceLoader(currentDir);
1462
+ const detected = await workspaceLoader.autoDetect();
1463
+
1464
+ if (detected.length === 0) {
1465
+ console.log(chalk.yellow(" No recognizable repos found in sibling directories."));
1466
+ } else {
1467
+ console.log(chalk.cyan("\n Detected repos:"));
1468
+ for (const r of detected) {
1469
+ console.log(chalk.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
1470
+ }
1471
+
1472
+ const keepAll = await confirm({
1473
+ message: `Include all ${detected.length} detected repo(s)?`,
1474
+ default: true,
1475
+ });
1476
+
1477
+ if (keepAll) {
1478
+ repos.push(...detected);
1479
+ } else {
1480
+ // Let user cherry-pick
1481
+ for (const r of detected) {
1482
+ const keep = await confirm({
1483
+ message: `Include "${r.name}" (${r.role}, ${r.type})?`,
1484
+ default: true,
1485
+ });
1486
+ if (keep) repos.push(r);
1487
+ }
1488
+ }
1489
+ console.log(chalk.green(` ✔ ${repos.length} repo(s) added from auto-scan.`));
1490
+ }
1491
+ }
1492
+
1493
+ // ── Manual add (always offered after auto-scan) ───────────────────────────
1494
+ const repoTypeChoices = [
1495
+ { name: "node-express (Node.js/Express backend)", value: "node-express" },
1496
+ { name: "node-koa (Node.js/Koa backend)", value: "node-koa" },
1497
+ { name: "go (Go backend)", value: "go" },
1498
+ { name: "python (Python backend)", value: "python" },
1499
+ { name: "java (Java/Spring backend)", value: "java" },
1500
+ { name: "rust (Rust backend)", value: "rust" },
1501
+ { name: "php (PHP/Lumen/Laravel backend)", value: "php" },
1502
+ { name: "react (React frontend)", value: "react" },
1503
+ { name: "next (Next.js)", value: "next" },
1504
+ { name: "vue (Vue frontend)", value: "vue" },
1505
+ { name: "react-native (React Native mobile)", value: "react-native" },
1506
+ { name: "unknown", value: "unknown" },
1507
+ ];
1508
+
1509
+ let addMore = await confirm({
1510
+ message: repos.length > 0 ? "Manually add more repos?" : "Add repos manually?",
1511
+ default: repos.length === 0,
1512
+ });
1513
+
1514
+ while (addMore) {
1515
+ console.log(chalk.cyan(`\n Adding repo #${repos.length + 1}`));
1516
+
1517
+ const repoName = await input({
1518
+ message: "Repo name (e.g. api, web, app):",
1519
+ validate: (v) => {
1520
+ if (!v.trim()) return "Name cannot be empty";
1521
+ if (repos.some((r) => r.name === v.trim())) return "Name already used";
1522
+ return true;
1523
+ },
1524
+ });
1525
+
1526
+ const repoPath = await input({
1527
+ message: `Relative path to "${repoName}" from here (default: ./${repoName}):`,
1528
+ default: `./${repoName}`,
1529
+ });
1530
+
1531
+ // Try to auto-detect type
1532
+ const absPath = path.resolve(currentDir, repoPath);
1533
+ let detectedType = "unknown";
1534
+ let detectedRole = "shared";
1535
+
1536
+ if (await fs.pathExists(absPath)) {
1537
+ const { type, role } = await detectRepoType(absPath);
1538
+ detectedType = type;
1539
+ detectedRole = role;
1540
+ console.log(chalk.gray(` Auto-detected: type=${type}, role=${role}`));
1541
+ } else {
1542
+ console.log(chalk.yellow(` Path "${absPath}" not found — type/role will be manual.`));
1543
+ }
1544
+
1545
+ const repoType = await select({
1546
+ message: `Repo type for "${repoName}":`,
1547
+ choices: repoTypeChoices,
1548
+ default: detectedType,
1549
+ });
1550
+
1551
+ const repoRole = await select({
1552
+ message: `Repo role for "${repoName}":`,
1553
+ choices: [
1554
+ { name: "backend", value: "backend" },
1555
+ { name: "frontend", value: "frontend" },
1556
+ { name: "mobile", value: "mobile" },
1557
+ { name: "shared", value: "shared" },
1558
+ ],
1559
+ default: detectedRole,
1560
+ });
1561
+
1562
+ repos.push({
1563
+ name: repoName,
1564
+ path: repoPath,
1565
+ type: repoType as RepoConfig["type"],
1566
+ role: repoRole as RepoConfig["role"],
1567
+ });
1568
+
1569
+ console.log(chalk.green(` ✔ Added: ${repoName} (${repoRole}, ${repoType})`));
1570
+
1571
+ addMore = await confirm({
1572
+ message: "Add another repo?",
1573
+ default: false,
1574
+ });
1575
+ }
1576
+
1577
+ const workspaceConfig: WorkspaceConfig = { name: workspaceName, repos };
1578
+
1579
+ // Show summary
1580
+ console.log(chalk.cyan("\n Workspace summary:"));
1581
+ console.log(chalk.gray(` Name: ${workspaceName}`));
1582
+ for (const r of repos) {
1583
+ console.log(chalk.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
1584
+ }
1585
+
1586
+ const ok = await confirm({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
1587
+ if (!ok) {
1588
+ console.log(chalk.gray(" Cancelled."));
1589
+ return;
1590
+ }
1591
+
1592
+ const loader = new WorkspaceLoader(currentDir);
1593
+ const saved = await loader.save(workspaceConfig);
1594
+ console.log(chalk.green(`\n ✔ Workspace saved: ${saved}`));
1595
+ console.log(chalk.gray(` Run \`ai-spec create "your feature"\` — workspace mode will activate automatically.`));
1596
+ });
1597
+
1598
+ // workspace status
1599
+ workspaceCmd
1600
+ .command("status")
1601
+ .description("Show current workspace configuration")
1602
+ .action(async () => {
1603
+ const currentDir = process.cwd();
1604
+ const loader = new WorkspaceLoader(currentDir);
1605
+ const config = await loader.load();
1606
+
1607
+ if (!config) {
1608
+ console.log(chalk.yellow(`No ${WORKSPACE_CONFIG_FILE} found in ${currentDir}`));
1609
+ console.log(chalk.gray(" Run `ai-spec workspace init` to create one."));
1610
+ return;
1611
+ }
1612
+
1613
+ console.log(chalk.bold(`\nWorkspace: ${config.name}`));
1614
+ console.log(chalk.gray(` Config: ${path.join(currentDir, WORKSPACE_CONFIG_FILE)}`));
1615
+ console.log(chalk.gray(` Repos (${config.repos.length}):\n`));
1616
+
1617
+ for (const repo of config.repos) {
1618
+ const absPath = loader.resolveAbsPath(repo);
1619
+ const exists = await fs.pathExists(absPath);
1620
+ const status = exists ? chalk.green("found") : chalk.red("not found");
1621
+
1622
+ console.log(
1623
+ ` ${chalk.bold(repo.name.padEnd(12))} ${repo.role.padEnd(10)} ${repo.type.padEnd(16)} ${status}`
1624
+ );
1625
+ console.log(chalk.gray(` path: ${absPath}`));
1626
+ if (repo.constitution) {
1627
+ console.log(chalk.green(` constitution: found`));
1628
+ }
1629
+ }
1630
+ });
1631
+
1632
+ // ═══════════════════════════════════════════════════════════════════════════════
1633
+ // Command: update — Incremental spec + code update pipeline
1634
+ // ═══════════════════════════════════════════════════════════════════════════════
1635
+
1636
+ program
1637
+ .command("update")
1638
+ .description("Update an existing spec with a change request, re-extract DSL, and identify affected files")
1639
+ .argument("[change]", "Change description (prompted if omitted)")
1640
+ .option("--provider <name>", `AI provider (${SUPPORTED_PROVIDERS.join("|")})`, undefined)
1641
+ .option("--model <name>", "Model name")
1642
+ .option("-k, --key <apiKey>", "API key")
1643
+ .option("--spec <path>", "Path to the existing spec file (auto-detected if omitted)")
1644
+ .option("--codegen", "Regenerate affected files automatically after updating spec")
1645
+ .option("--codegen-provider <name>", "Provider for code generation")
1646
+ .option("--codegen-model <name>", "Model for code generation")
1647
+ .option("--codegen-key <key>", "API key for code generation")
1648
+ .option("--skip-affected", "Skip identifying affected files")
1649
+ .action(async (change: string | undefined, opts) => {
1650
+ const currentDir = process.cwd();
1651
+ const config = await loadConfig(currentDir);
1652
+
1653
+ // ── Resolve change ────────────────────────────────────────────────────────
1654
+ if (!change) {
1655
+ change = await input({
1656
+ message: "Describe the change you want to make:",
1657
+ validate: (v) => v.trim().length > 0 || "Change description cannot be empty",
1658
+ });
1659
+ }
1660
+
1661
+ // ── Resolve provider ──────────────────────────────────────────────────────
1662
+ const providerName = opts.provider || config.provider || "gemini";
1663
+ const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
1664
+ const apiKey = await resolveApiKey(providerName, opts.key);
1665
+ const provider = createProvider(providerName, apiKey, modelName);
1666
+
1667
+ console.log(chalk.blue("\n─── ai-spec update ─────────────────────────────"));
1668
+ console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
1669
+
1670
+ // ── Find existing spec ────────────────────────────────────────────────────
1671
+ let specPath: string | null = opts.spec ?? null;
1672
+ if (!specPath) {
1673
+ const specsDir = path.join(currentDir, "specs");
1674
+ const latest = await SpecUpdater.findLatestSpec(specsDir);
1675
+ if (!latest) {
1676
+ console.error(chalk.red(" No spec files found in specs/. Run `ai-spec create` first or use --spec <path>."));
1677
+ process.exit(1);
1678
+ }
1679
+ specPath = latest.filePath;
1680
+ console.log(chalk.gray(` Using spec: ${path.relative(currentDir, specPath)} (v${latest.version})`));
1681
+ }
1682
+
1683
+ // ── Load context ──────────────────────────────────────────────────────────
1684
+ console.log(chalk.gray(" Loading project context..."));
1685
+ const loader = new ContextLoader(currentDir);
1686
+ const context = await loader.loadProjectContext();
1687
+
1688
+ // ── Detect repo type ──────────────────────────────────────────────────────
1689
+ const { detectRepoType: _detectRepoType } = await import("../core/workspace-loader");
1690
+ const { type: repoType } = await _detectRepoType(currentDir);
1691
+
1692
+ // ── Run update ────────────────────────────────────────────────────────────
1693
+ const updater = new SpecUpdater(provider);
1694
+ let result;
1695
+ try {
1696
+ result = await updater.update(change!, specPath, currentDir, context, {
1697
+ skipAffectedFiles: opts.skipAffected,
1698
+ repoType,
1699
+ });
1700
+ } catch (err) {
1701
+ console.error(chalk.red(` Update failed: ${(err as Error).message}`));
1702
+ process.exit(1);
1703
+ }
1704
+
1705
+ console.log(chalk.green(`\n ✔ Spec updated → v${result.newVersion}: ${path.relative(currentDir, result.newSpecPath)}`));
1706
+ if (result.newDslPath) {
1707
+ console.log(chalk.green(` ✔ DSL updated: ${path.relative(currentDir, result.newDslPath)}`));
1708
+ }
1709
+
1710
+ // ── Show affected files ───────────────────────────────────────────────────
1711
+ if (result.affectedFiles.length > 0) {
1712
+ console.log(chalk.cyan("\n Affected files:"));
1713
+ for (const f of result.affectedFiles) {
1714
+ const icon = f.action === "create" ? chalk.green("+") : chalk.yellow("~");
1715
+ console.log(` ${icon} ${f.file}: ${chalk.gray(f.description)}`);
1716
+ }
1717
+ }
1718
+
1719
+ // ── Optional: regenerate affected files ───────────────────────────────────
1720
+ if (opts.codegen && result.affectedFiles.length > 0) {
1721
+ const codegenProviderName = opts.codegenProvider || config.codegenProvider || providerName;
1722
+ const codegenModelName = opts.codegenModel || config.codegenModel || DEFAULT_MODELS[codegenProviderName];
1723
+ const codegenApiKey = opts.codegenKey ?? (codegenProviderName === providerName ? apiKey : await resolveApiKey(codegenProviderName, opts.codegenKey));
1724
+ const codegenProvider = createProvider(codegenProviderName, codegenApiKey, codegenModelName);
1725
+
1726
+ console.log(chalk.blue("\n Regenerating affected files..."));
1727
+ const codeGenerator = new CodeGenerator(codegenProvider, "api");
1728
+
1729
+ const specContent = await fs.readFile(result.newSpecPath, "utf-8");
1730
+ const constitutionSection = context.constitution
1731
+ ? `\n=== Project Constitution (MUST follow) ===\n${context.constitution.slice(0, 2000)}\n`
1732
+ : "";
1733
+ const dslSection = result.updatedDsl
1734
+ ? `\n=== DSL Context ===\n${JSON.stringify(result.updatedDsl, null, 2).slice(0, 3000)}\n`
1735
+ : "";
1736
+
1737
+ for (const affected of result.affectedFiles) {
1738
+ const fullPath = path.join(currentDir, affected.file);
1739
+ let existing = "";
1740
+ try { existing = await fs.readFile(fullPath, "utf-8"); } catch { /* new file */ }
1741
+
1742
+ const codePrompt = `Apply this change to the file.
1743
+
1744
+ Change: ${change}
1745
+ File: ${affected.file}
1746
+ Purpose: ${affected.description}
1747
+
1748
+ === Feature Spec (updated) ===
1749
+ ${specContent}
1750
+ ${constitutionSection}${dslSection}
1751
+ === ${existing ? "Current File (return the FULL updated content)" : "New File"} ===
1752
+ ${existing || "Create from scratch."}`;
1753
+
1754
+ process.stdout.write(` ${existing ? chalk.yellow("~") : chalk.green("+")} ${affected.file}... `);
1755
+ try {
1756
+ const { getCodeGenSystemPrompt: _getPrompt } = await import("../prompts/codegen.prompt");
1757
+ const raw = await codegenProvider.generate(codePrompt, _getPrompt(repoType));
1758
+ const content = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
1759
+ await fs.ensureDir(path.dirname(fullPath));
1760
+ await fs.writeFile(fullPath, content, "utf-8");
1761
+ console.log(chalk.green("✔"));
1762
+ } catch (err) {
1763
+ console.log(chalk.red(`✘ ${(err as Error).message}`));
1764
+ }
1765
+ }
1766
+ }
1767
+
1768
+ // ── Hints ─────────────────────────────────────────────────────────────────
1769
+ if (!opts.codegen && result.affectedFiles.length > 0) {
1770
+ console.log(chalk.blue("\n Next steps:"));
1771
+ console.log(chalk.gray(` • Re-run with --codegen to regenerate affected files automatically`));
1772
+ console.log(chalk.gray(` • Or update files manually based on the affected files list above`));
1773
+ console.log(chalk.gray(` • Run \`ai-spec mock\` to refresh the mock server with the new DSL`));
1774
+ }
1775
+ });
1776
+
1777
+ // ═══════════════════════════════════════════════════════════════════════════════
1778
+ // Command: export — Export DSL to OpenAPI / other formats
1779
+ // ═══════════════════════════════════════════════════════════════════════════════
1780
+
1781
+ program
1782
+ .command("export")
1783
+ .description("Export the latest DSL to OpenAPI 3.1.0 (YAML or JSON)")
1784
+ .option("--openapi", "Export as OpenAPI 3.1.0 (default behaviour)")
1785
+ .option("--format <fmt>", "Output format: yaml | json (default: yaml)", "yaml")
1786
+ .option("--output <path>", "Output file path (default: openapi.yaml)")
1787
+ .option("--server <url>", "API server URL in the OpenAPI document (default: http://localhost:3000)")
1788
+ .option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
1789
+ .action(async (opts) => {
1790
+ const currentDir = process.cwd();
1791
+
1792
+ // ── Find DSL ──────────────────────────────────────────────────────────────
1793
+ let dslPath: string | null = opts.dsl ?? null;
1794
+ if (!dslPath) {
1795
+ dslPath = await findLatestDslFile(currentDir);
1796
+ if (!dslPath) {
1797
+ console.error(chalk.red(" No .dsl.json file found. Run `ai-spec create` first or use --dsl <path>."));
1798
+ process.exit(1);
1799
+ }
1800
+ console.log(chalk.gray(` Using DSL: ${path.relative(currentDir, dslPath)}`));
1801
+ }
1802
+
1803
+ let dsl: SpecDSL;
1804
+ try {
1805
+ dsl = await fs.readJson(dslPath);
1806
+ } catch (err) {
1807
+ console.error(chalk.red(` Failed to read DSL: ${(err as Error).message}`));
1808
+ process.exit(1);
1809
+ }
1810
+
1811
+ // ── Export ────────────────────────────────────────────────────────────────
1812
+ console.log(chalk.blue("\n─── ai-spec export ─────────────────────────────"));
1813
+
1814
+ const format = (opts.format === "json" ? "json" : "yaml") as "yaml" | "json";
1815
+ const serverUrl = opts.server || "http://localhost:3000";
1816
+
1817
+ try {
1818
+ const outputPath = await exportOpenApi(dsl, currentDir, {
1819
+ format,
1820
+ serverUrl,
1821
+ outputPath: opts.output,
1822
+ });
1823
+ const rel = path.relative(currentDir, outputPath);
1824
+ console.log(chalk.green(` ✔ OpenAPI ${format.toUpperCase()} exported: ${rel}`));
1825
+ console.log(chalk.gray(` Feature : ${dsl.feature.title}`));
1826
+ console.log(chalk.gray(` Endpoints: ${dsl.endpoints.length}`));
1827
+ console.log(chalk.gray(` Models : ${dsl.models.length}`));
1828
+ console.log(chalk.gray(` Server : ${serverUrl}`));
1829
+ console.log(chalk.blue("\n Next steps:"));
1830
+ console.log(chalk.gray(` • Import ${rel} into Postman / Insomnia / Swagger UI`));
1831
+ console.log(chalk.gray(` • Use openapi-generator to generate client SDKs`));
1832
+ } catch (err) {
1833
+ console.error(chalk.red(` Export failed: ${(err as Error).message}`));
1834
+ process.exit(1);
1835
+ }
1836
+ });
1837
+
1838
+ // ═══════════════════════════════════════════════════════════════════════════════
1839
+ // Command: mock — Generate mock server, proxy config, and MSW handlers from DSL
1840
+ // ═══════════════════════════════════════════════════════════════════════════════
1841
+
1842
+ program
1843
+ .command("mock")
1844
+ .description("Generate a standalone mock server + proxy config from the latest DSL")
1845
+ .option("--port <n>", "Mock server port (default: 3001)", "3001")
1846
+ .option("--msw", "Also generate MSW (Mock Service Worker) handlers at src/mocks/")
1847
+ .option("--proxy", "Also generate frontend proxy config snippet")
1848
+ .option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
1849
+ .option(
1850
+ "--workspace",
1851
+ "Generate mock assets for all backend repos in the workspace"
1852
+ )
1853
+ .action(async (opts) => {
1854
+ const currentDir = process.cwd();
1855
+ const port = parseInt(opts.port, 10) || 3001;
1856
+
1857
+ console.log(chalk.blue("\n─── ai-spec mock ───────────────────────────────"));
1858
+
1859
+ // ── Workspace mode ────────────────────────────────────────────────────────
1860
+ if (opts.workspace) {
1861
+ const workspaceLoader = new WorkspaceLoader(currentDir);
1862
+ const workspaceConfig = await workspaceLoader.load();
1863
+ if (!workspaceConfig) {
1864
+ console.error(chalk.red(` No ${WORKSPACE_CONFIG_FILE} found. Run \`ai-spec workspace init\` first.`));
1865
+ process.exit(1);
1866
+ }
1867
+
1868
+ const backendRepos = workspaceConfig.repos.filter((r) => r.role === "backend");
1869
+ if (backendRepos.length === 0) {
1870
+ console.log(chalk.yellow(" No backend repos found in workspace."));
1871
+ return;
1872
+ }
1873
+
1874
+ for (const repo of backendRepos) {
1875
+ const repoAbsPath = workspaceLoader.resolveAbsPath(repo);
1876
+ console.log(chalk.cyan(`\n Repo: ${repo.name} (${repoAbsPath})`));
1877
+
1878
+ const dslFile = await findLatestDslFile(repoAbsPath);
1879
+ if (!dslFile) {
1880
+ console.log(chalk.yellow(` No DSL file found — skipping.`));
1881
+ continue;
1882
+ }
1883
+
1884
+ const dsl: SpecDSL = await fs.readJson(dslFile);
1885
+ const result = await generateMockAssets(dsl, repoAbsPath, {
1886
+ port,
1887
+ msw: opts.msw,
1888
+ proxy: opts.proxy,
1889
+ });
1890
+
1891
+ for (const f of result.files) {
1892
+ console.log(chalk.green(` ✔ ${f.path}`));
1893
+ console.log(chalk.gray(` ${f.description}`));
1894
+ }
1895
+ }
1896
+ return;
1897
+ }
1898
+
1899
+ // ── Single-repo mode ──────────────────────────────────────────────────────
1900
+ let dslPath: string | null = opts.dsl ?? null;
1901
+
1902
+ if (!dslPath) {
1903
+ dslPath = await findLatestDslFile(currentDir);
1904
+ if (!dslPath) {
1905
+ console.error(
1906
+ chalk.red(
1907
+ " No .dsl.json file found in .ai-spec/. Run `ai-spec create` first or use --dsl <path>."
1908
+ )
1909
+ );
1910
+ process.exit(1);
1911
+ }
1912
+ console.log(chalk.gray(` Using DSL: ${path.relative(currentDir, dslPath)}`));
1913
+ }
1914
+
1915
+ let dsl: SpecDSL;
1916
+ try {
1917
+ dsl = await fs.readJson(dslPath);
1918
+ } catch (err) {
1919
+ console.error(chalk.red(` Failed to read DSL file: ${(err as Error).message}`));
1920
+ process.exit(1);
1921
+ }
1922
+
1923
+ const result = await generateMockAssets(dsl, currentDir, {
1924
+ port,
1925
+ msw: opts.msw,
1926
+ proxy: opts.proxy,
1927
+ });
1928
+
1929
+ console.log(chalk.green(`\n ✔ Mock assets generated (${result.files.length} file(s)):`));
1930
+ for (const f of result.files) {
1931
+ console.log(chalk.green(` ${f.path}`));
1932
+ console.log(chalk.gray(` ${f.description}`));
1933
+ }
1934
+
1935
+ console.log(chalk.blue("\n─── Quick start ────────────────────────────────"));
1936
+ console.log(chalk.white(` 1. Install express (if not already):`));
1937
+ console.log(chalk.gray(` npm install --save-dev express`));
1938
+ console.log(chalk.white(` 2. Start mock server:`));
1939
+ console.log(chalk.gray(` node mock/server.js`));
1940
+ console.log(chalk.white(` 3. Configure your frontend to proxy API calls to:`));
1941
+ console.log(chalk.gray(` http://localhost:${port}`));
1942
+ if (opts.proxy) {
1943
+ console.log(chalk.gray(` (See the generated proxy config file for framework-specific instructions)`));
1944
+ }
1945
+ if (opts.msw) {
1946
+ console.log(chalk.white(` 4. MSW: import and start the worker in your app entry:`));
1947
+ console.log(chalk.gray(` import { worker } from './mocks/browser';`));
1948
+ console.log(chalk.gray(` if (process.env.NODE_ENV === 'development') worker.start();`));
1949
+ }
1950
+ });
1951
+
1952
+ // Show welcome screen when invoked with no arguments
1953
+ if (process.argv.length <= 2) {
1954
+ (async () => {
1955
+ const currentDir = process.cwd();
1956
+ const config = await loadConfig(currentDir);
1957
+ await printWelcome(currentDir, config);
1958
+ })();
1959
+ } else {
1960
+ program.parse();
1961
+ }