ai-spec-dev 0.31.0 → 0.35.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 (70) hide show
  1. package/.claude/commands/add-lesson.md +34 -0
  2. package/.claude/commands/check-layers.md +65 -0
  3. package/.claude/commands/installed-deps.md +35 -0
  4. package/.claude/commands/recall-lessons.md +40 -0
  5. package/.claude/commands/scan-singletons.md +45 -0
  6. package/.claude/commands/verify-imports.md +48 -0
  7. package/.claude/settings.local.json +15 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +460 -0
  10. package/cli/commands/config.ts +93 -0
  11. package/cli/commands/create.ts +1233 -0
  12. package/cli/commands/dashboard.ts +62 -0
  13. package/cli/commands/export.ts +66 -0
  14. package/cli/commands/init.ts +190 -0
  15. package/cli/commands/learn.ts +30 -0
  16. package/cli/commands/logs.ts +106 -0
  17. package/cli/commands/mock.ts +175 -0
  18. package/cli/commands/model.ts +156 -0
  19. package/cli/commands/restore.ts +22 -0
  20. package/cli/commands/review.ts +63 -0
  21. package/cli/commands/scan.ts +99 -0
  22. package/cli/commands/trend.ts +36 -0
  23. package/cli/commands/types.ts +69 -0
  24. package/cli/commands/update.ts +178 -0
  25. package/cli/commands/vcr.ts +70 -0
  26. package/cli/commands/workspace.ts +219 -0
  27. package/cli/index.ts +34 -2240
  28. package/cli/utils.ts +83 -0
  29. package/core/combined-generator.ts +13 -3
  30. package/core/dashboard-generator.ts +340 -0
  31. package/core/design-dialogue.ts +124 -0
  32. package/core/dsl-feedback.ts +285 -0
  33. package/core/error-feedback.ts +46 -2
  34. package/core/project-index.ts +301 -0
  35. package/core/reviewer.ts +84 -6
  36. package/core/run-logger.ts +109 -3
  37. package/core/run-trend.ts +261 -0
  38. package/core/self-evaluator.ts +139 -7
  39. package/core/spec-generator.ts +14 -8
  40. package/core/task-generator.ts +17 -0
  41. package/core/types-generator.ts +219 -0
  42. package/core/vcr.ts +210 -0
  43. package/dist/cli/index.js +6692 -4512
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/cli/index.mjs +6692 -4512
  46. package/dist/cli/index.mjs.map +1 -1
  47. package/dist/index.d.mts +19 -5
  48. package/dist/index.d.ts +19 -5
  49. package/dist/index.js +420 -224
  50. package/dist/index.js.map +1 -1
  51. package/dist/index.mjs +418 -224
  52. package/dist/index.mjs.map +1 -1
  53. package/docs-assets/purpose/architecture-overview.svg +64 -0
  54. package/docs-assets/purpose/create-pipeline.svg +113 -0
  55. package/docs-assets/purpose/task-layering.svg +74 -0
  56. package/package.json +6 -3
  57. package/prompts/codegen.prompt.ts +97 -9
  58. package/prompts/design.prompt.ts +59 -0
  59. package/prompts/spec.prompt.ts +8 -1
  60. package/prompts/tasks.prompt.ts +27 -2
  61. package/purpose.md +600 -174
  62. package/tests/dsl-extractor.test.ts +264 -0
  63. package/tests/dsl-feedback.test.ts +266 -0
  64. package/tests/dsl-validator.test.ts +283 -0
  65. package/tests/error-feedback.test.ts +292 -0
  66. package/tests/provider-utils.test.ts +173 -0
  67. package/tests/run-trend.test.ts +186 -0
  68. package/tests/self-evaluator.test.ts +339 -0
  69. package/tests/spec-assessor.test.ts +142 -0
  70. package/tests/task-generator.test.ts +230 -0
@@ -0,0 +1,1233 @@
1
+ import { Command } from "commander";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import { input, select } from "@inquirer/prompts";
6
+ import {
7
+ AIProvider,
8
+ createProvider,
9
+ DEFAULT_MODELS,
10
+ SUPPORTED_PROVIDERS,
11
+ } from "../../core/spec-generator";
12
+ import { ContextLoader, isFrontendDeps } from "../../core/context-loader";
13
+ import { SpecRefiner } from "../../core/spec-refiner";
14
+ import { CodeGenerator, CodeGenMode } from "../../core/code-generator";
15
+ import { CodeReviewer, extractComplianceScore, extractMissingCount } from "../../core/reviewer";
16
+ import { GitWorktreeManager } from "../../git/worktree";
17
+ import { ConstitutionGenerator } from "../../core/constitution-generator";
18
+ import { TaskGenerator, printTasks } from "../../core/task-generator";
19
+ import { generateSpecWithTasks } from "../../core/combined-generator";
20
+ import {
21
+ slugify,
22
+ findLatestVersion,
23
+ nextVersionPath,
24
+ computeDiff,
25
+ printDiff,
26
+ printDiffSummary,
27
+ } from "../../core/spec-versioning";
28
+ import { DslExtractor } from "../../core/dsl-extractor";
29
+ import { TestGenerator } from "../../core/test-generator";
30
+ import { runErrorFeedback } from "../../core/error-feedback";
31
+ import { assessSpec, printSpecAssessment } from "../../core/spec-assessor";
32
+ import { accumulateReviewKnowledge } from "../../core/knowledge-memory";
33
+ import {
34
+ WorkspaceLoader,
35
+ WorkspaceConfig,
36
+ WORKSPACE_CONFIG_FILE,
37
+ detectRepoType,
38
+ } from "../../core/workspace-loader";
39
+ import { SpecDSL } from "../../core/dsl-types";
40
+ import {
41
+ generateMockAssets,
42
+ applyMockProxy,
43
+ startMockServerBackground,
44
+ saveMockServerPid,
45
+ } from "../../core/mock-server-generator";
46
+ import { generateRunId, RunLogger, setActiveLogger } from "../../core/run-logger";
47
+ import { RunSnapshot, setActiveSnapshot } from "../../core/run-snapshot";
48
+ import { computePromptHash } from "../../core/prompt-hasher";
49
+ import { runSelfEval, printSelfEval } from "../../core/self-evaluator";
50
+ import {
51
+ assessDslRichness,
52
+ buildDslGapRefinementPrompt,
53
+ extractStructuralFindings,
54
+ buildStructuralAmendmentPrompt,
55
+ printDslGaps,
56
+ printStructuralFindings,
57
+ } from "../../core/dsl-feedback";
58
+ import { RequirementDecomposer, DecompositionResult } from "../../core/requirement-decomposer";
59
+ import { buildFrontendApiContract, buildContractContextSection } from "../../core/contract-bridge";
60
+ import { loadFrontendContext, buildFrontendContextSection } from "../../core/frontend-context-loader";
61
+ import { buildFrontendSpecPrompt } from "../../prompts/frontend-spec.prompt";
62
+ import { AiSpecConfig, loadConfig, resolveApiKey } from "../utils";
63
+ import {
64
+ VcrRecordingProvider,
65
+ VcrReplayProvider,
66
+ loadVcrRecording,
67
+ } from "../../core/vcr";
68
+ import { DesignDialogue } from "../../core/design-dialogue";
69
+
70
+ // ─── Banner ───────────────────────────────────────────────────────────────────
71
+
72
+ function printBanner(opts: {
73
+ specProvider: string;
74
+ specModel: string;
75
+ codegenMode: string;
76
+ codegenProvider: string;
77
+ codegenModel: string;
78
+ }) {
79
+ console.log(chalk.blue("\n" + "─".repeat(52)));
80
+ console.log(chalk.bold(" ai-spec — AI-driven Development Orchestrator"));
81
+ console.log(chalk.blue("─".repeat(52)));
82
+ console.log(chalk.gray(` Spec : ${opts.specProvider} / ${opts.specModel}`));
83
+ console.log(
84
+ chalk.gray(
85
+ ` Codegen : ${opts.codegenMode} (${opts.codegenProvider} / ${opts.codegenModel})`
86
+ )
87
+ );
88
+ console.log(chalk.blue("─".repeat(52) + "\n"));
89
+ }
90
+
91
+ // ─── Multi-repo types ─────────────────────────────────────────────────────────
92
+
93
+ type MultiRepoResult = {
94
+ repoName: string;
95
+ status: "success" | "failed" | "skipped";
96
+ specFile: string | null;
97
+ dsl: SpecDSL | null;
98
+ repoAbsPath: string;
99
+ role: string;
100
+ };
101
+
102
+ // ─── Single-repo workspace pipeline ──────────────────────────────────────────
103
+
104
+ async function runSingleRepoPipelineInWorkspace(opts: {
105
+ idea: string;
106
+ specProvider: ReturnType<typeof createProvider>;
107
+ specProviderName: string;
108
+ specModelName: string;
109
+ codegenProvider: ReturnType<typeof createProvider>;
110
+ codegenMode: CodeGenMode;
111
+ repoAbsPath: string;
112
+ repoName: string;
113
+ cliOpts: Record<string, unknown>;
114
+ contractContextSection?: string;
115
+ }): Promise<{ dsl: SpecDSL | null; specFile: string | null }> {
116
+ const {
117
+ idea,
118
+ specProvider,
119
+ specProviderName,
120
+ specModelName,
121
+ codegenProvider,
122
+ codegenMode,
123
+ repoAbsPath,
124
+ repoName,
125
+ cliOpts,
126
+ contractContextSection,
127
+ } = opts;
128
+
129
+ console.log(chalk.blue(`\n [${repoName}] Loading project context...`));
130
+ const loader = new ContextLoader(repoAbsPath);
131
+ let context = await loader.loadProjectContext();
132
+
133
+ const { type: detectedRepoType } = await detectRepoType(repoAbsPath);
134
+
135
+ console.log(chalk.gray(` Tech stack: ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
136
+ console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
137
+ if (context.constitution && context.constitution.length > 6000) {
138
+ console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
139
+ }
140
+
141
+ if (!context.constitution) {
142
+ console.log(chalk.yellow(` Constitution: not found — auto-generating...`));
143
+ try {
144
+ const constitutionGen = new ConstitutionGenerator(specProvider);
145
+ const constitutionContent = await constitutionGen.generate(repoAbsPath);
146
+ await constitutionGen.saveConstitution(repoAbsPath, constitutionContent);
147
+ context.constitution = constitutionContent;
148
+ console.log(chalk.green(` Constitution: generated`));
149
+ } catch (err) {
150
+ console.log(chalk.yellow(` Constitution: auto-generation failed (${(err as Error).message}), continuing.`));
151
+ }
152
+ } else {
153
+ console.log(chalk.green(` Constitution: found`));
154
+ }
155
+
156
+ let fullIdea = idea;
157
+ if (contractContextSection) {
158
+ fullIdea = `${idea}\n\n${contractContextSection}`;
159
+ }
160
+
161
+ console.log(chalk.blue(` [${repoName}] Generating spec...`));
162
+ let finalSpec: string;
163
+ try {
164
+ const result = await generateSpecWithTasks(specProvider, fullIdea, context);
165
+ finalSpec = result.spec;
166
+ console.log(chalk.green(` Spec generated.`));
167
+ } catch (err) {
168
+ console.error(chalk.red(` Spec generation failed: ${(err as Error).message}`));
169
+ return { dsl: null, specFile: null };
170
+ }
171
+
172
+ // DSL Extraction
173
+ let extractedDsl: SpecDSL | null = null;
174
+ if (!cliOpts.skipDsl) {
175
+ console.log(chalk.blue(` [${repoName}] Extracting DSL...`));
176
+ try {
177
+ const dslExtractor = new DslExtractor(specProvider);
178
+ const repoIsFrontend = isFrontendDeps(context.dependencies);
179
+ extractedDsl = await dslExtractor.extract(finalSpec, { auto: true, isFrontend: repoIsFrontend });
180
+ if (extractedDsl) {
181
+ console.log(chalk.green(` DSL extracted.`));
182
+ }
183
+ } catch (err) {
184
+ console.log(chalk.yellow(` DSL extraction failed: ${(err as Error).message}`));
185
+ }
186
+ }
187
+
188
+ // Git Worktree — auto-skip for frontend repos
189
+ const isFrontendRepo = isFrontendDeps(context.dependencies ?? []);
190
+ const skipWorktreeForRepo = cliOpts.worktree
191
+ ? false
192
+ : cliOpts.skipWorktree || isFrontendRepo;
193
+
194
+ let workingDir = repoAbsPath;
195
+ if (!skipWorktreeForRepo) {
196
+ console.log(chalk.blue(` [${repoName}] Setting up git worktree...`));
197
+ try {
198
+ const worktreeManager = new GitWorktreeManager(repoAbsPath);
199
+ const worktreePath = await worktreeManager.createWorktree(idea);
200
+ if (worktreePath) workingDir = worktreePath;
201
+ } catch (err) {
202
+ console.log(chalk.yellow(` Worktree setup failed: ${(err as Error).message}. Using main branch.`));
203
+ }
204
+ } else {
205
+ console.log(chalk.gray(` [${repoName}] Skipping worktree${isFrontendRepo ? " (frontend repo)" : ""}.`));
206
+ }
207
+
208
+ // Save Spec
209
+ const specsDir = path.join(workingDir, "specs");
210
+ await fs.ensureDir(specsDir);
211
+ const featureSlug = slugify(idea);
212
+ const { filePath: specFile } = await nextVersionPath(specsDir, featureSlug);
213
+ await fs.writeFile(specFile, finalSpec, "utf-8");
214
+ console.log(chalk.green(` Spec saved: ${path.relative(repoAbsPath, specFile)}`));
215
+
216
+ let savedDslFile: string | null = null;
217
+ if (extractedDsl) {
218
+ const dslExtractorForSave = new DslExtractor(specProvider);
219
+ savedDslFile = await dslExtractorForSave.saveDsl(extractedDsl, specFile);
220
+ console.log(chalk.green(` DSL saved: ${path.relative(repoAbsPath, savedDslFile)}`));
221
+ }
222
+
223
+ // Code Generation
224
+ console.log(chalk.blue(` [${repoName}] Running code generation (mode: ${codegenMode})...`));
225
+ try {
226
+ const codegen = new CodeGenerator(codegenProvider, codegenMode);
227
+ await codegen.generateCode(specFile, workingDir, context, {
228
+ auto: true,
229
+ dslFilePath: savedDslFile ?? undefined,
230
+ repoType: detectedRepoType,
231
+ });
232
+ console.log(chalk.green(` Code generation complete.`));
233
+ } catch (err) {
234
+ console.log(chalk.yellow(` Code generation failed: ${(err as Error).message}`));
235
+ }
236
+
237
+ // Test Generation
238
+ if (!cliOpts.skipTests && extractedDsl) {
239
+ console.log(chalk.blue(` [${repoName}] Generating test skeletons...`));
240
+ try {
241
+ const testGen = new TestGenerator(codegenProvider);
242
+ const testFiles = await testGen.generate(extractedDsl, workingDir);
243
+ console.log(chalk.green(` ${testFiles.length} test file(s) generated.`));
244
+ } catch (err) {
245
+ console.log(chalk.yellow(` Test generation failed: ${(err as Error).message}`));
246
+ }
247
+ }
248
+
249
+ // Error Feedback
250
+ if (!cliOpts.skipErrorFeedback) {
251
+ try {
252
+ await runErrorFeedback(codegenProvider, workingDir, extractedDsl, { maxCycles: 1 });
253
+ } catch (err) {
254
+ console.log(chalk.yellow(` Error feedback failed: ${(err as Error).message}`));
255
+ }
256
+ }
257
+
258
+ // Code Review
259
+ if (!cliOpts.skipReview) {
260
+ console.log(chalk.blue(` [${repoName}] Running code review...`));
261
+ try {
262
+ const reviewer = new CodeReviewer(specProvider);
263
+ const originalDir = process.cwd();
264
+ let reviewResult: string;
265
+ try {
266
+ process.chdir(workingDir);
267
+ reviewResult = await reviewer.reviewCode(finalSpec);
268
+ } finally {
269
+ process.chdir(originalDir);
270
+ }
271
+ await accumulateReviewKnowledge(specProvider, repoAbsPath, reviewResult);
272
+ console.log(chalk.green(` Code review complete.`));
273
+ } catch (err) {
274
+ console.log(chalk.yellow(` Code review failed: ${(err as Error).message}`));
275
+ }
276
+ }
277
+
278
+ return { dsl: extractedDsl, specFile };
279
+ }
280
+
281
+ // ─── Multi-repo pipeline ──────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Multi-repo pipeline: decompose → order repos → run each repo in order → bridge contracts.
285
+ */
286
+ async function runMultiRepoPipeline(
287
+ idea: string,
288
+ workspace: WorkspaceConfig,
289
+ opts: Record<string, unknown>,
290
+ currentDir: string,
291
+ config: AiSpecConfig
292
+ ): Promise<MultiRepoResult[]> {
293
+ // ── Resolve providers ──────────────────────────────────────────────────────
294
+ const specProviderName = (opts.provider as string) || config.provider || "gemini";
295
+ const specModelName = (opts.model as string) || config.model || DEFAULT_MODELS[specProviderName];
296
+ const specApiKey = await resolveApiKey(specProviderName, opts.key as string | undefined);
297
+ const specProvider = createProvider(specProviderName, specApiKey, specModelName);
298
+
299
+ const codegenMode: CodeGenMode = ((opts.codegen as string) as CodeGenMode) || config.codegen || "claude-code";
300
+ const codegenProviderName = (opts.codegenProvider as string) || config.codegenProvider || specProviderName;
301
+ const codegenModelName = (opts.codegenModel as string) || config.codegenModel || DEFAULT_MODELS[codegenProviderName];
302
+ const codegenApiKey =
303
+ codegenProviderName === specProviderName
304
+ ? specApiKey
305
+ : await resolveApiKey(codegenProviderName, opts.codegenKey as string | undefined);
306
+ const codegenProvider =
307
+ codegenProviderName === specProviderName && codegenApiKey === specApiKey
308
+ ? specProvider
309
+ : createProvider(codegenProviderName, codegenApiKey, codegenModelName);
310
+
311
+ printBanner({
312
+ specProvider: specProviderName,
313
+ specModel: specModelName,
314
+ codegenMode,
315
+ codegenProvider: codegenProviderName,
316
+ codegenModel: codegenModelName,
317
+ });
318
+
319
+ const workspaceLoader = new WorkspaceLoader(currentDir);
320
+
321
+ // ── Step 1: Load per-repo contexts ─────────────────────────────────────────
322
+ console.log(chalk.blue("\n[W1] Loading per-repo contexts..."));
323
+ const contexts = new Map<string, import("../../core/context-loader").ProjectContext>();
324
+ const frontendContexts = new Map<string, import("../../core/frontend-context-loader").FrontendContext>();
325
+
326
+ for (const repo of workspace.repos) {
327
+ const repoAbsPath = workspaceLoader.resolveAbsPath(repo);
328
+ try {
329
+ const loader = new ContextLoader(repoAbsPath);
330
+ const ctx = await loader.loadProjectContext();
331
+ contexts.set(repo.name, ctx);
332
+
333
+ if (repo.role === "frontend" || repo.role === "mobile") {
334
+ const fctx = await loadFrontendContext(repoAbsPath);
335
+ frontendContexts.set(repo.name, fctx);
336
+ console.log(chalk.gray(` ${repo.name}: ${fctx.framework} / ${fctx.httpClient} / hooks:${fctx.hookFiles.length} stores:${fctx.storeFiles.length}`));
337
+ } else {
338
+ console.log(chalk.gray(` ${repo.name}: ${ctx.techStack.join(", ") || "unknown"} (${ctx.dependencies.length} deps)`));
339
+ }
340
+ } catch (err) {
341
+ console.log(chalk.yellow(` ${repo.name}: context load failed — ${(err as Error).message}`));
342
+ }
343
+ }
344
+
345
+ // ── Step 2: Decompose requirement ─────────────────────────────────────────
346
+ console.log(chalk.blue("\n[W2] Decomposing requirement across repos..."));
347
+ const decomposer = new RequirementDecomposer(specProvider);
348
+ let decomposition: DecompositionResult;
349
+
350
+ try {
351
+ decomposition = await decomposer.decompose(idea, workspace, contexts, frontendContexts);
352
+ console.log(chalk.green(` Summary: ${decomposition.summary}`));
353
+ console.log(chalk.gray(` Repos affected: ${decomposition.repos.map((r) => r.repoName).join(", ")}`));
354
+ if (decomposition.coordinationNotes) {
355
+ console.log(chalk.gray(` Coordination: ${decomposition.coordinationNotes}`));
356
+ }
357
+ } catch (err) {
358
+ console.error(chalk.red(` Decomposition failed: ${(err as Error).message}`));
359
+ console.log(chalk.yellow(" Falling back to running all repos independently."));
360
+ decomposition = {
361
+ originalRequirement: idea,
362
+ summary: idea,
363
+ coordinationNotes: "",
364
+ repos: workspace.repos.map((repo) => ({
365
+ repoName: repo.name,
366
+ role: repo.role,
367
+ specIdea: idea,
368
+ isContractProvider: repo.role === "backend",
369
+ dependsOnRepos: repo.role !== "backend" ? workspace.repos.filter((r) => r.role === "backend").map((r) => r.name) : [],
370
+ uxDecisions: null,
371
+ })),
372
+ };
373
+ }
374
+
375
+ // ── Step 3: Show decomposition preview + confirmation ─────────────────────
376
+ if (!opts.auto) {
377
+ console.log(chalk.cyan("\n[W3] Decomposition Preview:"));
378
+ console.log(chalk.cyan("─".repeat(52)));
379
+ for (const r of decomposition.repos) {
380
+ console.log(chalk.bold(` ${r.repoName} (${r.role})`));
381
+ console.log(chalk.gray(` ${r.specIdea.slice(0, 150)}${r.specIdea.length > 150 ? "..." : ""}`));
382
+ if (r.uxDecisions) {
383
+ const ux = r.uxDecisions;
384
+ const uxSummary = [
385
+ ux.throttleMs ? `throttle ${ux.throttleMs}ms` : "",
386
+ ux.debounceMs ? `debounce ${ux.debounceMs}ms` : "",
387
+ ux.optimisticUpdate ? "optimistic-update" : "",
388
+ ux.errorRollback ? "rollback" : "",
389
+ ]
390
+ .filter(Boolean)
391
+ .join(", ");
392
+ if (uxSummary) console.log(chalk.cyan(` UX: ${uxSummary}`));
393
+ }
394
+ if (r.dependsOnRepos.length > 0) {
395
+ console.log(chalk.gray(` Depends on: ${r.dependsOnRepos.join(", ")}`));
396
+ }
397
+ }
398
+ console.log(chalk.cyan("─".repeat(52)));
399
+
400
+ const gate = await select({
401
+ message: "Proceed with multi-repo pipeline?",
402
+ choices: [
403
+ { name: "Proceed — run all repos", value: "proceed" },
404
+ { name: "Abort", value: "abort" },
405
+ ],
406
+ });
407
+
408
+ if (gate === "abort") {
409
+ console.log(chalk.yellow(" Aborted."));
410
+ process.exit(0);
411
+ }
412
+ }
413
+
414
+ // ── Step 4: Sort repos by dependency order ─────────────────────────────────
415
+ const sortedRepoRequirements = RequirementDecomposer.sortByDependency(decomposition.repos);
416
+
417
+ const contractDsls = new Map<string, SpecDSL>();
418
+
419
+ // ── Step 5: Run each repo's pipeline ──────────────────────────────────────
420
+ console.log(chalk.blue(`\n[W4] Running pipeline for ${sortedRepoRequirements.length} repo(s)...`));
421
+
422
+ const results: MultiRepoResult[] = [];
423
+
424
+ for (const repoReq of sortedRepoRequirements) {
425
+ const repoConfig = workspace.repos.find((r) => r.name === repoReq.repoName);
426
+ if (!repoConfig) {
427
+ console.log(chalk.yellow(` Skipping ${repoReq.repoName} — not found in workspace config.`));
428
+ results.push({ repoName: repoReq.repoName, status: "skipped", specFile: null, dsl: null, repoAbsPath: "", role: repoReq.role });
429
+ continue;
430
+ }
431
+
432
+ const repoAbsPath = workspaceLoader.resolveAbsPath(repoConfig);
433
+
434
+ console.log(chalk.bold.blue(`\n ── ${repoReq.repoName} (${repoReq.role}) ──────────────────────`));
435
+
436
+ let contractContextSection: string | undefined;
437
+ if (repoReq.dependsOnRepos.length > 0) {
438
+ const contractParts: string[] = [];
439
+ for (const depName of repoReq.dependsOnRepos) {
440
+ const depDsl = contractDsls.get(depName);
441
+ if (depDsl) {
442
+ console.log(chalk.gray(` Using API contract from: ${depName}`));
443
+ const contract = buildFrontendApiContract(depDsl);
444
+ contractParts.push(buildContractContextSection(contract));
445
+ }
446
+ }
447
+ if (contractParts.length > 0) {
448
+ contractContextSection = contractParts.join("\n\n");
449
+ }
450
+ }
451
+
452
+ let specIdea = repoReq.specIdea;
453
+ if (
454
+ (repoReq.role === "frontend" || repoReq.role === "mobile") &&
455
+ repoReq.uxDecisions
456
+ ) {
457
+ const frontendCtx = await loadFrontendContext(repoAbsPath);
458
+
459
+ specIdea = buildFrontendSpecPrompt({
460
+ specIdea: repoReq.specIdea,
461
+ apiContractSection: contractContextSection,
462
+ uxDecisions: repoReq.uxDecisions,
463
+ frontendContext: frontendCtx,
464
+ });
465
+
466
+ contractContextSection = undefined;
467
+
468
+ console.log(chalk.gray(` Frontend context: ${frontendCtx.framework} / ${frontendCtx.httpClient} / ${frontendCtx.uiLibrary}`));
469
+ }
470
+
471
+ try {
472
+ const { dsl, specFile } = await runSingleRepoPipelineInWorkspace({
473
+ idea: specIdea,
474
+ specProvider,
475
+ specProviderName,
476
+ specModelName,
477
+ codegenProvider,
478
+ codegenMode,
479
+ repoAbsPath,
480
+ repoName: repoReq.repoName,
481
+ cliOpts: opts,
482
+ contractContextSection,
483
+ });
484
+
485
+ if (repoReq.isContractProvider && dsl) {
486
+ contractDsls.set(repoReq.repoName, dsl);
487
+ console.log(chalk.green(` Contract stored for downstream repos.`));
488
+ }
489
+
490
+ results.push({ repoName: repoReq.repoName, status: "success", specFile, dsl, repoAbsPath, role: repoReq.role });
491
+ console.log(chalk.green(` ✔ ${repoReq.repoName} complete`));
492
+ } catch (err) {
493
+ console.error(chalk.red(` ✘ ${repoReq.repoName} failed: ${(err as Error).message}`));
494
+ results.push({ repoName: repoReq.repoName, status: "failed", specFile: null, dsl: null, repoAbsPath, role: repoReq.role });
495
+ }
496
+ }
497
+
498
+ // ── Done ──────────────────────────────────────────────────────────────────
499
+ console.log(chalk.bold.green("\n✔ Multi-repo pipeline complete!"));
500
+ console.log(chalk.gray(` Workspace: ${workspace.name}`));
501
+ console.log(chalk.gray(` Requirement: ${idea}`));
502
+ console.log();
503
+ for (const r of results) {
504
+ const icon = r.status === "success" ? chalk.green("✔") : r.status === "failed" ? chalk.red("✘") : chalk.gray("−");
505
+ const specInfo = r.specFile ? chalk.gray(` → ${r.specFile}`) : "";
506
+ console.log(` ${icon} ${r.repoName} (${r.status})${specInfo}`);
507
+ }
508
+
509
+ return results;
510
+ }
511
+
512
+ // ─── Command: create ──────────────────────────────────────────────────────────
513
+
514
+ export function registerCreate(program: Command): void {
515
+ program
516
+ .command("create")
517
+ .description("Generate a feature spec and kick off code generation")
518
+ .argument("[idea]", "Feature idea in natural language (prompted if omitted)")
519
+ .option(
520
+ "--provider <name>",
521
+ `AI provider for spec generation (${SUPPORTED_PROVIDERS.join("|")})`,
522
+ undefined
523
+ )
524
+ .option("--model <name>", "Model name for spec generation")
525
+ .option("-k, --key <apiKey>", "API key (overrides env var)")
526
+ .option(
527
+ "--codegen <mode>",
528
+ "Code generation mode: claude-code | api | plan",
529
+ undefined
530
+ )
531
+ .option(
532
+ "--codegen-provider <name>",
533
+ "AI provider for code generation (defaults to --provider)"
534
+ )
535
+ .option("--codegen-model <name>", "Model for code generation")
536
+ .option("--codegen-key <key>", "API key for code generation (if different)")
537
+ .option("--skip-worktree", "Skip git worktree creation (auto-set for frontend projects)")
538
+ .option("--worktree", "Force git worktree creation even for frontend projects")
539
+ .option("--skip-review", "Skip automated code review")
540
+ .option("--skip-tasks", "Skip task generation (just generate spec)")
541
+ .option("--auto", "Run claude non-interactively via -p flag (saves tokens)")
542
+ .option("--fast", "Skip interactive spec refinement, proceed immediately with initial spec")
543
+ .option("--resume", "Resume an interrupted run — skip tasks already marked as done")
544
+ .option("--skip-dsl", "Skip DSL extraction step")
545
+ .option("--skip-tests", "Skip test skeleton generation")
546
+ .option("--skip-error-feedback", "Skip error feedback loop (test/lint auto-fix)")
547
+ .option("--tdd", "TDD mode: generate failing tests first, then generate implementation to pass them")
548
+ .option("--skip-assessment", "Skip spec quality pre-assessment before the Approval Gate")
549
+ .option("--force", "Bypass the spec quality score gate even if score is below minSpecScore")
550
+ .option("--serve", "After workspace pipeline completes, auto-start mock server + patch frontend proxy")
551
+ .option("--vcr-record", "Record all AI responses to .ai-spec-vcr/ for offline replay")
552
+ .option("--vcr-replay <runId>", "Replay AI responses from a previous recording (zero API calls)")
553
+ .action(async (idea: string | undefined, opts) => {
554
+ const currentDir = process.cwd();
555
+ const config = await loadConfig(currentDir);
556
+
557
+ // ── Resolve idea ────────────────────────────────────────────────────────
558
+ if (!idea) {
559
+ idea = await input({
560
+ message: "What feature do you want to build?",
561
+ validate: (v) => v.trim().length > 0 || "Please describe your feature",
562
+ });
563
+ }
564
+
565
+ // ── Detect workspace mode ───────────────────────────────────────────────
566
+ const workspaceLoader = new WorkspaceLoader(currentDir);
567
+ const workspaceConfig = await workspaceLoader.load();
568
+
569
+ if (workspaceConfig) {
570
+ console.log(chalk.cyan(`\n[Workspace] Detected workspace: ${workspaceConfig.name}`));
571
+ console.log(chalk.gray(` Repos: ${workspaceConfig.repos.map((r) => r.name).join(", ")}`));
572
+ const pipelineResults = await runMultiRepoPipeline(idea!, workspaceConfig, opts, currentDir, config);
573
+
574
+ // ── Auto-serve: start mock server + patch frontend proxy ──────────────
575
+ if (opts.serve) {
576
+ console.log(chalk.blue("\n─── Auto-serve: starting mock server ───────────"));
577
+ const backendResult = pipelineResults.find((r) => r.role === "backend" && r.status === "success" && r.dsl);
578
+ const frontendResult = pipelineResults.find((r) => (r.role === "frontend" || r.role === "mobile") && r.status === "success");
579
+
580
+ if (!backendResult) {
581
+ console.log(chalk.yellow(" No successful backend with DSL found — skipping auto-serve."));
582
+ } else {
583
+ const mockPort = 3001;
584
+ const mockResult = await generateMockAssets(backendResult.dsl!, backendResult.repoAbsPath, { port: mockPort });
585
+ const serverJsPath = path.join(backendResult.repoAbsPath, "mock", "server.js");
586
+ console.log(chalk.green(` ✔ Mock assets generated (${mockResult.files.length} file(s))`));
587
+
588
+ const pid = startMockServerBackground(serverJsPath, mockPort);
589
+ console.log(chalk.green(` ✔ Mock server started (PID ${pid}) → http://localhost:${mockPort}`));
590
+
591
+ if (frontendResult) {
592
+ const proxyResult = await applyMockProxy(frontendResult.repoAbsPath, mockPort, backendResult.dsl!.endpoints);
593
+ await saveMockServerPid(frontendResult.repoAbsPath, pid);
594
+ if (proxyResult.applied) {
595
+ console.log(chalk.green(` ✔ Frontend proxy patched (${proxyResult.framework})`));
596
+ console.log(chalk.bold.cyan(`\n Ready! Run your frontend dev server:`));
597
+ console.log(chalk.white(` cd ${frontendResult.repoAbsPath}`));
598
+ console.log(chalk.white(` ${proxyResult.devCommand}`));
599
+ console.log(chalk.gray(`\n When done, restore: ai-spec mock --restore --frontend ${frontendResult.repoAbsPath}`));
600
+ } else {
601
+ console.log(chalk.yellow(` ⚠ Auto-patch not available for ${proxyResult.framework}.`));
602
+ if (proxyResult.note) console.log(chalk.gray(` ${proxyResult.note}`));
603
+ console.log(chalk.gray(` Mock server: http://localhost:${mockPort}`));
604
+ }
605
+ } else {
606
+ console.log(chalk.gray(` No frontend repo found — mock server is running at http://localhost:${mockPort}`));
607
+ console.log(chalk.gray(` Configure your frontend proxy manually to point to http://localhost:${mockPort}`));
608
+ }
609
+ }
610
+ }
611
+
612
+ return;
613
+ }
614
+
615
+ // ── Resolve spec provider ───────────────────────────────────────────────
616
+ const specProviderName = opts.provider || config.provider || "gemini";
617
+ const specModelName =
618
+ opts.model || config.model || DEFAULT_MODELS[specProviderName];
619
+ const specApiKey = await resolveApiKey(specProviderName, opts.key);
620
+
621
+ // ── Resolve codegen ─────────────────────────────────────────────────────
622
+ const codegenMode: CodeGenMode =
623
+ (opts.codegen as CodeGenMode) || config.codegen || "claude-code";
624
+ const codegenProviderName =
625
+ opts.codegenProvider || config.codegenProvider || specProviderName;
626
+ const codegenModelName =
627
+ opts.codegenModel ||
628
+ config.codegenModel ||
629
+ DEFAULT_MODELS[codegenProviderName];
630
+ const codegenApiKey =
631
+ codegenProviderName === specProviderName
632
+ ? specApiKey
633
+ : await resolveApiKey(codegenProviderName, opts.codegenKey);
634
+
635
+ // ── VCR: replay mode — load recording and create replay providers ───────
636
+ let vcrReplayProvider: VcrReplayProvider | null = null;
637
+ if (opts.vcrReplay) {
638
+ const recording = await loadVcrRecording(currentDir, opts.vcrReplay);
639
+ if (!recording) {
640
+ console.error(chalk.red(`VCR recording not found: ${opts.vcrReplay}`));
641
+ console.error(chalk.gray(` Expected: .ai-spec-vcr/${opts.vcrReplay}.json`));
642
+ console.error(chalk.gray(` List available recordings: ai-spec vcr list`));
643
+ process.exit(1);
644
+ }
645
+ vcrReplayProvider = new VcrReplayProvider(recording);
646
+ console.log(chalk.cyan(`\n[VCR] Replay mode — ${recording.entryCount} recorded responses loaded`));
647
+ console.log(chalk.gray(` Recording: ${opts.vcrReplay} (${recording.recordedAt.slice(0, 10)})`));
648
+ console.log(chalk.gray(` No API calls will be made during this run.\n`));
649
+ }
650
+
651
+ // ── VCR: record mode — wrap providers ────────────────────────────────────
652
+ let specVcrRecorder: VcrRecordingProvider | null = null;
653
+ let codegenVcrRecorder: VcrRecordingProvider | null = null;
654
+
655
+ printBanner({
656
+ specProvider: vcrReplayProvider ? "vcr-replay" : specProviderName,
657
+ specModel: vcrReplayProvider ? opts.vcrReplay : specModelName,
658
+ codegenMode,
659
+ codegenProvider: vcrReplayProvider ? "vcr-replay" : codegenProviderName,
660
+ codegenModel: vcrReplayProvider ? opts.vcrReplay : codegenModelName,
661
+ });
662
+
663
+ // ── Run tracking ────────────────────────────────────────────────────────
664
+ const runId = generateRunId();
665
+ console.log(chalk.gray(` Run ID: ${runId}`));
666
+ const runSnapshot = new RunSnapshot(currentDir, runId);
667
+ setActiveSnapshot(runSnapshot);
668
+ const runLogger = new RunLogger(currentDir, runId, {
669
+ provider: specProviderName,
670
+ model: specModelName,
671
+ });
672
+ setActiveLogger(runLogger);
673
+
674
+ const promptHash = computePromptHash();
675
+ runLogger.setPromptHash(promptHash);
676
+
677
+ // ── Step 1: Context ─────────────────────────────────────────────────────
678
+ console.log(chalk.blue("[1/6] Loading project context..."));
679
+ runLogger.stageStart("context_load");
680
+ const loader = new ContextLoader(currentDir);
681
+ const context = await loader.loadProjectContext();
682
+ const { type: detectedRepoType } = await detectRepoType(currentDir);
683
+ runLogger.stageEnd("context_load", { techStack: context.techStack, repoType: detectedRepoType });
684
+ console.log(chalk.gray(` Tech stack : ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
685
+ console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
686
+ console.log(chalk.gray(` API files : ${context.apiStructure.length} files`));
687
+ if (context.schema) {
688
+ console.log(chalk.gray(` Prisma schema: found`));
689
+ }
690
+ if (context.constitution) {
691
+ console.log(chalk.green(` Constitution : found (.ai-spec-constitution.md)`));
692
+ if (context.constitution.length > 6000) {
693
+ console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
694
+ }
695
+ } else {
696
+ console.log(chalk.yellow(" Constitution : not found — auto-generating..."));
697
+ try {
698
+ const constitutionGen = new ConstitutionGenerator(
699
+ createProvider(specProviderName, specApiKey, specModelName)
700
+ );
701
+ const constitutionContent = await constitutionGen.generate(currentDir);
702
+ await constitutionGen.saveConstitution(currentDir, constitutionContent);
703
+ context.constitution = constitutionContent;
704
+ console.log(chalk.green(` Constitution : ✔ generated and saved (.ai-spec-constitution.md)`));
705
+ } catch (err) {
706
+ console.log(chalk.yellow(` Constitution : ⚠ auto-generation failed (${(err as Error).message}), continuing without it.`));
707
+ }
708
+ }
709
+
710
+ // ── Step 1.5: Design Options Dialogue (skip in --fast / --auto / --vcr-replay) ──
711
+ let architectureDecision: string | undefined;
712
+ if (!opts.fast && !opts.auto && !opts.vcrReplay) {
713
+ runLogger.stageStart("design_dialogue");
714
+ const dialogue = new DesignDialogue(
715
+ vcrReplayProvider ?? createProvider(specProviderName, specApiKey, specModelName)
716
+ );
717
+ const choice = await dialogue.run(idea!, {
718
+ techStack: context.techStack,
719
+ repoType: detectedRepoType,
720
+ constitution: context.constitution ?? undefined,
721
+ });
722
+ architectureDecision = choice.selectedApproach ?? undefined;
723
+ runLogger.stageEnd("design_dialogue", {
724
+ skipped: !choice.selectedApproach,
725
+ approach: choice.selectedApproach?.slice(0, 80),
726
+ });
727
+ }
728
+
729
+ // ── Step 2: Spec + Tasks Generation (single AI call) ───────────────────
730
+ console.log(chalk.blue(`\n[2/6] Generating spec with ${specProviderName}/${specModelName}...`));
731
+ let specProvider: AIProvider = vcrReplayProvider ?? createProvider(specProviderName, specApiKey, specModelName);
732
+ if (!vcrReplayProvider && opts.vcrRecord) {
733
+ specVcrRecorder = new VcrRecordingProvider(specProvider);
734
+ specProvider = specVcrRecorder;
735
+ console.log(chalk.cyan(` [VCR] Recording spec AI calls → .ai-spec-vcr/${runId}.json`));
736
+ }
737
+
738
+ let initialSpec: string;
739
+ let initialTasks: import("../../core/task-generator").SpecTask[] = [];
740
+
741
+ runLogger.stageStart("spec_gen", { provider: specProviderName, model: specModelName });
742
+ try {
743
+ if (opts.skipTasks) {
744
+ const { SpecGenerator } = await import("../../core/spec-generator");
745
+ const generator = new SpecGenerator(specProvider);
746
+ initialSpec = await generator.generateSpec(idea, context, architectureDecision);
747
+ console.log(chalk.green(" ✔ Spec generated."));
748
+ } else {
749
+ const result = await generateSpecWithTasks(specProvider, idea, context, architectureDecision);
750
+ initialSpec = result.spec;
751
+ initialTasks = result.tasks;
752
+ console.log(chalk.green(` ✔ Spec generated.`));
753
+ if (initialTasks.length > 0) {
754
+ console.log(chalk.green(` ✔ ${initialTasks.length} tasks generated (combined call).`));
755
+ } else {
756
+ console.log(chalk.yellow(" ⚠ Tasks not parsed from response — will retry separately after refinement."));
757
+ }
758
+ }
759
+ runLogger.stageEnd("spec_gen", { taskCount: initialTasks.length });
760
+ } catch (err) {
761
+ runLogger.stageFail("spec_gen", (err as Error).message);
762
+ console.error(chalk.red(" ✘ Spec generation failed:"), err);
763
+ process.exit(1);
764
+ }
765
+
766
+ // ── Step 3: Interactive Refinement ──────────────────────────────────────
767
+ let finalSpec: string;
768
+ if (opts.fast) {
769
+ console.log(chalk.gray("\n[3/6] Skipping refinement (--fast)."));
770
+ finalSpec = initialSpec;
771
+ } else {
772
+ console.log(chalk.blue("\n[3/6] Interactive spec refinement..."));
773
+ runLogger.stageStart("spec_refine");
774
+ const refiner = new SpecRefiner(specProvider);
775
+ finalSpec = await refiner.refineLoop(initialSpec);
776
+ runLogger.stageEnd("spec_refine");
777
+ }
778
+
779
+ const featureSlug = slugify(idea!);
780
+
781
+ // ── Step 3.4: Spec Quality Pre-Assessment ──────────────────────────────
782
+ const minScore = config.minSpecScore ?? 0;
783
+ const shouldRunAssessment = !opts.skipAssessment && (!opts.auto || minScore > 0);
784
+
785
+ if (shouldRunAssessment) {
786
+ if (!opts.auto) {
787
+ console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
788
+ }
789
+ runLogger.stageStart("spec_assess");
790
+ const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? undefined);
791
+ if (assessment) {
792
+ runLogger.stageEnd("spec_assess", { overallScore: assessment.overallScore });
793
+ if (!opts.auto) printSpecAssessment(assessment);
794
+
795
+ if (minScore > 0 && assessment.overallScore < minScore) {
796
+ if (opts.force) {
797
+ console.log(chalk.yellow(`\n ⚠ Score gate: ${assessment.overallScore}/10 < minimum ${minScore}/10 — bypassed with --force.`));
798
+ } else {
799
+ runLogger.stageFail("spec_assess", `Score gate: ${assessment.overallScore} < ${minScore}`);
800
+ console.log(chalk.red(`\n ✘ Spec quality gate failed: overallScore ${assessment.overallScore}/10 < minimum ${minScore}/10`));
801
+ if (!opts.auto) {
802
+ console.log(chalk.gray(` Address the issues above and re-run, or use --force to bypass.`));
803
+ } else {
804
+ console.log(chalk.gray(` Auto mode: gate enforced. Fix the spec or lower minSpecScore, or use --force to bypass.`));
805
+ }
806
+ console.log(chalk.gray(` Gate threshold set in .ai-spec.json → "minSpecScore": ${minScore}`));
807
+ process.exit(1);
808
+ }
809
+ }
810
+ } else {
811
+ runLogger.stageEnd("spec_assess", { skipped: true });
812
+ if (!opts.auto) {
813
+ console.log(chalk.gray(" (Assessment skipped — AI call failed or timed out)"));
814
+ }
815
+ }
816
+ }
817
+
818
+ // ── Step 3.5: Approval Gate ─────────────────────────────────────────────
819
+ if (!opts.auto) {
820
+ console.log(chalk.blue("\n[3.5/6] Approval Gate — review before code generation"));
821
+
822
+ const specLines = finalSpec.split("\n").length;
823
+ const specWords = finalSpec.split(/\s+/).length;
824
+ const taskCountHint = initialTasks.length > 0 ? ` Tasks generated : ${initialTasks.length}` : "";
825
+ console.log(chalk.gray(` Spec length : ${specLines} lines / ${specWords} words`));
826
+ if (taskCountHint) console.log(chalk.gray(taskCountHint));
827
+
828
+ // Estimate DSL scope from spec text (no AI needed — regex on § headings)
829
+ const endpointMatches = finalSpec.match(/^\s*[-*]\s+`?(GET|POST|PUT|PATCH|DELETE)\s+\//gim);
830
+ const modelMatches = finalSpec.match(/^#{1,4}\s+\w.*model|^[-*]\s+\*\*\w+\*\*\s*[:(]/gim);
831
+ const estimatedEndpoints = endpointMatches?.length ?? 0;
832
+ const estimatedModels = modelMatches?.length ?? 0;
833
+ const estimatedFiles = Math.max(3, estimatedEndpoints + estimatedModels + 2);
834
+ if (estimatedEndpoints > 0 || estimatedModels > 0) {
835
+ console.log(chalk.cyan(` Est. DSL scope : ~${estimatedEndpoints} endpoint(s), ~${estimatedModels} model(s) → ~${estimatedFiles} files`));
836
+ }
837
+
838
+ const previewSpecsDir = path.join(currentDir, "specs");
839
+ const slug = featureSlug;
840
+ const prevVersion = await findLatestVersion(previewSpecsDir, slug);
841
+ if (prevVersion) {
842
+ console.log(chalk.gray(` Previous version: v${prevVersion.version} (${prevVersion.filePath})`));
843
+ const diff = computeDiff(prevVersion.content, finalSpec);
844
+ console.log(chalk.cyan("\n ── Changes vs previous version ──────────────"));
845
+ printDiffSummary(diff, `v${prevVersion.version} → v${prevVersion.version + 1}`);
846
+ printDiff(diff);
847
+ console.log(chalk.cyan(" ────────────────────────────────────────────"));
848
+ }
849
+
850
+ const gate = await select({
851
+ message: "Ready to proceed to code generation?",
852
+ choices: [
853
+ { name: "✅ Proceed — start code generation", value: "proceed" },
854
+ { name: "📋 View full spec", value: "view" },
855
+ { name: "❌ Abort", value: "abort" },
856
+ ],
857
+ });
858
+
859
+ if (gate === "view") {
860
+ console.log(chalk.cyan("\n" + "─".repeat(52)));
861
+ console.log(finalSpec);
862
+ console.log(chalk.cyan("─".repeat(52) + "\n"));
863
+
864
+ const confirm2 = await select({
865
+ message: "Proceed to code generation?",
866
+ choices: [
867
+ { name: "✅ Proceed", value: "proceed" },
868
+ { name: "❌ Abort", value: "abort" },
869
+ ],
870
+ });
871
+ if (confirm2 === "abort") {
872
+ console.log(chalk.yellow(" Aborted. Spec was NOT saved."));
873
+ process.exit(0);
874
+ }
875
+ } else if (gate === "abort") {
876
+ console.log(chalk.yellow(" Aborted. Spec was NOT saved."));
877
+ process.exit(0);
878
+ }
879
+
880
+ console.log(chalk.green(" ✔ Approved — continuing to code generation."));
881
+ } else {
882
+ console.log(chalk.gray("[3.5/6] Approval Gate: skipped (--auto)."));
883
+ }
884
+
885
+ // ── Step 3.8: DSL Extraction + Validation ──────────────────────────────
886
+ let extractedDsl: SpecDSL | null = null;
887
+
888
+ if (opts.skipDsl) {
889
+ console.log(chalk.gray("\n[DSL] Skipped (--skip-dsl)."));
890
+ } else {
891
+ console.log(chalk.blue("\n[DSL] Extracting structured DSL from spec..."));
892
+ console.log(chalk.gray(` Provider: ${specProviderName}/${specModelName}`));
893
+ runLogger.stageStart("dsl_extract");
894
+ try {
895
+ const isFrontend = isFrontendDeps(context.dependencies);
896
+ if (isFrontend) console.log(chalk.gray(" Frontend project detected — using ComponentSpec extractor"));
897
+ const dslExtractor = new DslExtractor(specProvider);
898
+ extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
899
+ if (extractedDsl) {
900
+ runLogger.stageEnd("dsl_extract", { endpoints: extractedDsl.endpoints?.length ?? 0, models: extractedDsl.models?.length ?? 0 });
901
+ console.log(chalk.green(" ✔ DSL extracted and validated."));
902
+ } else {
903
+ runLogger.stageEnd("dsl_extract", { skipped: true });
904
+ console.log(chalk.yellow(" ⚠ DSL skipped — codegen will use Spec + Tasks only."));
905
+ }
906
+ } catch (err) {
907
+ runLogger.stageFail("dsl_extract", (err as Error).message);
908
+ console.log(chalk.yellow(` ⚠ DSL extraction error: ${(err as Error).message} — continuing without DSL.`));
909
+ }
910
+ }
911
+
912
+ // ── Loop 1: DSL Gap Feedback ────────────────────────────────────────────
913
+ if (extractedDsl && !opts.auto && !opts.fast && !opts.skipDsl) {
914
+ const dslGaps = assessDslRichness(extractedDsl);
915
+
916
+ if (dslGaps.length > 0) {
917
+ printDslGaps(dslGaps);
918
+ runLogger.stageStart("dsl_gap_feedback", { gapCount: dslGaps.length, gaps: dslGaps.map((g) => g.code) });
919
+
920
+ const refineChoice = await select({
921
+ message: "How would you like to proceed?",
922
+ choices: [
923
+ { name: "🔧 Refine spec (AI fills the gaps, then re-extract DSL)", value: "refine" },
924
+ { name: "⏭ Skip — proceed with the current DSL", value: "skip" },
925
+ ],
926
+ });
927
+
928
+ if (refineChoice === "refine") {
929
+ console.log(chalk.blue(" Refining spec to fill DSL gaps..."));
930
+ try {
931
+ const refinedSpec = await specProvider.generate(
932
+ buildDslGapRefinementPrompt(finalSpec, dslGaps),
933
+ "You are a Senior Tech Lead doing a targeted spec revision. Output only the complete revised Markdown spec."
934
+ );
935
+ finalSpec = refinedSpec;
936
+ console.log(chalk.green(" ✔ Spec refined."));
937
+
938
+ console.log(chalk.blue(" Re-extracting DSL from refined spec..."));
939
+ const isFrontend2 = isFrontendDeps(context.dependencies);
940
+ const reExtractor = new DslExtractor(specProvider);
941
+ const reExtractedDsl = await reExtractor.extract(finalSpec, { auto: true, isFrontend: isFrontend2 });
942
+ if (reExtractedDsl) {
943
+ extractedDsl = reExtractedDsl;
944
+ console.log(chalk.green(` ✔ DSL re-extracted: ${extractedDsl.endpoints.length} endpoint(s), ${extractedDsl.models.length} model(s).`));
945
+ runLogger.stageEnd("dsl_gap_feedback", { action: "refined", endpoints: extractedDsl.endpoints.length, models: extractedDsl.models.length });
946
+ } else {
947
+ console.log(chalk.yellow(" ⚠ Re-extraction failed — keeping original DSL."));
948
+ runLogger.stageEnd("dsl_gap_feedback", { action: "refined_but_reextract_failed" });
949
+ }
950
+ } catch (err) {
951
+ console.log(chalk.yellow(` ⚠ Spec refinement failed: ${(err as Error).message} — keeping original DSL.`));
952
+ runLogger.stageEnd("dsl_gap_feedback", { action: "refinement_error", error: (err as Error).message });
953
+ }
954
+ } else {
955
+ runLogger.stageEnd("dsl_gap_feedback", { action: "skipped" });
956
+ console.log(chalk.gray(" Continuing with current DSL."));
957
+ }
958
+ }
959
+ }
960
+
961
+ // ── Step 4: Git Worktree ────────────────────────────────────────────────
962
+ const isFrontendProject = isFrontendDeps(context.dependencies ?? []);
963
+ const skipWorktree = opts.worktree
964
+ ? false
965
+ : opts.skipWorktree || isFrontendProject;
966
+
967
+ let workingDir = currentDir;
968
+ if (!skipWorktree) {
969
+ console.log(chalk.blue("\n[4/6] Setting up git worktree..."));
970
+ const worktreeManager = new GitWorktreeManager(currentDir);
971
+ const worktreePath = await worktreeManager.createWorktree(idea);
972
+ if (worktreePath) workingDir = worktreePath;
973
+ } else {
974
+ const reason = opts.worktree
975
+ ? ""
976
+ : isFrontendProject
977
+ ? " (frontend project — use --worktree to override)"
978
+ : " (--skip-worktree)";
979
+ console.log(chalk.gray(`[4/6] Skipping worktree${reason}.`));
980
+ }
981
+
982
+ // ── Step 5: Save Spec (versioned) + Generate Tasks ──────────────────────
983
+ const specsDir = path.join(workingDir, "specs");
984
+ await fs.ensureDir(specsDir);
985
+
986
+ const { filePath: specFile, version: specVersion } = await nextVersionPath(specsDir, featureSlug);
987
+ await fs.writeFile(specFile, finalSpec, "utf-8");
988
+ console.log(chalk.green(`\n[5/6] ✔ Spec saved: ${specFile}`) + chalk.gray(` (v${specVersion})`));
989
+
990
+ let savedDslFile: string | null = null;
991
+ if (extractedDsl) {
992
+ const dslExtractor = new DslExtractor(specProvider);
993
+ savedDslFile = await dslExtractor.saveDsl(extractedDsl, specFile);
994
+ console.log(chalk.green(` ✔ DSL saved : ${savedDslFile}`));
995
+ }
996
+
997
+ if (!opts.skipTasks) {
998
+ const taskGen = new TaskGenerator(specProvider);
999
+ let tasksToSave = initialTasks;
1000
+
1001
+ if (tasksToSave.length === 0) {
1002
+ console.log(chalk.blue(`\n Generating tasks (separate call)...`));
1003
+ try {
1004
+ tasksToSave = await taskGen.generateTasks(finalSpec, context);
1005
+ } catch (err) {
1006
+ console.log(chalk.yellow(` ⚠ Task generation failed: ${(err as Error).message}`));
1007
+ }
1008
+ }
1009
+
1010
+ if (tasksToSave.length > 0) {
1011
+ const sorted = taskGen.sortByLayer(tasksToSave);
1012
+ const tasksFile = await taskGen.saveTasks(sorted, specFile);
1013
+ printTasks(sorted);
1014
+ console.log(chalk.green(` ✔ Tasks saved: ${tasksFile}`));
1015
+ } else {
1016
+ console.log(chalk.yellow(" ⚠ No tasks generated — code generation will use fallback file planning."));
1017
+ }
1018
+ }
1019
+
1020
+ // ── Step 6: Code Generation ─────────────────────────────────────────────
1021
+ console.log(chalk.blue(`\n[6/6] Code generation (mode: ${codegenMode})...`));
1022
+ const rawCodegenProvider: AIProvider =
1023
+ codegenProviderName === specProviderName && codegenApiKey === specApiKey
1024
+ ? specProvider
1025
+ : createProvider(codegenProviderName, codegenApiKey, codegenModelName);
1026
+ let codegenProvider: AIProvider;
1027
+ if (!vcrReplayProvider && opts.vcrRecord && !(rawCodegenProvider instanceof VcrRecordingProvider)) {
1028
+ // Different provider from spec — needs its own recorder
1029
+ codegenVcrRecorder = new VcrRecordingProvider(rawCodegenProvider);
1030
+ codegenProvider = codegenVcrRecorder;
1031
+ console.log(chalk.cyan(` [VCR] Recording codegen AI calls → .ai-spec-vcr/${runId}.json`));
1032
+ } else {
1033
+ codegenProvider = rawCodegenProvider;
1034
+ }
1035
+
1036
+ // ── TDD: generate failing tests BEFORE implementation ──────────────────
1037
+ let generatedTestFiles: string[] = [];
1038
+ if (opts.tdd && extractedDsl) {
1039
+ console.log(chalk.cyan("\n[TDD] Generating pre-implementation tests (will fail until code is written)..."));
1040
+ const testGen = new TestGenerator(codegenProvider);
1041
+ generatedTestFiles = await testGen.generateTdd(extractedDsl, workingDir);
1042
+ }
1043
+
1044
+ runLogger.stageStart("codegen", { mode: codegenMode, provider: codegenProviderName, model: codegenModelName });
1045
+ const codegen = new CodeGenerator(codegenProvider, codegenMode);
1046
+ const generatedFiles = await codegen.generateCode(specFile, workingDir, context, {
1047
+ auto: opts.auto,
1048
+ resume: opts.resume,
1049
+ dslFilePath: savedDslFile ?? undefined,
1050
+ repoType: detectedRepoType,
1051
+ });
1052
+ runLogger.stageEnd("codegen", { filesGenerated: generatedFiles.length });
1053
+
1054
+ // ── Step 7: Test Skeleton Generation ───────────────────────────────────
1055
+ if (opts.tdd) {
1056
+ console.log(chalk.gray("\n[7/9] TDD mode — test files already written pre-implementation."));
1057
+ } else if (opts.skipTests) {
1058
+ console.log(chalk.gray("\n[7/9] Skipping test generation (--skip-tests)."));
1059
+ } else if (!extractedDsl) {
1060
+ console.log(chalk.gray("\n[7/9] Skipping test generation (no DSL available)."));
1061
+ } else {
1062
+ console.log(chalk.blue(`\n[7/9] Test skeleton generation...`));
1063
+ runLogger.stageStart("test_gen");
1064
+ const testGen = new TestGenerator(codegenProvider);
1065
+ generatedTestFiles = await testGen.generate(extractedDsl, workingDir);
1066
+ runLogger.stageEnd("test_gen", { filesGenerated: generatedTestFiles.length });
1067
+ }
1068
+
1069
+ // ── Step 8: Error Feedback Loop ─────────────────────────────────────────
1070
+ let compilePassed = false;
1071
+ if (opts.skipErrorFeedback) {
1072
+ console.log(chalk.gray("[8/9] Skipping error feedback (--skip-error-feedback)."));
1073
+ compilePassed = true;
1074
+ } else {
1075
+ if (opts.tdd) {
1076
+ console.log(chalk.cyan("[8/9] TDD mode — error feedback loop driving implementation to pass tests..."));
1077
+ }
1078
+ runLogger.stageStart("error_feedback");
1079
+ compilePassed = await runErrorFeedback(codegenProvider, workingDir, extractedDsl, {
1080
+ maxCycles: opts.tdd ? 3 : 2,
1081
+ });
1082
+ runLogger.stageEnd("error_feedback");
1083
+ }
1084
+
1085
+ // ── Step 9: Code Review ─────────────────────────────────────────────────
1086
+ let reviewResult = "";
1087
+ let accumulatePromise: Promise<void> | undefined;
1088
+ if (!opts.skipReview) {
1089
+ console.log(chalk.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
1090
+ runLogger.stageStart("review");
1091
+ const reviewer = new CodeReviewer(specProvider, currentDir);
1092
+ const savedSpec = await fs.readFile(specFile, "utf-8");
1093
+
1094
+ if (codegenMode === "api" && generatedFiles.length > 0) {
1095
+ reviewResult = await reviewer.reviewFiles(savedSpec, generatedFiles, workingDir, specFile);
1096
+ } else {
1097
+ const originalDir = process.cwd();
1098
+ try {
1099
+ process.chdir(workingDir);
1100
+ reviewResult = await reviewer.reviewCode(savedSpec, specFile);
1101
+ } finally {
1102
+ process.chdir(originalDir);
1103
+ }
1104
+ }
1105
+ runLogger.stageEnd("review");
1106
+
1107
+ // Surface Pass 0 compliance score
1108
+ const complianceScore = extractComplianceScore(reviewResult);
1109
+ const missingCount = extractMissingCount(reviewResult);
1110
+ if (complianceScore > 0) {
1111
+ const scoreColor = complianceScore >= 8 ? chalk.green : complianceScore >= 6 ? chalk.yellow : chalk.red;
1112
+ console.log(
1113
+ chalk.gray("\n Spec Compliance (Pass 0): ") +
1114
+ scoreColor(`${complianceScore}/10`) +
1115
+ (missingCount > 0
1116
+ ? chalk.red(` · ${missingCount} missing requirement(s) — see Blockers section above`)
1117
+ : chalk.green(" · all requirements covered"))
1118
+ );
1119
+ runLogger.stageEnd("compliance_check", { complianceScore, missingCount });
1120
+ }
1121
+
1122
+ // Fire async — don't block the remaining pipeline steps
1123
+ accumulatePromise = accumulateReviewKnowledge(specProvider, currentDir, reviewResult)
1124
+ .catch((err) => console.log(chalk.yellow(` ⚠ §9 accumulation failed: ${(err as Error).message}`)));
1125
+ }
1126
+
1127
+ // ── Loop 2: Review → DSL Structural Feedback ────────────────────────────
1128
+ if (reviewResult && !opts.skipReview && !opts.auto && extractedDsl && savedDslFile) {
1129
+ const structuralFindings = extractStructuralFindings(reviewResult);
1130
+
1131
+ if (structuralFindings.length > 0) {
1132
+ printStructuralFindings(structuralFindings);
1133
+ runLogger.stageStart("review_dsl_feedback", { findingCount: structuralFindings.length, categories: structuralFindings.map((f) => f.category) });
1134
+
1135
+ const savedSpecContent = await fs.readFile(specFile, "utf-8");
1136
+
1137
+ const patchChoice = await select({
1138
+ message: "These are design issues in the Spec/DSL. How would you like to handle them?",
1139
+ choices: [
1140
+ { name: "🔧 Amend spec + update DSL (AI fixes the design issues, no regen yet)", value: "amend" },
1141
+ { name: "📝 Note in §9 only (already done — no DSL change)", value: "note" },
1142
+ { name: "⏭ Skip", value: "skip" },
1143
+ ],
1144
+ });
1145
+
1146
+ if (patchChoice === "amend") {
1147
+ console.log(chalk.blue(" Amending spec to address structural findings..."));
1148
+ try {
1149
+ const amendedSpec = await specProvider.generate(
1150
+ buildStructuralAmendmentPrompt(savedSpecContent, structuralFindings),
1151
+ "You are a Senior Tech Lead doing a targeted spec correction. Output only the complete revised Markdown spec."
1152
+ );
1153
+
1154
+ await runSnapshot.snapshotFile(specFile);
1155
+ if (savedDslFile) await runSnapshot.snapshotFile(savedDslFile);
1156
+
1157
+ await fs.writeFile(specFile, amendedSpec, "utf-8");
1158
+ console.log(chalk.green(` ✔ Spec updated: ${specFile}`));
1159
+
1160
+ console.log(chalk.blue(" Re-extracting DSL from amended spec..."));
1161
+ const isFrontend3 = isFrontendDeps(context.dependencies);
1162
+ const amendExtractor = new DslExtractor(specProvider);
1163
+ const amendedDsl = await amendExtractor.extract(amendedSpec, { auto: true, isFrontend: isFrontend3 });
1164
+ if (amendedDsl) {
1165
+ const dslWriter = new DslExtractor(specProvider);
1166
+ const newDslPath = await dslWriter.saveDsl(amendedDsl, specFile);
1167
+ extractedDsl = amendedDsl;
1168
+ console.log(chalk.green(` ✔ DSL updated: ${newDslPath}`));
1169
+ console.log(chalk.cyan(
1170
+ `\n Next step: run ${chalk.white("ai-spec update --codegen")} to regenerate files affected by the DSL change.`
1171
+ ));
1172
+ runLogger.stageEnd("review_dsl_feedback", {
1173
+ action: "amended",
1174
+ endpoints: amendedDsl.endpoints.length,
1175
+ models: amendedDsl.models.length,
1176
+ });
1177
+ } else {
1178
+ console.log(chalk.yellow(" ⚠ DSL re-extraction failed — spec was updated but DSL file unchanged."));
1179
+ runLogger.stageEnd("review_dsl_feedback", { action: "amended_spec_only" });
1180
+ }
1181
+ } catch (err) {
1182
+ console.log(chalk.yellow(` ⚠ Spec amendment failed: ${(err as Error).message}`));
1183
+ runLogger.stageEnd("review_dsl_feedback", { action: "amendment_error", error: (err as Error).message });
1184
+ }
1185
+ } else {
1186
+ runLogger.stageEnd("review_dsl_feedback", { action: patchChoice });
1187
+ if (patchChoice === "note") {
1188
+ console.log(chalk.gray(" Structural findings retained in §9. DSL unchanged."));
1189
+ }
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // ── Step 10: Harness Self-Evaluation ────────────────────────────────────
1195
+ runLogger.stageStart("self_eval");
1196
+ const selfEvalResult = runSelfEval({
1197
+ dsl: extractedDsl,
1198
+ generatedFiles,
1199
+ compilePassed,
1200
+ reviewText: reviewResult,
1201
+ promptHash,
1202
+ logger: runLogger,
1203
+ });
1204
+ printSelfEval(selfEvalResult);
1205
+
1206
+ // ── Await async §9 accumulation (fire-and-await pattern) ────────────────
1207
+ if (accumulatePromise) await accumulatePromise;
1208
+
1209
+ // ── VCR: save recording ─────────────────────────────────────────────────
1210
+ if (specVcrRecorder) {
1211
+ const vcrPath = await specVcrRecorder.save(currentDir, runId, codegenVcrRecorder ?? undefined);
1212
+ console.log(chalk.cyan(`[VCR] Recording saved: ${path.relative(currentDir, vcrPath)}`));
1213
+ console.log(chalk.gray(` Replay with: ai-spec create --vcr-replay ${runId} <idea>`));
1214
+ }
1215
+
1216
+ // ── Done ────────────────────────────────────────────────────────────────
1217
+ runLogger.finish();
1218
+ console.log(chalk.bold.green("\n✔ All done!"));
1219
+ console.log(chalk.gray(` Spec : ${specFile}`));
1220
+ if (savedDslFile) console.log(chalk.gray(` DSL : ${savedDslFile}`));
1221
+ if (generatedTestFiles.length > 0) {
1222
+ console.log(chalk.gray(` Tests : ${generatedTestFiles.length} skeleton file(s) generated`));
1223
+ }
1224
+ console.log(chalk.gray(` Working dir : ${workingDir}`));
1225
+ if (workingDir !== currentDir) {
1226
+ console.log(chalk.gray(` Run \`cd ${workingDir}\` to enter the worktree.`));
1227
+ }
1228
+ runLogger.printSummary();
1229
+ if (runSnapshot.fileCount > 0) {
1230
+ console.log(chalk.gray(` To undo changes: ai-spec restore ${runId}`));
1231
+ }
1232
+ });
1233
+ }