ai-spec-dev 0.33.0 → 0.36.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 (64) 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 +11 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +424 -0
  10. package/cli/commands/config.ts +18 -0
  11. package/cli/commands/create.ts +1248 -0
  12. package/cli/commands/dashboard.ts +62 -0
  13. package/cli/commands/init.ts +45 -8
  14. package/cli/commands/mock.ts +175 -0
  15. package/cli/commands/scan.ts +99 -0
  16. package/cli/commands/types.ts +69 -0
  17. package/cli/commands/vcr.ts +70 -0
  18. package/cli/index.ts +34 -2517
  19. package/cli/utils.ts +4 -0
  20. package/core/code-generator.ts +6 -4
  21. package/core/combined-generator.ts +13 -3
  22. package/core/dashboard-generator.ts +340 -0
  23. package/core/design-dialogue.ts +124 -0
  24. package/core/dsl-extractor.ts +9 -1
  25. package/core/dsl-feedback.ts +41 -5
  26. package/core/dsl-validator.ts +32 -0
  27. package/core/error-feedback.ts +46 -2
  28. package/core/key-store.ts +5 -4
  29. package/core/project-index.ts +301 -0
  30. package/core/provider-utils.ts +39 -4
  31. package/core/reviewer.ts +84 -6
  32. package/core/run-logger.ts +109 -3
  33. package/core/run-trend.ts +24 -4
  34. package/core/self-evaluator.ts +39 -11
  35. package/core/spec-generator.ts +14 -8
  36. package/core/task-generator.ts +17 -0
  37. package/core/types-generator.ts +219 -0
  38. package/core/vcr.ts +210 -0
  39. package/dist/cli/index.js +7407 -5643
  40. package/dist/cli/index.js.map +1 -1
  41. package/dist/cli/index.mjs +7401 -5637
  42. package/dist/cli/index.mjs.map +1 -1
  43. package/dist/index.d.mts +34 -5
  44. package/dist/index.d.ts +34 -5
  45. package/dist/index.js +497 -232
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +495 -233
  48. package/dist/index.mjs.map +1 -1
  49. package/docs-assets/purpose/architecture-overview.svg +64 -0
  50. package/docs-assets/purpose/create-pipeline.svg +113 -0
  51. package/docs-assets/purpose/task-layering.svg +74 -0
  52. package/package.json +1 -1
  53. package/prompts/codegen.prompt.ts +97 -9
  54. package/prompts/design.prompt.ts +59 -0
  55. package/prompts/spec.prompt.ts +8 -1
  56. package/prompts/tasks.prompt.ts +27 -2
  57. package/purpose.md +600 -174
  58. package/tests/code-generator.test.ts +253 -0
  59. package/tests/context-loader.test.ts +207 -0
  60. package/tests/dsl-validator.test.ts +105 -0
  61. package/tests/openapi-exporter.test.ts +310 -0
  62. package/tests/reviewer.test.ts +214 -0
  63. package/tests/spec-generator.test.ts +228 -0
  64. package/tests/spec-versioning.test.ts +205 -0
package/cli/index.ts CHANGED
@@ -1,169 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import * as path from "path";
4
- import * as fs from "fs-extra";
5
- import chalk from "chalk";
6
3
  import * as dotenv from "dotenv";
7
- import { input, confirm, select, checkbox } from "@inquirer/prompts";
8
4
 
9
5
  dotenv.config();
10
6
 
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, appendDirectLesson } 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 {
56
- globalConstitutionSystemPrompt,
57
- buildGlobalConstitutionPrompt,
58
- } from "../prompts/global-constitution.prompt";
59
- import { RequirementDecomposer, DecompositionResult, RepoRequirement } from "../core/requirement-decomposer";
60
- import { buildFrontendApiContract, buildContractContextSection } from "../core/contract-bridge";
61
- import { loadFrontendContext, buildFrontendContextSection } from "../core/frontend-context-loader";
62
- import { buildFrontendSpecPrompt, frontendSpecSystemPrompt } from "../prompts/frontend-spec.prompt";
63
- import { SpecDSL } from "../core/dsl-types";
64
- import {
65
- generateMockAssets,
66
- findLatestDslFile,
67
- applyMockProxy,
68
- restoreMockProxy,
69
- startMockServerBackground,
70
- saveMockServerPid,
71
- } from "../core/mock-server-generator";
72
- import { SpecUpdater } from "../core/spec-updater";
73
- import { exportOpenApi } from "../core/openapi-exporter";
74
- import { generateRunId, RunLogger, setActiveLogger } from "../core/run-logger";
75
- import { RunSnapshot, setActiveSnapshot } from "../core/run-snapshot";
76
- import { computePromptHash } from "../core/prompt-hasher";
77
- import { runSelfEval, printSelfEval } from "../core/self-evaluator";
78
- import { loadRunLogs, buildTrendReport, printTrendReport } from "../core/run-trend";
79
- import {
80
- assessDslRichness,
81
- buildDslGapRefinementPrompt,
82
- extractStructuralFindings,
83
- buildStructuralAmendmentPrompt,
84
- printDslGaps,
85
- printStructuralFindings,
86
- } from "../core/dsl-feedback";
87
-
88
- // ─── Config File ──────────────────────────────────────────────────────────────
89
-
90
- interface AiSpecConfig {
91
- provider?: string;
92
- model?: string;
93
- codegen?: CodeGenMode;
94
- codegenProvider?: string;
95
- codegenModel?: string;
96
- /** Minimum overall spec score (1-10) required to pass Approval Gate. 0 = disabled (default). */
97
- minSpecScore?: number;
98
- }
99
-
100
- const CONFIG_FILE = ".ai-spec.json";
101
-
102
- async function loadConfig(dir: string): Promise<AiSpecConfig> {
103
- const p = path.join(dir, CONFIG_FILE);
104
- if (await fs.pathExists(p)) {
105
- return fs.readJson(p);
106
- }
107
- return {};
108
- }
109
-
110
- // ─── API Key Resolution ────────────────────────────────────────────────────────
111
-
112
- async function resolveApiKey(
113
- providerName: string,
114
- cliKey?: string
115
- ): Promise<string> {
116
- if (cliKey) return cliKey;
117
-
118
- const envVar = ENV_KEY_MAP[providerName];
119
- if (envVar && process.env[envVar]) return process.env[envVar]!;
120
-
121
- // Check saved key and offer reuse
122
- const savedKey = await getSavedKey(providerName);
123
- if (savedKey) {
124
- const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);
125
- const choice = await select({
126
- message: `${providerName} API key (saved: ${masked}):`,
127
- choices: [
128
- { name: "Use saved key", value: "reuse" },
129
- { name: "Enter a new key", value: "new" },
130
- ],
131
- });
132
- if (choice === "reuse") return savedKey;
133
- }
134
-
135
- // Fresh input — save for next time
136
- const newKey = await input({
137
- message: `Enter your ${providerName} API key${envVar ? ` (or set ${envVar} env var)` : ""}:`,
138
- validate: (v) => v.trim().length > 0 || "API key cannot be empty",
139
- });
140
- await saveKey(providerName, newKey.trim());
141
- console.log(chalk.gray(` Key saved to ${KEY_STORE_FILE}`));
142
- return newKey.trim();
143
- }
144
-
145
- // ─── Banner ───────────────────────────────────────────────────────────────────
146
-
147
- function printBanner(opts: {
148
- specProvider: string;
149
- specModel: string;
150
- codegenMode: string;
151
- codegenProvider: string;
152
- codegenModel: string;
153
- }) {
154
- console.log(chalk.blue("\n" + "─".repeat(52)));
155
- console.log(chalk.bold(" ai-spec — AI-driven Development Orchestrator"));
156
- console.log(chalk.blue("─".repeat(52)));
157
- console.log(chalk.gray(` Spec : ${opts.specProvider} / ${opts.specModel}`));
158
- console.log(
159
- chalk.gray(
160
- ` Codegen : ${opts.codegenMode} (${opts.codegenProvider} / ${opts.codegenModel})`
161
- )
162
- );
163
- console.log(chalk.blue("─".repeat(52) + "\n"));
164
- }
165
-
166
- // ─── Program ──────────────────────────────────────────────────────────────────
7
+ import { registerCreate } from "./commands/create";
8
+ import { registerReview } from "./commands/review";
9
+ import { registerInit } from "./commands/init";
10
+ import { registerConfig } from "./commands/config";
11
+ import { registerModel } from "./commands/model";
12
+ import { registerWorkspace } from "./commands/workspace";
13
+ import { registerUpdate } from "./commands/update";
14
+ import { registerExport } from "./commands/export";
15
+ import { registerMock } from "./commands/mock";
16
+ import { registerLearn } from "./commands/learn";
17
+ import { registerRestore } from "./commands/restore";
18
+ import { registerTrend } from "./commands/trend";
19
+ import { registerLogs } from "./commands/logs";
20
+ import { registerTypes } from "./commands/types";
21
+ import { registerDashboard } from "./commands/dashboard";
22
+ import { registerVcr } from "./commands/vcr";
23
+ import { registerScan } from "./commands/scan";
167
24
 
168
25
  const program = new Command();
169
26
 
@@ -172,2362 +29,22 @@ program
172
29
  .description("AI-driven Development Orchestrator — spec, generate, review")
173
30
  .version("0.14.1");
174
31
 
175
- // ═══════════════════════════════════════════════════════════════════════════════
176
- // Command: create
177
- // ═══════════════════════════════════════════════════════════════════════════════
178
-
179
- program
180
- .command("create")
181
- .description("Generate a feature spec and kick off code generation")
182
- .argument("[idea]", "Feature idea in natural language (prompted if omitted)")
183
- .option(
184
- "--provider <name>",
185
- `AI provider for spec generation (${SUPPORTED_PROVIDERS.join("|")})`,
186
- undefined
187
- )
188
- .option("--model <name>", "Model name for spec generation")
189
- .option("-k, --key <apiKey>", "API key (overrides env var)")
190
- .option(
191
- "--codegen <mode>",
192
- "Code generation mode: claude-code | api | plan",
193
- undefined
194
- )
195
- .option(
196
- "--codegen-provider <name>",
197
- "AI provider for code generation (defaults to --provider)"
198
- )
199
- .option("--codegen-model <name>", "Model for code generation")
200
- .option("--codegen-key <key>", "API key for code generation (if different)")
201
- .option("--skip-worktree", "Skip git worktree creation (auto-set for frontend projects)")
202
- .option("--worktree", "Force git worktree creation even for frontend projects")
203
- .option("--skip-review", "Skip automated code review")
204
- .option("--skip-tasks", "Skip task generation (just generate spec)")
205
- .option("--auto", "Run claude non-interactively via -p flag (saves tokens)")
206
- .option("--fast", "Skip interactive spec refinement, proceed immediately with initial spec")
207
- .option("--resume", "Resume an interrupted run — skip tasks already marked as done")
208
- .option("--skip-dsl", "Skip DSL extraction step")
209
- .option("--skip-tests", "Skip test skeleton generation")
210
- .option("--skip-error-feedback", "Skip error feedback loop (test/lint auto-fix)")
211
- .option("--tdd", "TDD mode: generate failing tests first, then generate implementation to pass them")
212
- .option("--skip-assessment", "Skip spec quality pre-assessment before the Approval Gate")
213
- .option("--force", "Bypass the spec quality score gate even if score is below minSpecScore")
214
- .option("--serve", "After workspace pipeline completes, auto-start mock server + patch frontend proxy")
215
- .action(async (idea: string | undefined, opts) => {
216
- const currentDir = process.cwd();
217
- const config = await loadConfig(currentDir);
218
-
219
- // ── Resolve idea ──────────────────────────────────────────────────────────
220
- if (!idea) {
221
- idea = await input({
222
- message: "What feature do you want to build?",
223
- validate: (v) => v.trim().length > 0 || "Please describe your feature",
224
- });
225
- }
226
-
227
- // ── Detect workspace mode ─────────────────────────────────────────────────
228
- const workspaceLoader = new WorkspaceLoader(currentDir);
229
- const workspaceConfig = await workspaceLoader.load();
230
-
231
- if (workspaceConfig) {
232
- console.log(chalk.cyan(`\n[Workspace] Detected workspace: ${workspaceConfig.name}`));
233
- console.log(chalk.gray(` Repos: ${workspaceConfig.repos.map((r) => r.name).join(", ")}`));
234
- const pipelineResults = await runMultiRepoPipeline(idea!, workspaceConfig, opts, currentDir, config);
235
-
236
- // ── Auto-serve: start mock server + patch frontend proxy ──────────────
237
- if (opts.serve) {
238
- console.log(chalk.blue("\n─── Auto-serve: starting mock server ───────────"));
239
- const backendResult = pipelineResults.find((r) => r.role === "backend" && r.status === "success" && r.dsl);
240
- const frontendResult = pipelineResults.find((r) => (r.role === "frontend" || r.role === "mobile") && r.status === "success");
241
-
242
- if (!backendResult) {
243
- console.log(chalk.yellow(" No successful backend with DSL found — skipping auto-serve."));
244
- } else {
245
- const mockPort = 3001;
246
- // Generate mock assets in backend repo
247
- const mockResult = await generateMockAssets(backendResult.dsl!, backendResult.repoAbsPath, { port: mockPort });
248
- const serverJsPath = path.join(backendResult.repoAbsPath, "mock", "server.js");
249
- console.log(chalk.green(` ✔ Mock assets generated (${mockResult.files.length} file(s))`));
250
-
251
- // Start mock server
252
- const pid = startMockServerBackground(serverJsPath, mockPort);
253
- console.log(chalk.green(` ✔ Mock server started (PID ${pid}) → http://localhost:${mockPort}`));
254
-
255
- // Patch frontend proxy
256
- if (frontendResult) {
257
- const proxyResult = await applyMockProxy(frontendResult.repoAbsPath, mockPort, backendResult.dsl!.endpoints);
258
- await saveMockServerPid(frontendResult.repoAbsPath, pid);
259
- if (proxyResult.applied) {
260
- console.log(chalk.green(` ✔ Frontend proxy patched (${proxyResult.framework})`));
261
- console.log(chalk.bold.cyan(`\n Ready! Run your frontend dev server:`));
262
- console.log(chalk.white(` cd ${frontendResult.repoAbsPath}`));
263
- console.log(chalk.white(` ${proxyResult.devCommand}`));
264
- console.log(chalk.gray(`\n When done, restore: ai-spec mock --restore --frontend ${frontendResult.repoAbsPath}`));
265
- } else {
266
- console.log(chalk.yellow(` ⚠ Auto-patch not available for ${proxyResult.framework}.`));
267
- if (proxyResult.note) console.log(chalk.gray(` ${proxyResult.note}`));
268
- console.log(chalk.gray(` Mock server: http://localhost:${mockPort}`));
269
- }
270
- } else {
271
- console.log(chalk.gray(` No frontend repo found — mock server is running at http://localhost:${mockPort}`));
272
- console.log(chalk.gray(` Configure your frontend proxy manually to point to http://localhost:${mockPort}`));
273
- }
274
- }
275
- }
276
-
277
- return;
278
- }
279
-
280
- // ── Resolve spec provider ─────────────────────────────────────────────────
281
- const specProviderName = opts.provider || config.provider || "gemini";
282
- const specModelName =
283
- opts.model || config.model || DEFAULT_MODELS[specProviderName];
284
- const specApiKey = await resolveApiKey(specProviderName, opts.key);
285
-
286
- // ── Resolve codegen ───────────────────────────────────────────────────────
287
- const codegenMode: CodeGenMode =
288
- (opts.codegen as CodeGenMode) || config.codegen || "claude-code";
289
- const codegenProviderName =
290
- opts.codegenProvider || config.codegenProvider || specProviderName;
291
- const codegenModelName =
292
- opts.codegenModel ||
293
- config.codegenModel ||
294
- DEFAULT_MODELS[codegenProviderName];
295
- const codegenApiKey =
296
- codegenProviderName === specProviderName
297
- ? specApiKey
298
- : await resolveApiKey(codegenProviderName, opts.codegenKey);
299
-
300
- printBanner({
301
- specProvider: specProviderName,
302
- specModel: specModelName,
303
- codegenMode,
304
- codegenProvider: codegenProviderName,
305
- codegenModel: codegenModelName,
306
- });
307
-
308
- // ── Run tracking ──────────────────────────────────────────────────────────────
309
- const runId = generateRunId();
310
- console.log(chalk.gray(` Run ID: ${runId}`));
311
- const runSnapshot = new RunSnapshot(currentDir, runId);
312
- setActiveSnapshot(runSnapshot);
313
- const runLogger = new RunLogger(currentDir, runId, {
314
- provider: specProviderName,
315
- model: specModelName,
316
- });
317
- setActiveLogger(runLogger);
318
-
319
- // Record prompt hash immediately — links this RunLog to the prompt version
320
- // in use, enabling cross-run harnessScore comparisons (Harness Engineering).
321
- const promptHash = computePromptHash();
322
- runLogger.setPromptHash(promptHash);
323
-
324
- // ── Step 1: Context ───────────────────────────────────────────────────────
325
- console.log(chalk.blue("[1/6] Loading project context..."));
326
- runLogger.stageStart("context_load");
327
- const loader = new ContextLoader(currentDir);
328
- const context = await loader.loadProjectContext();
329
- const { type: detectedRepoType } = await detectRepoType(currentDir);
330
- runLogger.stageEnd("context_load", { techStack: context.techStack, repoType: detectedRepoType });
331
- console.log(chalk.gray(` Tech stack : ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
332
- console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
333
- console.log(chalk.gray(` API files : ${context.apiStructure.length} files`));
334
- if (context.schema) {
335
- console.log(chalk.gray(` Prisma schema: found`));
336
- }
337
- if (context.constitution) {
338
- console.log(chalk.green(` Constitution : found (.ai-spec-constitution.md)`));
339
- if (context.constitution.length > 6000) {
340
- console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
341
- }
342
- } else {
343
- // Auto-run init: generate constitution before proceeding
344
- console.log(chalk.yellow(" Constitution : not found — auto-generating..."));
345
- try {
346
- const constitutionGen = new ConstitutionGenerator(
347
- createProvider(specProviderName, specApiKey, specModelName)
348
- );
349
- const constitutionContent = await constitutionGen.generate(currentDir);
350
- await constitutionGen.saveConstitution(currentDir, constitutionContent);
351
- context.constitution = constitutionContent;
352
- console.log(chalk.green(` Constitution : ✔ generated and saved (.ai-spec-constitution.md)`));
353
- } catch (err) {
354
- console.log(chalk.yellow(` Constitution : ⚠ auto-generation failed (${(err as Error).message}), continuing without it.`));
355
- }
356
- }
357
-
358
- // ── Step 2: Spec + Tasks Generation (single AI call) ─────────────────────
359
- console.log(chalk.blue(`\n[2/6] Generating spec with ${specProviderName}/${specModelName}...`));
360
- const specProvider = createProvider(specProviderName, specApiKey, specModelName);
361
-
362
- let initialSpec: string;
363
- let initialTasks: import("../core/task-generator").SpecTask[] = [];
364
-
365
- runLogger.stageStart("spec_gen", { provider: specProviderName, model: specModelName });
366
- try {
367
- if (opts.skipTasks) {
368
- // Tasks skipped: use SpecGenerator alone
369
- const generator = new SpecGenerator(specProvider);
370
- initialSpec = await generator.generateSpec(idea, context);
371
- console.log(chalk.green(" ✔ Spec generated."));
372
- } else {
373
- // Combined: spec + tasks in one call
374
- const result = await generateSpecWithTasks(specProvider, idea, context);
375
- initialSpec = result.spec;
376
- initialTasks = result.tasks;
377
- console.log(chalk.green(` ✔ Spec generated.`));
378
- if (initialTasks.length > 0) {
379
- console.log(chalk.green(` ✔ ${initialTasks.length} tasks generated (combined call).`));
380
- } else {
381
- console.log(chalk.yellow(" ⚠ Tasks not parsed from response — will retry separately after refinement."));
382
- }
383
- }
384
- runLogger.stageEnd("spec_gen", { taskCount: initialTasks.length });
385
- } catch (err) {
386
- runLogger.stageFail("spec_gen", (err as Error).message);
387
- console.error(chalk.red(" ✘ Spec generation failed:"), err);
388
- process.exit(1);
389
- }
390
-
391
- // ── Step 3: Interactive Refinement ────────────────────────────────────────
392
- let finalSpec: string;
393
- if (opts.fast) {
394
- console.log(chalk.gray("\n[3/6] Skipping refinement (--fast)."));
395
- finalSpec = initialSpec;
396
- } else {
397
- console.log(chalk.blue("\n[3/6] Interactive spec refinement..."));
398
- runLogger.stageStart("spec_refine");
399
- const refiner = new SpecRefiner(specProvider);
400
- finalSpec = await refiner.refineLoop(initialSpec);
401
- runLogger.stageEnd("spec_refine");
402
- }
403
-
404
- // Compute slug once — used in Approval Gate diff preview and versioned file save
405
- const featureSlug = slugify(idea!);
406
-
407
- // ── Step 3.4: Spec Quality Pre-Assessment ────────────────────────────────
408
- // Skipped in --skip-assessment mode.
409
- // In --auto mode: skipped UNLESS minSpecScore is configured — the hard gate
410
- // must still enforce even in CI/auto runs (only the interactive display is skipped).
411
- const minScore = config.minSpecScore ?? 0;
412
- const shouldRunAssessment = !opts.skipAssessment && (!opts.auto || minScore > 0);
413
-
414
- if (shouldRunAssessment) {
415
- if (!opts.auto) {
416
- console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
417
- }
418
- runLogger.stageStart("spec_assess");
419
- const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? undefined);
420
- if (assessment) {
421
- runLogger.stageEnd("spec_assess", { overallScore: assessment.overallScore });
422
- if (!opts.auto) printSpecAssessment(assessment);
423
-
424
- if (minScore > 0 && assessment.overallScore < minScore) {
425
- if (opts.force) {
426
- console.log(chalk.yellow(`\n ⚠ Score gate: ${assessment.overallScore}/10 < minimum ${minScore}/10 — bypassed with --force.`));
427
- } else {
428
- runLogger.stageFail("spec_assess", `Score gate: ${assessment.overallScore} < ${minScore}`);
429
- console.log(chalk.red(`\n ✘ Spec quality gate failed: overallScore ${assessment.overallScore}/10 < minimum ${minScore}/10`));
430
- if (!opts.auto) {
431
- console.log(chalk.gray(` Address the issues above and re-run, or use --force to bypass.`));
432
- } else {
433
- console.log(chalk.gray(` Auto mode: gate enforced. Fix the spec or lower minSpecScore, or use --force to bypass.`));
434
- }
435
- console.log(chalk.gray(` Gate threshold set in .ai-spec.json → "minSpecScore": ${minScore}`));
436
- process.exit(1);
437
- }
438
- }
439
- } else {
440
- runLogger.stageEnd("spec_assess", { skipped: true });
441
- if (!opts.auto) {
442
- console.log(chalk.gray(" (Assessment skipped — AI call failed or timed out)"));
443
- }
444
- }
445
- }
446
-
447
- // ── Step 3.5: Approval Gate ───────────────────────────────────────────────
448
- if (!opts.auto) {
449
- console.log(chalk.blue("\n[3.5/6] Approval Gate — review before code generation"));
450
-
451
- // Show spec stats
452
- const specLines = finalSpec.split("\n").length;
453
- const specWords = finalSpec.split(/\s+/).length;
454
- const taskCountHint = initialTasks.length > 0 ? ` Tasks generated : ${initialTasks.length}` : "";
455
- console.log(chalk.gray(` Spec length : ${specLines} lines / ${specWords} words`));
456
- if (taskCountHint) console.log(chalk.gray(taskCountHint));
457
-
458
- // Show diff vs previous version if one exists
459
- const previewSpecsDir = path.join(currentDir, "specs");
460
- const slug = featureSlug;
461
- const prevVersion = await findLatestVersion(previewSpecsDir, slug);
462
- if (prevVersion) {
463
- console.log(chalk.gray(` Previous version: v${prevVersion.version} (${prevVersion.filePath})`));
464
- const diff = computeDiff(prevVersion.content, finalSpec);
465
- console.log(chalk.cyan("\n ── Changes vs previous version ──────────────"));
466
- printDiffSummary(diff, `v${prevVersion.version} → v${prevVersion.version + 1}`);
467
- printDiff(diff);
468
- console.log(chalk.cyan(" ────────────────────────────────────────────"));
469
- }
470
-
471
- const gate = await select({
472
- message: "Ready to proceed to code generation?",
473
- choices: [
474
- { name: "✅ Proceed — start code generation", value: "proceed" },
475
- { name: "📋 View full spec", value: "view" },
476
- { name: "❌ Abort", value: "abort" },
477
- ],
478
- });
479
-
480
- if (gate === "view") {
481
- console.log(chalk.cyan("\n" + "─".repeat(52)));
482
- console.log(finalSpec);
483
- console.log(chalk.cyan("─".repeat(52) + "\n"));
484
-
485
- const confirm2 = await select({
486
- message: "Proceed to code generation?",
487
- choices: [
488
- { name: "✅ Proceed", value: "proceed" },
489
- { name: "❌ Abort", value: "abort" },
490
- ],
491
- });
492
- if (confirm2 === "abort") {
493
- console.log(chalk.yellow(" Aborted. Spec was NOT saved."));
494
- process.exit(0);
495
- }
496
- } else if (gate === "abort") {
497
- console.log(chalk.yellow(" Aborted. Spec was NOT saved."));
498
- process.exit(0);
499
- }
500
-
501
- console.log(chalk.green(" ✔ Approved — continuing to code generation."));
502
- } else {
503
- console.log(chalk.gray("[3.5/6] Approval Gate: skipped (--auto)."));
504
- }
505
-
506
- // ── Step 3.8: DSL Extraction + Validation ─────────────────────────────────
507
- // Runs after approval, before worktree, so the DSL is saved alongside the spec.
508
- // We extract from finalSpec (already approved). The DSL file is saved in Step 5.
509
- let extractedDsl: import("../core/dsl-types").SpecDSL | null = null;
510
-
511
- if (opts.skipDsl) {
512
- console.log(chalk.gray("\n[DSL] Skipped (--skip-dsl)."));
513
- } else {
514
- console.log(chalk.blue("\n[DSL] Extracting structured DSL from spec..."));
515
- console.log(chalk.gray(` Provider: ${specProviderName}/${specModelName}`));
516
- runLogger.stageStart("dsl_extract");
517
- try {
518
- const isFrontend = isFrontendDeps(context.dependencies);
519
- if (isFrontend) console.log(chalk.gray(" Frontend project detected — using ComponentSpec extractor"));
520
- const dslExtractor = new DslExtractor(specProvider);
521
- extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
522
- if (extractedDsl) {
523
- runLogger.stageEnd("dsl_extract", { endpoints: extractedDsl.endpoints?.length ?? 0, models: extractedDsl.models?.length ?? 0 });
524
- console.log(chalk.green(" ✔ DSL extracted and validated."));
525
- } else {
526
- runLogger.stageEnd("dsl_extract", { skipped: true });
527
- console.log(chalk.yellow(" ⚠ DSL skipped — codegen will use Spec + Tasks only."));
528
- }
529
- } catch (err) {
530
- // Unexpected error (not user abort — that would have called process.exit)
531
- runLogger.stageFail("dsl_extract", (err as Error).message);
532
- console.log(chalk.yellow(` ⚠ DSL extraction error: ${(err as Error).message} — continuing without DSL.`));
533
- }
534
- }
535
-
536
- // ── Loop 1: DSL Gap Feedback ──────────────────────────────────────────────
537
- // Runs only in interactive mode (not --auto / --fast / --skip-dsl).
538
- // Checks for common completeness gaps in the freshly-extracted DSL and
539
- // offers a targeted spec refinement AI call to fill them before codegen.
540
- // Zero extra AI calls unless the user explicitly opts in.
541
- if (extractedDsl && !opts.auto && !opts.fast && !opts.skipDsl) {
542
- const dslGaps = assessDslRichness(extractedDsl);
543
-
544
- if (dslGaps.length > 0) {
545
- printDslGaps(dslGaps);
546
- runLogger.stageStart("dsl_gap_feedback", { gapCount: dslGaps.length, gaps: dslGaps.map((g) => g.code) });
547
-
548
- const refineChoice = await select({
549
- message: "How would you like to proceed?",
550
- choices: [
551
- { name: "🔧 Refine spec (AI fills the gaps, then re-extract DSL)", value: "refine" },
552
- { name: "⏭ Skip — proceed with the current DSL", value: "skip" },
553
- ],
554
- });
555
-
556
- if (refineChoice === "refine") {
557
- console.log(chalk.blue(" Refining spec to fill DSL gaps..."));
558
- try {
559
- const refinedSpec = await specProvider.generate(
560
- buildDslGapRefinementPrompt(finalSpec, dslGaps),
561
- "You are a Senior Tech Lead doing a targeted spec revision. Output only the complete revised Markdown spec."
562
- );
563
- finalSpec = refinedSpec;
564
- console.log(chalk.green(" ✔ Spec refined."));
565
-
566
- // Re-extract DSL from the improved spec
567
- console.log(chalk.blue(" Re-extracting DSL from refined spec..."));
568
- const isFrontend2 = isFrontendDeps(context.dependencies);
569
- const reExtractor = new DslExtractor(specProvider);
570
- const reExtractedDsl = await reExtractor.extract(finalSpec, { auto: true, isFrontend: isFrontend2 });
571
- if (reExtractedDsl) {
572
- extractedDsl = reExtractedDsl;
573
- console.log(chalk.green(` ✔ DSL re-extracted: ${extractedDsl.endpoints.length} endpoint(s), ${extractedDsl.models.length} model(s).`));
574
- runLogger.stageEnd("dsl_gap_feedback", { action: "refined", endpoints: extractedDsl.endpoints.length, models: extractedDsl.models.length });
575
- } else {
576
- console.log(chalk.yellow(" ⚠ Re-extraction failed — keeping original DSL."));
577
- runLogger.stageEnd("dsl_gap_feedback", { action: "refined_but_reextract_failed" });
578
- }
579
- } catch (err) {
580
- console.log(chalk.yellow(` ⚠ Spec refinement failed: ${(err as Error).message} — keeping original DSL.`));
581
- runLogger.stageEnd("dsl_gap_feedback", { action: "refinement_error", error: (err as Error).message });
582
- }
583
- } else {
584
- runLogger.stageEnd("dsl_gap_feedback", { action: "skipped" });
585
- console.log(chalk.gray(" Continuing with current DSL."));
586
- }
587
- }
588
- }
589
-
590
- // ── Step 4: Git Worktree ──────────────────────────────────────────────────
591
- // Frontend projects (React / Vue / Next / React-Native) work directly on a
592
- // feature branch — no worktree needed. node_modules is not copied into
593
- // worktrees by default, so running the dev server would break.
594
- // Auto-skip unless the user explicitly passes --worktree.
595
- const isFrontendProject = isFrontendDeps(context.dependencies ?? []);
596
- const skipWorktree = opts.worktree
597
- ? false
598
- : opts.skipWorktree || isFrontendProject;
599
-
600
- let workingDir = currentDir;
601
- if (!skipWorktree) {
602
- console.log(chalk.blue("\n[4/6] Setting up git worktree..."));
603
- const worktreeManager = new GitWorktreeManager(currentDir);
604
- const worktreePath = await worktreeManager.createWorktree(idea);
605
- if (worktreePath) workingDir = worktreePath;
606
- } else {
607
- const reason = opts.worktree
608
- ? ""
609
- : isFrontendProject
610
- ? " (frontend project — use --worktree to override)"
611
- : " (--skip-worktree)";
612
- console.log(chalk.gray(`[4/6] Skipping worktree${reason}.`));
613
- }
614
-
615
- // ── Step 5: Save Spec (versioned) + Generate Tasks ────────────────────────
616
- const specsDir = path.join(workingDir, "specs");
617
- await fs.ensureDir(specsDir);
618
-
619
- const { filePath: specFile, version: specVersion } = await nextVersionPath(specsDir, featureSlug);
620
- await fs.writeFile(specFile, finalSpec, "utf-8");
621
- console.log(chalk.green(`\n[5/6] ✔ Spec saved: ${specFile}`) + chalk.gray(` (v${specVersion})`));
622
-
623
- // Save DSL alongside the spec if extraction succeeded
624
- let savedDslFile: string | null = null;
625
- if (extractedDsl) {
626
- const dslExtractor = new DslExtractor(specProvider);
627
- savedDslFile = await dslExtractor.saveDsl(extractedDsl, specFile);
628
- console.log(chalk.green(` ✔ DSL saved : ${savedDslFile}`));
629
- }
630
-
631
- if (!opts.skipTasks) {
632
- const taskGen = new TaskGenerator(specProvider);
633
- let tasksToSave = initialTasks;
634
-
635
- // If combined call didn't produce tasks (parse failed), retry as a separate call
636
- if (tasksToSave.length === 0) {
637
- console.log(chalk.blue(`\n Generating tasks (separate call)...`));
638
- try {
639
- tasksToSave = await taskGen.generateTasks(finalSpec, context);
640
- } catch (err) {
641
- console.log(chalk.yellow(` ⚠ Task generation failed: ${(err as Error).message}`));
642
- }
643
- }
644
-
645
- if (tasksToSave.length > 0) {
646
- const sorted = taskGen.sortByLayer(tasksToSave);
647
- const tasksFile = await taskGen.saveTasks(sorted, specFile);
648
- printTasks(sorted);
649
- console.log(chalk.green(` ✔ Tasks saved: ${tasksFile}`));
650
- } else {
651
- console.log(chalk.yellow(" ⚠ No tasks generated — code generation will use fallback file planning."));
652
- }
653
- }
654
-
655
- // ── Step 6: Code Generation ───────────────────────────────────────────────
656
- console.log(chalk.blue(`\n[6/6] Code generation (mode: ${codegenMode})...`));
657
- const codegenProvider =
658
- codegenProviderName === specProviderName && codegenApiKey === specApiKey
659
- ? specProvider
660
- : createProvider(codegenProviderName, codegenApiKey, codegenModelName);
661
-
662
- // ── TDD: generate failing tests BEFORE implementation ────────────────────
663
- let generatedTestFiles: string[] = [];
664
- if (opts.tdd && extractedDsl) {
665
- console.log(chalk.cyan("\n[TDD] Generating pre-implementation tests (will fail until code is written)..."));
666
- const testGen = new TestGenerator(codegenProvider);
667
- generatedTestFiles = await testGen.generateTdd(extractedDsl, workingDir);
668
- }
669
-
670
- runLogger.stageStart("codegen", { mode: codegenMode, provider: codegenProviderName, model: codegenModelName });
671
- const codegen = new CodeGenerator(codegenProvider, codegenMode);
672
- const generatedFiles = await codegen.generateCode(specFile, workingDir, context, {
673
- auto: opts.auto,
674
- resume: opts.resume,
675
- dslFilePath: savedDslFile ?? undefined,
676
- repoType: detectedRepoType,
677
- });
678
- runLogger.stageEnd("codegen", { filesGenerated: generatedFiles.length });
679
-
680
- // ── Step 7: Test Skeleton Generation (skipped in TDD mode — tests already written) ──
681
- if (opts.tdd) {
682
- console.log(chalk.gray("\n[7/9] TDD mode — test files already written pre-implementation."));
683
- } else if (opts.skipTests) {
684
- console.log(chalk.gray("\n[7/9] Skipping test generation (--skip-tests)."));
685
- } else if (!extractedDsl) {
686
- console.log(chalk.gray("\n[7/9] Skipping test generation (no DSL available)."));
687
- } else {
688
- console.log(chalk.blue(`\n[7/9] Test skeleton generation...`));
689
- runLogger.stageStart("test_gen");
690
- const testGen = new TestGenerator(codegenProvider);
691
- generatedTestFiles = await testGen.generate(extractedDsl, workingDir);
692
- runLogger.stageEnd("test_gen", { filesGenerated: generatedTestFiles.length });
693
- }
694
-
695
- // ── Step 8: Error Feedback Loop ───────────────────────────────────────────
696
- // In TDD mode, the error feedback loop is the primary driver:
697
- // it runs tests, collects failures, and fixes implementation until tests pass.
698
- let compilePassed = false;
699
- if (opts.skipErrorFeedback) {
700
- console.log(chalk.gray("[8/9] Skipping error feedback (--skip-error-feedback)."));
701
- compilePassed = true; // treat skip as neutral pass for self-eval
702
- } else {
703
- if (opts.tdd) {
704
- console.log(chalk.cyan("[8/9] TDD mode — error feedback loop driving implementation to pass tests..."));
705
- }
706
- runLogger.stageStart("error_feedback");
707
- compilePassed = await runErrorFeedback(codegenProvider, workingDir, extractedDsl, {
708
- maxCycles: opts.tdd ? 3 : 2, // TDD gets one extra cycle
709
- });
710
- runLogger.stageEnd("error_feedback");
711
- }
712
-
713
- // ── Step 9: Code Review ───────────────────────────────────────────────────
714
- let reviewResult = "";
715
- if (!opts.skipReview) {
716
- console.log(chalk.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
717
- runLogger.stageStart("review");
718
- const reviewer = new CodeReviewer(specProvider, currentDir);
719
- const savedSpec = await fs.readFile(specFile, "utf-8");
720
-
721
- if (codegenMode === "api" && generatedFiles.length > 0) {
722
- // api mode: review the generated files directly (no git diff available)
723
- reviewResult = await reviewer.reviewFiles(savedSpec, generatedFiles, workingDir, specFile);
724
- } else {
725
- // claude-code / plan mode: review via git diff
726
- const originalDir = process.cwd();
727
- try {
728
- process.chdir(workingDir);
729
- reviewResult = await reviewer.reviewCode(savedSpec, specFile);
730
- } finally {
731
- process.chdir(originalDir);
732
- }
733
- }
734
- runLogger.stageEnd("review");
735
-
736
- // Knowledge Memory: extract lessons from review and append to constitution §9
737
- await accumulateReviewKnowledge(specProvider, currentDir, reviewResult);
738
- }
739
-
740
- // ── Loop 2: Review → DSL Structural Feedback ─────────────────────────────
741
- // Runs only in interactive mode when a review was produced.
742
- // Classifies Pass 1 (architecture) findings as design-level vs implementation-
743
- // level issues. Design issues belong in the Spec/DSL — not just in §9.
744
- // If found: offer to amend the spec → re-extract DSL → overwrite saved DSL file.
745
- // The user can then run `ai-spec update --codegen` to regenerate affected files.
746
- if (reviewResult && !opts.skipReview && !opts.auto && extractedDsl && savedDslFile) {
747
- const structuralFindings = extractStructuralFindings(reviewResult);
748
-
749
- if (structuralFindings.length > 0) {
750
- printStructuralFindings(structuralFindings);
751
- runLogger.stageStart("review_dsl_feedback", { findingCount: structuralFindings.length, categories: structuralFindings.map((f) => f.category) });
752
-
753
- const savedSpecContent = await fs.readFile(specFile, "utf-8");
754
-
755
- const patchChoice = await select({
756
- message: "These are design issues in the Spec/DSL. How would you like to handle them?",
757
- choices: [
758
- { name: "🔧 Amend spec + update DSL (AI fixes the design issues, no regen yet)", value: "amend" },
759
- { name: "📝 Note in §9 only (already done — no DSL change)", value: "note" },
760
- { name: "⏭ Skip", value: "skip" },
761
- ],
762
- });
763
-
764
- if (patchChoice === "amend") {
765
- console.log(chalk.blue(" Amending spec to address structural findings..."));
766
- try {
767
- const amendedSpec = await specProvider.generate(
768
- buildStructuralAmendmentPrompt(savedSpecContent, structuralFindings),
769
- "You are a Senior Tech Lead doing a targeted spec correction. Output only the complete revised Markdown spec."
770
- );
771
-
772
- // Snapshot spec + DSL before overwriting so `ai-spec restore <runId>` works
773
- await runSnapshot.snapshotFile(specFile);
774
- if (savedDslFile) await runSnapshot.snapshotFile(savedDslFile);
775
-
776
- // Overwrite the saved spec file with the amendment
777
- await fs.writeFile(specFile, amendedSpec, "utf-8");
778
- console.log(chalk.green(` ✔ Spec updated: ${specFile}`));
779
-
780
- // Re-extract DSL from the amended spec
781
- console.log(chalk.blue(" Re-extracting DSL from amended spec..."));
782
- const isFrontend3 = isFrontendDeps(context.dependencies);
783
- const amendExtractor = new DslExtractor(specProvider);
784
- const amendedDsl = await amendExtractor.extract(amendedSpec, { auto: true, isFrontend: isFrontend3 });
785
- if (amendedDsl) {
786
- // Overwrite saved DSL file
787
- const dslWriter = new DslExtractor(specProvider);
788
- const newDslPath = await dslWriter.saveDsl(amendedDsl, specFile);
789
- extractedDsl = amendedDsl;
790
- console.log(chalk.green(` ✔ DSL updated: ${newDslPath}`));
791
- console.log(chalk.cyan(
792
- `\n Next step: run ${chalk.white("ai-spec update --codegen")} to regenerate files affected by the DSL change.`
793
- ));
794
- runLogger.stageEnd("review_dsl_feedback", {
795
- action: "amended",
796
- endpoints: amendedDsl.endpoints.length,
797
- models: amendedDsl.models.length,
798
- });
799
- } else {
800
- console.log(chalk.yellow(" ⚠ DSL re-extraction failed — spec was updated but DSL file unchanged."));
801
- runLogger.stageEnd("review_dsl_feedback", { action: "amended_spec_only" });
802
- }
803
- } catch (err) {
804
- console.log(chalk.yellow(` ⚠ Spec amendment failed: ${(err as Error).message}`));
805
- runLogger.stageEnd("review_dsl_feedback", { action: "amendment_error", error: (err as Error).message });
806
- }
807
- } else {
808
- runLogger.stageEnd("review_dsl_feedback", { action: patchChoice });
809
- if (patchChoice === "note") {
810
- console.log(chalk.gray(" Structural findings retained in §9. DSL unchanged."));
811
- }
812
- }
813
- }
814
- }
815
-
816
- // ── Step 10: Harness Self-Evaluation ──────────────────────────────────────
817
- // Zero AI calls — deterministic scoring from file-system state + review text.
818
- // Records harnessScore + promptHash in RunLog for cross-run trend analysis.
819
- runLogger.stageStart("self_eval");
820
- const selfEvalResult = runSelfEval({
821
- dsl: extractedDsl,
822
- generatedFiles,
823
- compilePassed,
824
- reviewText: reviewResult,
825
- promptHash,
826
- logger: runLogger,
827
- });
828
- printSelfEval(selfEvalResult);
829
-
830
- // ── Done ──────────────────────────────────────────────────────────────────
831
- runLogger.finish();
832
- console.log(chalk.bold.green("\n✔ All done!"));
833
- console.log(chalk.gray(` Spec : ${specFile}`));
834
- if (savedDslFile) console.log(chalk.gray(` DSL : ${savedDslFile}`));
835
- if (generatedTestFiles.length > 0) {
836
- console.log(chalk.gray(` Tests : ${generatedTestFiles.length} skeleton file(s) generated`));
837
- }
838
- console.log(chalk.gray(` Working dir : ${workingDir}`));
839
- if (workingDir !== currentDir) {
840
- console.log(chalk.gray(` Run \`cd ${workingDir}\` to enter the worktree.`));
841
- }
842
- runLogger.printSummary();
843
- if (runSnapshot.fileCount > 0) {
844
- console.log(chalk.gray(` To undo changes: ai-spec restore ${runId}`));
845
- }
846
- });
847
-
848
- // ═══════════════════════════════════════════════════════════════════════════════
849
- // Command: review
850
- // ═══════════════════════════════════════════════════════════════════════════════
851
-
852
- program
853
- .command("review")
854
- .description("Run AI code review on current git diff against a spec")
855
- .argument("[specFile]", "Path to spec file (auto-detects latest in specs/ if omitted)")
856
- .option(
857
- "--provider <name>",
858
- `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
859
- undefined
860
- )
861
- .option("--model <name>", "Model name")
862
- .option("-k, --key <apiKey>", "API key")
863
- .action(async (specFile: string | undefined, opts) => {
864
- const currentDir = process.cwd();
865
- const config = await loadConfig(currentDir);
866
-
867
- const providerName = opts.provider || config.provider || "gemini";
868
- const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
869
- const apiKey = await resolveApiKey(providerName, opts.key);
870
-
871
- const provider = createProvider(providerName, apiKey, modelName);
872
- const reviewer = new CodeReviewer(provider, currentDir);
873
-
874
- let specContent = "";
875
- let resolvedSpecFile: string | undefined;
876
-
877
- if (specFile && (await fs.pathExists(specFile))) {
878
- specContent = await fs.readFile(specFile, "utf-8");
879
- resolvedSpecFile = specFile;
880
- console.log(chalk.gray(`Using spec: ${specFile}`));
881
- } else {
882
- // Auto-detect the latest spec in specs/
883
- const specsDir = path.join(currentDir, "specs");
884
- if (await fs.pathExists(specsDir)) {
885
- const files = (await fs.readdir(specsDir))
886
- .filter((f) => f.endsWith(".md"))
887
- .sort()
888
- .reverse();
889
- if (files.length > 0) {
890
- const latest = path.join(specsDir, files[0]);
891
- specContent = await fs.readFile(latest, "utf-8");
892
- resolvedSpecFile = latest;
893
- console.log(chalk.gray(`Auto-detected spec: specs/${files[0]}`));
894
- }
895
- }
896
- }
897
-
898
- if (!specContent) {
899
- console.log(chalk.yellow("No spec file found. Running review without spec context."));
900
- }
901
-
902
- await reviewer.reviewCode(specContent, resolvedSpecFile);
903
- await reviewer.printScoreTrend();
904
- });
905
-
906
- // ═══════════════════════════════════════════════════════════════════════════════
907
- // Command: init — generate Project Constitution
908
- // ═══════════════════════════════════════════════════════════════════════════════
909
-
910
- program
911
- .command("init")
912
- .description(`Analyze codebase and generate Project Constitution (${CONSTITUTION_FILE})`)
913
- .option(
914
- "--provider <name>",
915
- `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
916
- undefined
917
- )
918
- .option("--model <name>", "Model name")
919
- .option("-k, --key <apiKey>", "API key")
920
- .option("--force", "Overwrite existing constitution")
921
- .option(
922
- "--global",
923
- `Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
924
- )
925
- .option("--consolidate", "Consolidate §9 accumulated lessons into §1–§8 core rules (prune & rebase)")
926
- .option("--dry-run", "Preview consolidation result without writing (use with --consolidate)")
927
- .action(async (opts) => {
928
- const currentDir = process.cwd();
929
- const config = await loadConfig(currentDir);
930
-
931
- const providerName = opts.provider || config.provider || "gemini";
932
- const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
933
- const apiKey = await resolveApiKey(providerName, opts.key);
934
- const provider = createProvider(providerName, apiKey, modelName);
935
-
936
- // ── Consolidate mode ─────────────────────────────────────────────────
937
- if (opts.consolidate) {
938
- const consolidator = new ConstitutionConsolidator(provider);
939
- try {
940
- const result = await consolidator.consolidate(currentDir, {
941
- dryRun: opts.dryRun,
942
- auto: opts.auto,
943
- });
944
- if (result.written) {
945
- console.log(chalk.blue("\n Summary:"));
946
- 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)})`));
947
- console.log(chalk.gray(` §9 : ${result.before.lessonCount} → ${result.after.lessonCount} lessons remaining`));
948
- if (result.backupPath) {
949
- console.log(chalk.gray(` Backup: ${path.basename(result.backupPath)}`));
950
- }
951
- }
952
- } catch (err) {
953
- console.error(chalk.red(` ✘ Consolidation failed: ${(err as Error).message}`));
954
- process.exit(1);
955
- }
956
- return;
957
- }
958
-
959
- // ── Global constitution mode ──────────────────────────────────────────
960
- if (opts.global) {
961
- const existing = await loadGlobalConstitution([currentDir]);
962
- if (existing && !opts.force) {
963
- console.log(chalk.yellow(`\n Global constitution already exists at: ${existing.source}`));
964
- console.log(chalk.gray(" Use --force to overwrite it."));
965
- return;
966
- }
967
-
968
- console.log(chalk.blue("\n─── Generating Global Constitution ──────────────"));
969
- console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
970
- console.log(chalk.gray(" Scanning repos in workspace..."));
971
-
972
- // Build per-repo summaries from sibling directories
973
- const loader = new ContextLoader(currentDir);
974
- const ctx = await loader.loadProjectContext();
975
- const summary = [
976
- `Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
977
- `Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`,
978
- ].join("\n");
979
-
980
- const prompt = buildGlobalConstitutionPrompt([{ name: path.basename(currentDir), summary }]);
981
- let globalConstitution: string;
982
- try {
983
- globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
984
- } catch (err) {
985
- console.error(chalk.red(" ✘ Failed to generate global constitution:"), err);
986
- process.exit(1);
987
- }
988
-
989
- const saved = await saveGlobalConstitution(globalConstitution, currentDir);
990
- console.log(chalk.green(`\n ✔ Global constitution saved: ${saved}`));
991
- console.log(chalk.gray(" This will be automatically merged into all project constitutions in this workspace."));
992
- console.log(chalk.gray(" Project-level rules always override global rules.\n"));
993
- console.log(chalk.bold(" Preview:"));
994
- console.log(chalk.gray(globalConstitution.split("\n").slice(0, 12).join("\n")));
995
- if (globalConstitution.split("\n").length > 12) {
996
- console.log(chalk.gray(` ... (${globalConstitution.split("\n").length} lines total)`));
997
- }
998
- return;
999
- }
1000
-
1001
- // ── Project constitution mode (default) ───────────────────────────────
1002
- const constitutionPath = path.join(currentDir, CONSTITUTION_FILE);
1003
-
1004
- if (!opts.force && (await fs.pathExists(constitutionPath))) {
1005
- console.log(chalk.yellow(`\n ${CONSTITUTION_FILE} already exists.`));
1006
- console.log(chalk.gray(" Use --force to overwrite it."));
1007
- console.log(chalk.gray(` Or edit it directly: ${constitutionPath}`));
1008
- return;
1009
- }
1010
-
1011
- console.log(chalk.blue("\n─── Generating Project Constitution ─────────────"));
1012
- console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
1013
- console.log(chalk.gray(" Analyzing codebase..."));
1014
-
1015
- const generator = new ConstitutionGenerator(provider);
1016
-
1017
- let constitution: string;
1018
- try {
1019
- constitution = await generator.generate(currentDir);
1020
- } catch (err) {
1021
- console.error(chalk.red(" ✘ Failed to generate constitution:"), err);
1022
- process.exit(1);
1023
- }
1024
-
1025
- const saved = await generator.saveConstitution(currentDir, constitution);
1026
-
1027
- // Note if a global constitution will also be applied
1028
- const globalResult = await loadGlobalConstitution([path.dirname(currentDir)]);
1029
- if (globalResult) {
1030
- console.log(chalk.cyan(`\n ℹ Global constitution detected: ${globalResult.source}`));
1031
- console.log(chalk.gray(" It will be merged with this project constitution at runtime."));
1032
- console.log(chalk.gray(" Project rules take priority over global rules."));
1033
- }
1034
-
1035
- console.log(chalk.green(`\n ✔ Constitution saved: ${saved}`));
1036
- console.log(chalk.gray(" This file will be automatically used in all future `ai-spec create` runs."));
1037
- console.log(chalk.gray(" Edit it to add custom rules or red lines for your project.\n"));
1038
- console.log(chalk.bold(" Preview:"));
1039
- console.log(chalk.gray(constitution.split("\n").slice(0, 15).join("\n")));
1040
- if (constitution.split("\n").length > 15) {
1041
- console.log(chalk.gray(` ... (${constitution.split("\n").length} lines total)`));
1042
- }
1043
- });
1044
-
1045
- // ═══════════════════════════════════════════════════════════════════════════════
1046
- // Command: config
1047
- // ═══════════════════════════════════════════════════════════════════════════════
1048
-
1049
- program
1050
- .command("config")
1051
- .description(`Set default configuration for this project (saved to ${CONFIG_FILE})`)
1052
- .option("--provider <name>", "Default AI provider for spec generation")
1053
- .option("--model <name>", "Default model for spec generation")
1054
- .option(
1055
- "--codegen <mode>",
1056
- "Default code generation mode (claude-code|api|plan)"
1057
- )
1058
- .option("--codegen-provider <name>", "Default provider for code generation")
1059
- .option("--codegen-model <name>", "Default model for code generation")
1060
- .option("--min-spec-score <score>", "Minimum overall spec score (1-10) to pass Approval Gate (0 = disabled)")
1061
- .option("--show", "Print current configuration")
1062
- .option("--reset", "Reset configuration to empty")
1063
- .option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json")
1064
- .option("--clear-key <provider>", "Delete saved API key for a specific provider")
1065
- .option("--list-keys", "Show which providers have a saved key")
1066
- .action(async (opts) => {
1067
- const currentDir = process.cwd();
1068
- const configPath = path.join(currentDir, CONFIG_FILE);
1069
-
1070
- if (opts.clearKeys) {
1071
- await clearAllKeys();
1072
- console.log(chalk.green(`✔ All saved API keys cleared.`));
1073
- return;
1074
- }
1075
-
1076
- if (opts.clearKey) {
1077
- await clearKey(opts.clearKey);
1078
- console.log(chalk.green(`✔ Saved key for "${opts.clearKey}" removed.`));
1079
- return;
1080
- }
1081
-
1082
- if (opts.listKeys) {
1083
- const store: Record<string, string> = await fs.readJson(KEY_STORE_FILE).catch(() => ({}));
1084
- const providers = Object.keys(store);
1085
- if (providers.length === 0) {
1086
- console.log(chalk.gray("No saved API keys."));
1087
- } else {
1088
- console.log(chalk.bold("Saved API keys:"));
1089
- for (const p of providers) {
1090
- const k = store[p];
1091
- console.log(chalk.gray(` ${p}: ${k.slice(0, 6)}...${k.slice(-4)}`));
1092
- }
1093
- console.log(chalk.gray(`\nFile: ${KEY_STORE_FILE}`));
1094
- }
1095
- return;
1096
- }
1097
-
1098
- if (opts.reset) {
1099
- await fs.writeJson(configPath, {}, { spaces: 2 });
1100
- console.log(chalk.green(`✔ Config reset: ${configPath}`));
1101
- return;
1102
- }
1103
-
1104
- const existing: AiSpecConfig = await loadConfig(currentDir);
1105
-
1106
- if (opts.show) {
1107
- if (Object.keys(existing).length === 0) {
1108
- console.log(chalk.gray("No config file found. Using built-in defaults."));
1109
- } else {
1110
- console.log(chalk.bold(`${configPath}:`));
1111
- console.log(JSON.stringify(existing, null, 2));
1112
- }
1113
- return;
1114
- }
1115
-
1116
- const updated: AiSpecConfig = { ...existing };
1117
- if (opts.provider) updated.provider = opts.provider;
1118
- if (opts.model) updated.model = opts.model;
1119
- if (opts.codegen) updated.codegen = opts.codegen as CodeGenMode;
1120
- if (opts.codegenProvider) updated.codegenProvider = opts.codegenProvider;
1121
- if (opts.codegenModel) updated.codegenModel = opts.codegenModel;
1122
- if (opts.minSpecScore !== undefined) {
1123
- const score = parseInt(opts.minSpecScore, 10);
1124
- if (isNaN(score) || score < 0 || score > 10) {
1125
- console.error(chalk.red(" --min-spec-score must be a number between 0 and 10"));
1126
- process.exit(1);
1127
- }
1128
- updated.minSpecScore = score;
1129
- }
1130
-
1131
- await fs.writeJson(configPath, updated, { spaces: 2 });
1132
- console.log(chalk.green(`✔ Config saved to ${configPath}`));
1133
- console.log(JSON.stringify(updated, null, 2));
1134
- });
1135
-
1136
- // ═══════════════════════════════════════════════════════════════════════════════
1137
- // Command: model — interactive model switcher
1138
- // ═══════════════════════════════════════════════════════════════════════════════
1139
-
1140
- program
1141
- .command("model")
1142
- .description("Interactively switch the active AI provider/model and save to .ai-spec.json")
1143
- .option("--list", "List all available providers and models")
1144
- .action(async (opts) => {
1145
- const currentDir = process.cwd();
1146
- const configPath = path.join(currentDir, CONFIG_FILE);
1147
-
1148
- // ── --list: just print the catalog ────────────────────────────────────────
1149
- if (opts.list) {
1150
- console.log(chalk.bold("\nAvailable providers & models:\n"));
1151
- for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
1152
- console.log(
1153
- ` ${chalk.bold.cyan(key.padEnd(10))} ${chalk.white(meta.displayName)}`
1154
- );
1155
- console.log(chalk.gray(` ${meta.description}`));
1156
- console.log(
1157
- chalk.gray(
1158
- ` env: ${meta.envKey} | models: ${meta.models.join(", ")}`
1159
- )
1160
- );
1161
- console.log();
1162
- }
1163
- return;
1164
- }
1165
-
1166
- const existing: AiSpecConfig = await loadConfig(currentDir);
1167
-
1168
- console.log(chalk.blue("\n─── Model Switcher ─────────────────────────────"));
1169
- if (Object.keys(existing).length > 0) {
1170
- console.log(
1171
- chalk.gray(
1172
- ` Current: spec=${existing.provider ?? "gemini"}/${existing.model ?? DEFAULT_MODELS[existing.provider ?? "gemini"]}` +
1173
- (existing.codegenProvider
1174
- ? ` codegen=${existing.codegenProvider}/${existing.codegenModel ?? ""}`
1175
- : "")
1176
- )
1177
- );
1178
- }
1179
- console.log();
1180
-
1181
- // ── What to configure ─────────────────────────────────────────────────────
1182
- const target = await select({
1183
- message: "Configure model for:",
1184
- choices: [
1185
- { name: "Spec generation (used for spec writing & refinement)", value: "spec" },
1186
- { name: "Code generation (used when --codegen api is active)", value: "codegen" },
1187
- { name: "Both (same provider/model for all tasks)", value: "both" },
1188
- ],
1189
- });
1190
-
1191
- // ── Helper: pick provider + model ─────────────────────────────────────────
1192
- async function pickProviderAndModel(label: string): Promise<{ provider: string; model: string }> {
1193
- const providerKey = await select({
1194
- message: `${label} — select provider:`,
1195
- choices: Object.entries(PROVIDER_CATALOG).map(([key, meta]) => ({
1196
- name: `${meta.displayName.padEnd(22)} ${chalk.gray(meta.description)}`,
1197
- value: key,
1198
- short: meta.displayName,
1199
- })),
1200
- });
1201
-
1202
- const meta = PROVIDER_CATALOG[providerKey];
1203
- const modelChoices = [
1204
- ...meta.models.map((m) => ({ name: m, value: m })),
1205
- { name: chalk.italic("✎ Enter custom model name..."), value: "__custom__" },
1206
- ];
1207
-
1208
- let chosenModel = await select({
1209
- message: `${label} — select model (${meta.displayName}):`,
1210
- choices: modelChoices,
1211
- });
1212
-
1213
- if (chosenModel === "__custom__") {
1214
- chosenModel = await input({
1215
- message: "Enter model name:",
1216
- validate: (v) => v.trim().length > 0 || "Model name cannot be empty",
1217
- });
1218
- }
1219
-
1220
- return { provider: providerKey, model: chosenModel };
1221
- }
1222
-
1223
- // ── Run picker(s) ─────────────────────────────────────────────────────────
1224
- const updated: AiSpecConfig = { ...existing };
1225
-
1226
- if (target === "spec" || target === "both") {
1227
- const { provider, model } = await pickProviderAndModel("Spec");
1228
- updated.provider = provider;
1229
- updated.model = model;
1230
- }
1231
-
1232
- if (target === "codegen" || target === "both") {
1233
- if (target === "both") {
1234
- updated.codegenProvider = updated.provider;
1235
- updated.codegenModel = updated.model;
1236
- } else {
1237
- const { provider, model } = await pickProviderAndModel("Codegen");
1238
- updated.codegenProvider = provider;
1239
- updated.codegenModel = model;
1240
- }
1241
-
1242
- // claude-code 模式只支持 Claude provider。
1243
- // 如果选了非 claude provider,自动把 codegen 模式改为 api。
1244
- const effectiveCodegenProvider = updated.codegenProvider ?? updated.provider ?? "gemini";
1245
- if (effectiveCodegenProvider !== "claude") {
1246
- if (!updated.codegen || updated.codegen === "claude-code") {
1247
- updated.codegen = "api";
1248
- console.log(
1249
- chalk.yellow(
1250
- `\n ⚠ provider "${effectiveCodegenProvider}" 不支持 "claude-code" 模式。`
1251
- )
1252
- );
1253
- console.log(chalk.gray(` 已自动将 codegen 模式设为 "api"。`));
1254
- }
1255
- }
1256
- }
1257
-
1258
- // ── Confirm & save ────────────────────────────────────────────────────────
1259
- console.log(chalk.blue("\n Preview:"));
1260
- console.log(chalk.gray(` spec → ${updated.provider}/${updated.model}`));
1261
- if (updated.codegenProvider) {
1262
- console.log(
1263
- chalk.gray(
1264
- ` codegen → ${updated.codegenProvider}/${updated.codegenModel} (mode: ${updated.codegen ?? "claude-code"})`
1265
- )
1266
- );
1267
- }
1268
-
1269
- const ok = await confirm({ message: "Save to .ai-spec.json?", default: true });
1270
- if (!ok) {
1271
- console.log(chalk.gray(" Cancelled."));
1272
- return;
1273
- }
1274
-
1275
- await fs.writeJson(configPath, updated, { spaces: 2 });
1276
- console.log(chalk.green(`\n ✔ Saved to ${configPath}`));
1277
-
1278
- // Remind about env var if not set
1279
- const providerToCheck = updated.provider ?? "gemini";
1280
- const envKey = ENV_KEY_MAP[providerToCheck];
1281
- if (envKey && !process.env[envKey]) {
1282
- console.log(
1283
- chalk.yellow(
1284
- ` ⚠ Remember to set ${envKey} in your environment or .env file.`
1285
- )
1286
- );
1287
- }
1288
- });
1289
-
1290
- // ═══════════════════════════════════════════════════════════════════════════════
1291
- // Multi-Repo Pipeline
1292
- // ═══════════════════════════════════════════════════════════════════════════════
1293
-
1294
- /**
1295
- * Run a single repo through the full spec→dsl→worktree→codegen→tests→review pipeline.
1296
- * Returns the extracted DSL (or null) for contract bridging.
1297
- */
1298
- async function runSingleRepoPipelineInWorkspace(opts: {
1299
- idea: string;
1300
- specProvider: ReturnType<typeof createProvider>;
1301
- specProviderName: string;
1302
- specModelName: string;
1303
- codegenProvider: ReturnType<typeof createProvider>;
1304
- codegenMode: CodeGenMode;
1305
- repoAbsPath: string;
1306
- repoName: string;
1307
- cliOpts: Record<string, unknown>;
1308
- contractContextSection?: string;
1309
- }): Promise<{ dsl: SpecDSL | null; specFile: string | null }> {
1310
- const {
1311
- idea,
1312
- specProvider,
1313
- specProviderName,
1314
- specModelName,
1315
- codegenProvider,
1316
- codegenMode,
1317
- repoAbsPath,
1318
- repoName,
1319
- cliOpts,
1320
- contractContextSection,
1321
- } = opts;
1322
-
1323
- console.log(chalk.blue(`\n [${repoName}] Loading project context...`));
1324
- const loader = new ContextLoader(repoAbsPath);
1325
- let context = await loader.loadProjectContext();
1326
-
1327
- // Detect repo language so the correct codegen system prompt is used
1328
- const { type: detectedRepoType } = await detectRepoType(repoAbsPath);
1329
-
1330
- console.log(chalk.gray(` Tech stack: ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
1331
- console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
1332
- if (context.constitution && context.constitution.length > 6000) {
1333
- console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
1334
- }
1335
-
1336
- if (!context.constitution) {
1337
- console.log(chalk.yellow(` Constitution: not found — auto-generating...`));
1338
- try {
1339
- const constitutionGen = new ConstitutionGenerator(specProvider);
1340
- const constitutionContent = await constitutionGen.generate(repoAbsPath);
1341
- await constitutionGen.saveConstitution(repoAbsPath, constitutionContent);
1342
- context.constitution = constitutionContent;
1343
- console.log(chalk.green(` Constitution: generated`));
1344
- } catch (err) {
1345
- console.log(chalk.yellow(` Constitution: auto-generation failed (${(err as Error).message}), continuing.`));
1346
- }
1347
- } else {
1348
- console.log(chalk.green(` Constitution: found`));
1349
- }
1350
-
1351
- // Build the spec idea: inject contract context if available
1352
- let fullIdea = idea;
1353
- if (contractContextSection) {
1354
- fullIdea = `${idea}\n\n${contractContextSection}`;
1355
- }
1356
-
1357
- console.log(chalk.blue(` [${repoName}] Generating spec...`));
1358
- let finalSpec: string;
1359
- try {
1360
- const result = await generateSpecWithTasks(specProvider, fullIdea, context);
1361
- finalSpec = result.spec;
1362
- console.log(chalk.green(` Spec generated.`));
1363
- } catch (err) {
1364
- console.error(chalk.red(` Spec generation failed: ${(err as Error).message}`));
1365
- return { dsl: null, specFile: null };
1366
- }
1367
-
1368
- // DSL Extraction
1369
- let extractedDsl: SpecDSL | null = null;
1370
- if (!cliOpts.skipDsl) {
1371
- console.log(chalk.blue(` [${repoName}] Extracting DSL...`));
1372
- try {
1373
- const dslExtractor = new DslExtractor(specProvider);
1374
- const repoIsFrontend = isFrontendDeps(context.dependencies);
1375
- extractedDsl = await dslExtractor.extract(finalSpec, { auto: true, isFrontend: repoIsFrontend });
1376
- if (extractedDsl) {
1377
- console.log(chalk.green(` DSL extracted.`));
1378
- }
1379
- } catch (err) {
1380
- console.log(chalk.yellow(` DSL extraction failed: ${(err as Error).message}`));
1381
- }
1382
- }
1383
-
1384
- // Git Worktree — auto-skip for frontend repos
1385
- const isFrontendRepo = isFrontendDeps(context.dependencies ?? []);
1386
- const skipWorktreeForRepo = cliOpts.worktree
1387
- ? false
1388
- : cliOpts.skipWorktree || isFrontendRepo;
1389
-
1390
- let workingDir = repoAbsPath;
1391
- if (!skipWorktreeForRepo) {
1392
- console.log(chalk.blue(` [${repoName}] Setting up git worktree...`));
1393
- try {
1394
- const worktreeManager = new GitWorktreeManager(repoAbsPath);
1395
- const worktreePath = await worktreeManager.createWorktree(idea);
1396
- if (worktreePath) workingDir = worktreePath;
1397
- } catch (err) {
1398
- console.log(chalk.yellow(` Worktree setup failed: ${(err as Error).message}. Using main branch.`));
1399
- }
1400
- } else {
1401
- console.log(chalk.gray(` [${repoName}] Skipping worktree${isFrontendRepo ? " (frontend repo)" : ""}.`));
1402
- }
1403
-
1404
- // Save Spec
1405
- const specsDir = path.join(workingDir, "specs");
1406
- await fs.ensureDir(specsDir);
1407
- const featureSlug = slugify(idea);
1408
- const { filePath: specFile } = await nextVersionPath(specsDir, featureSlug);
1409
- await fs.writeFile(specFile, finalSpec, "utf-8");
1410
- console.log(chalk.green(` Spec saved: ${path.relative(repoAbsPath, specFile)}`));
1411
-
1412
- let savedDslFile: string | null = null;
1413
- if (extractedDsl) {
1414
- const dslExtractorForSave = new DslExtractor(specProvider);
1415
- savedDslFile = await dslExtractorForSave.saveDsl(extractedDsl, specFile);
1416
- console.log(chalk.green(` DSL saved: ${path.relative(repoAbsPath, savedDslFile)}`));
1417
- }
1418
-
1419
- // Code Generation
1420
- console.log(chalk.blue(` [${repoName}] Running code generation (mode: ${codegenMode})...`));
1421
- try {
1422
- const codegen = new CodeGenerator(codegenProvider, codegenMode);
1423
- await codegen.generateCode(specFile, workingDir, context, {
1424
- auto: true,
1425
- dslFilePath: savedDslFile ?? undefined,
1426
- repoType: detectedRepoType,
1427
- });
1428
- console.log(chalk.green(` Code generation complete.`));
1429
- } catch (err) {
1430
- console.log(chalk.yellow(` Code generation failed: ${(err as Error).message}`));
1431
- }
1432
-
1433
- // Test Generation
1434
- if (!cliOpts.skipTests && extractedDsl) {
1435
- console.log(chalk.blue(` [${repoName}] Generating test skeletons...`));
1436
- try {
1437
- const testGen = new TestGenerator(codegenProvider);
1438
- const testFiles = await testGen.generate(extractedDsl, workingDir);
1439
- console.log(chalk.green(` ${testFiles.length} test file(s) generated.`));
1440
- } catch (err) {
1441
- console.log(chalk.yellow(` Test generation failed: ${(err as Error).message}`));
1442
- }
1443
- }
1444
-
1445
- // Error Feedback
1446
- if (!cliOpts.skipErrorFeedback) {
1447
- try {
1448
- await runErrorFeedback(codegenProvider, workingDir, extractedDsl, { maxCycles: 1 });
1449
- } catch (err) {
1450
- console.log(chalk.yellow(` Error feedback failed: ${(err as Error).message}`));
1451
- }
1452
- }
1453
-
1454
- // Code Review
1455
- if (!cliOpts.skipReview) {
1456
- console.log(chalk.blue(` [${repoName}] Running code review...`));
1457
- try {
1458
- const reviewer = new CodeReviewer(specProvider);
1459
- // git diff must run in the repo's working directory (may be a worktree),
1460
- // not in wherever the CLI was invoked from.
1461
- const originalDir = process.cwd();
1462
- let reviewResult: string;
1463
- try {
1464
- process.chdir(workingDir);
1465
- reviewResult = await reviewer.reviewCode(finalSpec);
1466
- } finally {
1467
- process.chdir(originalDir);
1468
- }
1469
- await accumulateReviewKnowledge(specProvider, repoAbsPath, reviewResult);
1470
- console.log(chalk.green(` Code review complete.`));
1471
- } catch (err) {
1472
- console.log(chalk.yellow(` Code review failed: ${(err as Error).message}`));
1473
- }
1474
- }
1475
-
1476
- return { dsl: extractedDsl, specFile };
1477
- }
1478
-
1479
- /**
1480
- * Multi-repo pipeline: decompose → order repos → run each repo in order → bridge contracts.
1481
- */
1482
- type MultiRepoResult = {
1483
- repoName: string;
1484
- status: "success" | "failed" | "skipped";
1485
- specFile: string | null;
1486
- dsl: SpecDSL | null;
1487
- repoAbsPath: string;
1488
- role: string;
1489
- };
1490
-
1491
- async function runMultiRepoPipeline(
1492
- idea: string,
1493
- workspace: WorkspaceConfig,
1494
- opts: Record<string, unknown>,
1495
- currentDir: string,
1496
- config: AiSpecConfig
1497
- ): Promise<MultiRepoResult[]> {
1498
- // ── Resolve providers ──────────────────────────────────────────────────────
1499
- const specProviderName = (opts.provider as string) || config.provider || "gemini";
1500
- const specModelName = (opts.model as string) || config.model || DEFAULT_MODELS[specProviderName];
1501
- const specApiKey = await resolveApiKey(specProviderName, opts.key as string | undefined);
1502
- const specProvider = createProvider(specProviderName, specApiKey, specModelName);
1503
-
1504
- const codegenMode: CodeGenMode = ((opts.codegen as string) as CodeGenMode) || config.codegen || "claude-code";
1505
- const codegenProviderName = (opts.codegenProvider as string) || config.codegenProvider || specProviderName;
1506
- const codegenModelName = (opts.codegenModel as string) || config.codegenModel || DEFAULT_MODELS[codegenProviderName];
1507
- const codegenApiKey =
1508
- codegenProviderName === specProviderName
1509
- ? specApiKey
1510
- : await resolveApiKey(codegenProviderName, opts.codegenKey as string | undefined);
1511
- const codegenProvider =
1512
- codegenProviderName === specProviderName && codegenApiKey === specApiKey
1513
- ? specProvider
1514
- : createProvider(codegenProviderName, codegenApiKey, codegenModelName);
1515
-
1516
- printBanner({
1517
- specProvider: specProviderName,
1518
- specModel: specModelName,
1519
- codegenMode,
1520
- codegenProvider: codegenProviderName,
1521
- codegenModel: codegenModelName,
1522
- });
1523
-
1524
- const workspaceLoader = new WorkspaceLoader(currentDir);
1525
-
1526
- // ── Step 1: Load per-repo contexts ─────────────────────────────────────────
1527
- console.log(chalk.blue("\n[W1] Loading per-repo contexts..."));
1528
- const contexts = new Map<string, import("../core/context-loader").ProjectContext>();
1529
- const frontendContexts = new Map<string, import("../core/frontend-context-loader").FrontendContext>();
1530
-
1531
- for (const repo of workspace.repos) {
1532
- const repoAbsPath = workspaceLoader.resolveAbsPath(repo);
1533
- try {
1534
- const loader = new ContextLoader(repoAbsPath);
1535
- const ctx = await loader.loadProjectContext();
1536
- contexts.set(repo.name, ctx);
1537
-
1538
- // Load frontend context for frontend/mobile repos
1539
- if (repo.role === "frontend" || repo.role === "mobile") {
1540
- const fctx = await loadFrontendContext(repoAbsPath);
1541
- frontendContexts.set(repo.name, fctx);
1542
- console.log(chalk.gray(` ${repo.name}: ${fctx.framework} / ${fctx.httpClient} / hooks:${fctx.hookFiles.length} stores:${fctx.storeFiles.length}`));
1543
- } else {
1544
- console.log(chalk.gray(` ${repo.name}: ${ctx.techStack.join(", ") || "unknown"} (${ctx.dependencies.length} deps)`));
1545
- }
1546
- } catch (err) {
1547
- console.log(chalk.yellow(` ${repo.name}: context load failed — ${(err as Error).message}`));
1548
- }
1549
- }
1550
-
1551
- // ── Step 2: Decompose requirement ─────────────────────────────────────────
1552
- console.log(chalk.blue("\n[W2] Decomposing requirement across repos..."));
1553
- const decomposer = new RequirementDecomposer(specProvider);
1554
- let decomposition: DecompositionResult;
1555
-
1556
- try {
1557
- decomposition = await decomposer.decompose(idea, workspace, contexts, frontendContexts);
1558
- console.log(chalk.green(` Summary: ${decomposition.summary}`));
1559
- console.log(chalk.gray(` Repos affected: ${decomposition.repos.map((r) => r.repoName).join(", ")}`));
1560
- if (decomposition.coordinationNotes) {
1561
- console.log(chalk.gray(` Coordination: ${decomposition.coordinationNotes}`));
1562
- }
1563
- } catch (err) {
1564
- console.error(chalk.red(` Decomposition failed: ${(err as Error).message}`));
1565
- console.log(chalk.yellow(" Falling back to running all repos independently."));
1566
- // Fallback: create basic requirements for all repos
1567
- decomposition = {
1568
- originalRequirement: idea,
1569
- summary: idea,
1570
- coordinationNotes: "",
1571
- repos: workspace.repos.map((repo) => ({
1572
- repoName: repo.name,
1573
- role: repo.role,
1574
- specIdea: idea,
1575
- isContractProvider: repo.role === "backend",
1576
- dependsOnRepos: repo.role !== "backend" ? workspace.repos.filter((r) => r.role === "backend").map((r) => r.name) : [],
1577
- uxDecisions: null,
1578
- })),
1579
- };
1580
- }
1581
-
1582
- // ── Step 3: Show decomposition preview + confirmation ─────────────────────
1583
- if (!opts.auto) {
1584
- console.log(chalk.cyan("\n[W3] Decomposition Preview:"));
1585
- console.log(chalk.cyan("─".repeat(52)));
1586
- for (const r of decomposition.repos) {
1587
- console.log(chalk.bold(` ${r.repoName} (${r.role})`));
1588
- console.log(chalk.gray(` ${r.specIdea.slice(0, 150)}${r.specIdea.length > 150 ? "..." : ""}`));
1589
- if (r.uxDecisions) {
1590
- const ux = r.uxDecisions;
1591
- const uxSummary = [
1592
- ux.throttleMs ? `throttle ${ux.throttleMs}ms` : "",
1593
- ux.debounceMs ? `debounce ${ux.debounceMs}ms` : "",
1594
- ux.optimisticUpdate ? "optimistic-update" : "",
1595
- ux.errorRollback ? "rollback" : "",
1596
- ]
1597
- .filter(Boolean)
1598
- .join(", ");
1599
- if (uxSummary) console.log(chalk.cyan(` UX: ${uxSummary}`));
1600
- }
1601
- if (r.dependsOnRepos.length > 0) {
1602
- console.log(chalk.gray(` Depends on: ${r.dependsOnRepos.join(", ")}`));
1603
- }
1604
- }
1605
- console.log(chalk.cyan("─".repeat(52)));
1606
-
1607
- const gate = await select({
1608
- message: "Proceed with multi-repo pipeline?",
1609
- choices: [
1610
- { name: "Proceed — run all repos", value: "proceed" },
1611
- { name: "Abort", value: "abort" },
1612
- ],
1613
- });
1614
-
1615
- if (gate === "abort") {
1616
- console.log(chalk.yellow(" Aborted."));
1617
- process.exit(0);
1618
- }
1619
- }
1620
-
1621
- // ── Step 4: Sort repos by dependency order ─────────────────────────────────
1622
- const sortedRepoRequirements = RequirementDecomposer.sortByDependency(decomposition.repos);
1623
-
1624
- // Contract accumulator: repoName → extracted DSL
1625
- const contractDsls = new Map<string, SpecDSL>();
1626
-
1627
- // ── Step 5: Run each repo's pipeline ──────────────────────────────────────
1628
- console.log(chalk.blue(`\n[W4] Running pipeline for ${sortedRepoRequirements.length} repo(s)...`));
1629
-
1630
- const results: MultiRepoResult[] = [];
1631
-
1632
- for (const repoReq of sortedRepoRequirements) {
1633
- // Find repo config in workspace
1634
- const repoConfig = workspace.repos.find((r) => r.name === repoReq.repoName);
1635
- if (!repoConfig) {
1636
- console.log(chalk.yellow(` Skipping ${repoReq.repoName} — not found in workspace config.`));
1637
- results.push({ repoName: repoReq.repoName, status: "skipped", specFile: null, dsl: null, repoAbsPath: "", role: repoReq.role });
1638
- continue;
1639
- }
1640
-
1641
- const repoAbsPath = workspaceLoader.resolveAbsPath(repoConfig);
1642
-
1643
- console.log(chalk.bold.blue(`\n ── ${repoReq.repoName} (${repoReq.role}) ──────────────────────`));
1644
-
1645
- // Build contract context from upstream repos
1646
- let contractContextSection: string | undefined;
1647
- if (repoReq.dependsOnRepos.length > 0) {
1648
- const contractParts: string[] = [];
1649
- for (const depName of repoReq.dependsOnRepos) {
1650
- const depDsl = contractDsls.get(depName);
1651
- if (depDsl) {
1652
- console.log(chalk.gray(` Using API contract from: ${depName}`));
1653
- const contract = buildFrontendApiContract(depDsl);
1654
- contractParts.push(buildContractContextSection(contract));
1655
- }
1656
- }
1657
- if (contractParts.length > 0) {
1658
- contractContextSection = contractParts.join("\n\n");
1659
- }
1660
- }
1661
-
1662
- // For frontend repos, also load frontend context and inject UX decisions
1663
- let specIdea = repoReq.specIdea;
1664
- if (
1665
- (repoReq.role === "frontend" || repoReq.role === "mobile") &&
1666
- repoReq.uxDecisions
1667
- ) {
1668
- const frontendCtx = await loadFrontendContext(repoAbsPath);
1669
- const frontendCtxSection = buildFrontendContextSection(frontendCtx);
1670
-
1671
- specIdea = buildFrontendSpecPrompt({
1672
- specIdea: repoReq.specIdea,
1673
- apiContractSection: contractContextSection,
1674
- uxDecisions: repoReq.uxDecisions,
1675
- frontendContext: frontendCtx,
1676
- });
1677
-
1678
- // contractContextSection is already injected via buildFrontendSpecPrompt
1679
- contractContextSection = undefined;
1680
-
1681
- console.log(chalk.gray(` Frontend context: ${frontendCtx.framework} / ${frontendCtx.httpClient} / ${frontendCtx.uiLibrary}`));
1682
- }
1683
-
1684
- try {
1685
- const { dsl, specFile } = await runSingleRepoPipelineInWorkspace({
1686
- idea: specIdea,
1687
- specProvider,
1688
- specProviderName,
1689
- specModelName,
1690
- codegenProvider,
1691
- codegenMode,
1692
- repoAbsPath,
1693
- repoName: repoReq.repoName,
1694
- cliOpts: opts,
1695
- contractContextSection,
1696
- });
1697
-
1698
- // Store DSL for downstream repos if this is a contract provider
1699
- if (repoReq.isContractProvider && dsl) {
1700
- contractDsls.set(repoReq.repoName, dsl);
1701
- console.log(chalk.green(` Contract stored for downstream repos.`));
1702
- }
1703
-
1704
- results.push({ repoName: repoReq.repoName, status: "success", specFile, dsl, repoAbsPath, role: repoReq.role });
1705
- console.log(chalk.green(` ✔ ${repoReq.repoName} complete`));
1706
- } catch (err) {
1707
- console.error(chalk.red(` ✘ ${repoReq.repoName} failed: ${(err as Error).message}`));
1708
- results.push({ repoName: repoReq.repoName, status: "failed", specFile: null, dsl: null, repoAbsPath, role: repoReq.role });
1709
- // Continue — don't abort other repos
1710
- }
1711
- }
1712
-
1713
- // ── Done ──────────────────────────────────────────────────────────────────
1714
- console.log(chalk.bold.green("\n✔ Multi-repo pipeline complete!"));
1715
- console.log(chalk.gray(` Workspace: ${workspace.name}`));
1716
- console.log(chalk.gray(` Requirement: ${idea}`));
1717
- console.log();
1718
- for (const r of results) {
1719
- const icon = r.status === "success" ? chalk.green("✔") : r.status === "failed" ? chalk.red("✘") : chalk.gray("−");
1720
- const specInfo = r.specFile ? chalk.gray(` → ${r.specFile}`) : "";
1721
- console.log(` ${icon} ${r.repoName} (${r.status})${specInfo}`);
1722
- }
1723
-
1724
- return results;
1725
- }
1726
-
1727
- // ═══════════════════════════════════════════════════════════════════════════════
1728
- // Command: workspace
1729
- // ═══════════════════════════════════════════════════════════════════════════════
1730
-
1731
- const workspaceCmd = program
1732
- .command("workspace")
1733
- .description("Manage multi-repo workspace configuration");
1734
-
1735
- // workspace init
1736
- workspaceCmd
1737
- .command("init")
1738
- .description(`Interactive workspace setup — creates ${WORKSPACE_CONFIG_FILE}`)
1739
- .action(async () => {
1740
- const currentDir = process.cwd();
1741
- const configPath = path.join(currentDir, WORKSPACE_CONFIG_FILE);
1742
-
1743
- if (await fs.pathExists(configPath)) {
1744
- const overwrite = await confirm({
1745
- message: `${WORKSPACE_CONFIG_FILE} already exists. Overwrite?`,
1746
- default: false,
1747
- });
1748
- if (!overwrite) {
1749
- console.log(chalk.gray(" Cancelled."));
1750
- return;
1751
- }
1752
- }
1753
-
1754
- console.log(chalk.blue("\n─── Workspace Setup ────────────────────────────"));
1755
-
1756
- const workspaceName = await input({
1757
- message: "Workspace name:",
1758
- validate: (v) => v.trim().length > 0 || "Name cannot be empty",
1759
- });
1760
-
1761
- const repos: RepoConfig[] = [];
1762
-
1763
- // ── Auto-scan option ──────────────────────────────────────────────────────
1764
- const useAutoScan = await confirm({
1765
- message: "Auto-scan sibling directories for repos?",
1766
- default: true,
1767
- });
1768
-
1769
- if (useAutoScan) {
1770
- const workspaceLoader = new WorkspaceLoader(currentDir);
1771
- const detected = await workspaceLoader.autoDetect();
1772
-
1773
- if (detected.length === 0) {
1774
- console.log(chalk.yellow(" No recognizable repos found in sibling directories."));
1775
- } else {
1776
- console.log(chalk.cyan("\n Detected repos:"));
1777
- for (const r of detected) {
1778
- console.log(chalk.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
1779
- }
1780
-
1781
- const keepAll = await confirm({
1782
- message: `Include all ${detected.length} detected repo(s)?`,
1783
- default: true,
1784
- });
1785
-
1786
- if (keepAll) {
1787
- repos.push(...detected);
1788
- } else {
1789
- // Let user cherry-pick
1790
- for (const r of detected) {
1791
- const keep = await confirm({
1792
- message: `Include "${r.name}" (${r.role}, ${r.type})?`,
1793
- default: true,
1794
- });
1795
- if (keep) repos.push(r);
1796
- }
1797
- }
1798
- console.log(chalk.green(` ✔ ${repos.length} repo(s) added from auto-scan.`));
1799
- }
1800
- }
1801
-
1802
- // ── Manual add (always offered after auto-scan) ───────────────────────────
1803
- const repoTypeChoices = [
1804
- { name: "node-express (Node.js/Express backend)", value: "node-express" },
1805
- { name: "node-koa (Node.js/Koa backend)", value: "node-koa" },
1806
- { name: "go (Go backend)", value: "go" },
1807
- { name: "python (Python backend)", value: "python" },
1808
- { name: "java (Java/Spring backend)", value: "java" },
1809
- { name: "rust (Rust backend)", value: "rust" },
1810
- { name: "php (PHP/Lumen/Laravel backend)", value: "php" },
1811
- { name: "react (React frontend)", value: "react" },
1812
- { name: "next (Next.js)", value: "next" },
1813
- { name: "vue (Vue frontend)", value: "vue" },
1814
- { name: "react-native (React Native mobile)", value: "react-native" },
1815
- { name: "unknown", value: "unknown" },
1816
- ];
1817
-
1818
- let addMore = await confirm({
1819
- message: repos.length > 0 ? "Manually add more repos?" : "Add repos manually?",
1820
- default: repos.length === 0,
1821
- });
1822
-
1823
- while (addMore) {
1824
- console.log(chalk.cyan(`\n Adding repo #${repos.length + 1}`));
1825
-
1826
- const repoName = await input({
1827
- message: "Repo name (e.g. api, web, app):",
1828
- validate: (v) => {
1829
- if (!v.trim()) return "Name cannot be empty";
1830
- if (repos.some((r) => r.name === v.trim())) return "Name already used";
1831
- return true;
1832
- },
1833
- });
1834
-
1835
- const repoPath = await input({
1836
- message: `Relative path to "${repoName}" from here (default: ./${repoName}):`,
1837
- default: `./${repoName}`,
1838
- });
1839
-
1840
- // Try to auto-detect type
1841
- const absPath = path.resolve(currentDir, repoPath);
1842
- let detectedType = "unknown";
1843
- let detectedRole = "shared";
1844
-
1845
- if (await fs.pathExists(absPath)) {
1846
- const { type, role } = await detectRepoType(absPath);
1847
- detectedType = type;
1848
- detectedRole = role;
1849
- console.log(chalk.gray(` Auto-detected: type=${type}, role=${role}`));
1850
- } else {
1851
- console.log(chalk.yellow(` Path "${absPath}" not found — type/role will be manual.`));
1852
- }
1853
-
1854
- const repoType = await select({
1855
- message: `Repo type for "${repoName}":`,
1856
- choices: repoTypeChoices,
1857
- default: detectedType,
1858
- });
1859
-
1860
- const repoRole = await select({
1861
- message: `Repo role for "${repoName}":`,
1862
- choices: [
1863
- { name: "backend", value: "backend" },
1864
- { name: "frontend", value: "frontend" },
1865
- { name: "mobile", value: "mobile" },
1866
- { name: "shared", value: "shared" },
1867
- ],
1868
- default: detectedRole,
1869
- });
1870
-
1871
- repos.push({
1872
- name: repoName,
1873
- path: repoPath,
1874
- type: repoType as RepoConfig["type"],
1875
- role: repoRole as RepoConfig["role"],
1876
- });
1877
-
1878
- console.log(chalk.green(` ✔ Added: ${repoName} (${repoRole}, ${repoType})`));
1879
-
1880
- addMore = await confirm({
1881
- message: "Add another repo?",
1882
- default: false,
1883
- });
1884
- }
1885
-
1886
- const workspaceConfig: WorkspaceConfig = { name: workspaceName, repos };
1887
-
1888
- // Show summary
1889
- console.log(chalk.cyan("\n Workspace summary:"));
1890
- console.log(chalk.gray(` Name: ${workspaceName}`));
1891
- for (const r of repos) {
1892
- console.log(chalk.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
1893
- }
1894
-
1895
- const ok = await confirm({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
1896
- if (!ok) {
1897
- console.log(chalk.gray(" Cancelled."));
1898
- return;
1899
- }
1900
-
1901
- const loader = new WorkspaceLoader(currentDir);
1902
- const saved = await loader.save(workspaceConfig);
1903
- console.log(chalk.green(`\n ✔ Workspace saved: ${saved}`));
1904
- console.log(chalk.gray(` Run \`ai-spec create "your feature"\` — workspace mode will activate automatically.`));
1905
- });
1906
-
1907
- // workspace status
1908
- workspaceCmd
1909
- .command("status")
1910
- .description("Show current workspace configuration")
1911
- .action(async () => {
1912
- const currentDir = process.cwd();
1913
- const loader = new WorkspaceLoader(currentDir);
1914
- const config = await loader.load();
1915
-
1916
- if (!config) {
1917
- console.log(chalk.yellow(`No ${WORKSPACE_CONFIG_FILE} found in ${currentDir}`));
1918
- console.log(chalk.gray(" Run `ai-spec workspace init` to create one."));
1919
- return;
1920
- }
1921
-
1922
- console.log(chalk.bold(`\nWorkspace: ${config.name}`));
1923
- console.log(chalk.gray(` Config: ${path.join(currentDir, WORKSPACE_CONFIG_FILE)}`));
1924
- console.log(chalk.gray(` Repos (${config.repos.length}):\n`));
1925
-
1926
- for (const repo of config.repos) {
1927
- const absPath = loader.resolveAbsPath(repo);
1928
- const exists = await fs.pathExists(absPath);
1929
- const status = exists ? chalk.green("found") : chalk.red("not found");
1930
-
1931
- console.log(
1932
- ` ${chalk.bold(repo.name.padEnd(12))} ${repo.role.padEnd(10)} ${repo.type.padEnd(16)} ${status}`
1933
- );
1934
- console.log(chalk.gray(` path: ${absPath}`));
1935
- if (repo.constitution) {
1936
- console.log(chalk.green(` constitution: found`));
1937
- }
1938
- }
1939
- });
1940
-
1941
- // ═══════════════════════════════════════════════════════════════════════════════
1942
- // Command: update — Incremental spec + code update pipeline
1943
- // ═══════════════════════════════════════════════════════════════════════════════
1944
-
1945
- program
1946
- .command("update")
1947
- .description("Update an existing spec with a change request, re-extract DSL, and identify affected files")
1948
- .argument("[change]", "Change description (prompted if omitted)")
1949
- .option("--provider <name>", `AI provider (${SUPPORTED_PROVIDERS.join("|")})`, undefined)
1950
- .option("--model <name>", "Model name")
1951
- .option("-k, --key <apiKey>", "API key")
1952
- .option("--spec <path>", "Path to the existing spec file (auto-detected if omitted)")
1953
- .option("--codegen", "Regenerate affected files automatically after updating spec")
1954
- .option("--codegen-provider <name>", "Provider for code generation")
1955
- .option("--codegen-model <name>", "Model for code generation")
1956
- .option("--codegen-key <key>", "API key for code generation")
1957
- .option("--skip-affected", "Skip identifying affected files")
1958
- .action(async (change: string | undefined, opts) => {
1959
- const currentDir = process.cwd();
1960
- const config = await loadConfig(currentDir);
1961
-
1962
- // ── Resolve change ────────────────────────────────────────────────────────
1963
- if (!change) {
1964
- change = await input({
1965
- message: "Describe the change you want to make:",
1966
- validate: (v) => v.trim().length > 0 || "Change description cannot be empty",
1967
- });
1968
- }
1969
-
1970
- // ── Resolve provider ──────────────────────────────────────────────────────
1971
- const providerName = opts.provider || config.provider || "gemini";
1972
- const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
1973
- const apiKey = await resolveApiKey(providerName, opts.key);
1974
- const provider = createProvider(providerName, apiKey, modelName);
1975
-
1976
- console.log(chalk.blue("\n─── ai-spec update ─────────────────────────────"));
1977
- console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
1978
-
1979
- // ── Run tracking (snapshot + log) ─────────────────────────────────────────
1980
- const updateRunId = generateRunId();
1981
- const updateSnapshot = new RunSnapshot(currentDir, updateRunId);
1982
- setActiveSnapshot(updateSnapshot);
1983
- const updateLogger = new RunLogger(currentDir, updateRunId, { provider: providerName, model: modelName });
1984
- setActiveLogger(updateLogger);
1985
- console.log(chalk.gray(` Run ID: ${updateRunId}`));
1986
-
1987
- // ── Find existing spec ────────────────────────────────────────────────────
1988
- let specPath: string | null = opts.spec ?? null;
1989
- if (!specPath) {
1990
- const specsDir = path.join(currentDir, "specs");
1991
- const latest = await SpecUpdater.findLatestSpec(specsDir);
1992
- if (!latest) {
1993
- console.error(chalk.red(" No spec files found in specs/. Run `ai-spec create` first or use --spec <path>."));
1994
- process.exit(1);
1995
- }
1996
- specPath = latest.filePath;
1997
- console.log(chalk.gray(` Using spec: ${path.relative(currentDir, specPath)} (v${latest.version})`));
1998
- }
1999
-
2000
- // ── Load context ──────────────────────────────────────────────────────────
2001
- console.log(chalk.gray(" Loading project context..."));
2002
- const loader = new ContextLoader(currentDir);
2003
- const context = await loader.loadProjectContext();
2004
- if (context.constitution && context.constitution.length > 6000) {
2005
- console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
2006
- }
2007
-
2008
- // ── Detect repo type ──────────────────────────────────────────────────────
2009
- const { detectRepoType: _detectRepoType } = await import("../core/workspace-loader");
2010
- const { type: repoType } = await _detectRepoType(currentDir);
2011
-
2012
- // ── Run update ────────────────────────────────────────────────────────────
2013
- const updater = new SpecUpdater(provider);
2014
- let result;
2015
- try {
2016
- result = await updater.update(change!, specPath, currentDir, context, {
2017
- skipAffectedFiles: opts.skipAffected,
2018
- repoType,
2019
- });
2020
- } catch (err) {
2021
- console.error(chalk.red(` Update failed: ${(err as Error).message}`));
2022
- process.exit(1);
2023
- }
2024
-
2025
- console.log(chalk.green(`\n ✔ Spec updated → v${result.newVersion}: ${path.relative(currentDir, result.newSpecPath)}`));
2026
- if (result.newDslPath) {
2027
- console.log(chalk.green(` ✔ DSL updated: ${path.relative(currentDir, result.newDslPath)}`));
2028
- }
2029
-
2030
- // ── Show affected files ───────────────────────────────────────────────────
2031
- if (result.affectedFiles.length > 0) {
2032
- console.log(chalk.cyan("\n Affected files:"));
2033
- for (const f of result.affectedFiles) {
2034
- const icon = f.action === "create" ? chalk.green("+") : chalk.yellow("~");
2035
- console.log(` ${icon} ${f.file}: ${chalk.gray(f.description)}`);
2036
- }
2037
- }
2038
-
2039
- // ── Optional: regenerate affected files ───────────────────────────────────
2040
- if (opts.codegen && result.affectedFiles.length > 0) {
2041
- const codegenProviderName = opts.codegenProvider || config.codegenProvider || providerName;
2042
- const codegenModelName = opts.codegenModel || config.codegenModel || DEFAULT_MODELS[codegenProviderName];
2043
- const codegenApiKey = opts.codegenKey ?? (codegenProviderName === providerName ? apiKey : await resolveApiKey(codegenProviderName, opts.codegenKey));
2044
- const codegenProvider = createProvider(codegenProviderName, codegenApiKey, codegenModelName);
2045
-
2046
- console.log(chalk.blue("\n Regenerating affected files..."));
2047
- const codeGenerator = new CodeGenerator(codegenProvider, "api");
2048
-
2049
- const specContent = await fs.readFile(result.newSpecPath, "utf-8");
2050
- const constitutionSection = context.constitution
2051
- ? `\n=== Project Constitution (MUST follow) ===\n${context.constitution}\n`
2052
- : "";
2053
- const dslSection = result.updatedDsl
2054
- ? `\n=== DSL Context ===\n${JSON.stringify(result.updatedDsl, null, 2).slice(0, 3000)}\n`
2055
- : "";
2056
-
2057
- updateLogger.stageStart("update_codegen");
2058
- for (const affected of result.affectedFiles) {
2059
- const fullPath = path.join(currentDir, affected.file);
2060
- let existing = "";
2061
- try { existing = await fs.readFile(fullPath, "utf-8"); } catch { /* new file */ }
2062
-
2063
- const codePrompt = `Apply this change to the file.
2064
-
2065
- Change: ${change}
2066
- File: ${affected.file}
2067
- Purpose: ${affected.description}
2068
-
2069
- === Feature Spec (updated) ===
2070
- ${specContent}
2071
- ${constitutionSection}${dslSection}
2072
- === ${existing ? "Current File (return the FULL updated content)" : "New File"} ===
2073
- ${existing || "Create from scratch."}`;
2074
-
2075
- process.stdout.write(` ${existing ? chalk.yellow("~") : chalk.green("+")} ${affected.file}... `);
2076
- try {
2077
- const { getCodeGenSystemPrompt: _getPrompt } = await import("../prompts/codegen.prompt");
2078
- const raw = await codegenProvider.generate(codePrompt, _getPrompt(repoType));
2079
- const content = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
2080
- await fs.ensureDir(path.dirname(fullPath));
2081
- // Snapshot original before overwrite so `ai-spec restore` can undo
2082
- await updateSnapshot.snapshotFile(fullPath);
2083
- await fs.writeFile(fullPath, content, "utf-8");
2084
- updateLogger.fileWritten(affected.file);
2085
- console.log(chalk.green("✔"));
2086
- } catch (err) {
2087
- updateLogger.stageFail("update_codegen", `${affected.file}: ${(err as Error).message}`);
2088
- console.log(chalk.red(`✘ ${(err as Error).message}`));
2089
- }
2090
- }
2091
- updateLogger.stageEnd("update_codegen", { filesUpdated: result.affectedFiles.length });
2092
-
2093
- // Knowledge Memory: run a lightweight review on the updated spec and accumulate lessons
2094
- const updatedSpecContent = await fs.readFile(result.newSpecPath, "utf-8").catch(() => "");
2095
- if (updatedSpecContent) {
2096
- const updateReviewer = new CodeReviewer(provider, currentDir);
2097
- const reviewResult = await updateReviewer.reviewCode(updatedSpecContent, result.newSpecPath).catch(() => "");
2098
- if (reviewResult && reviewResult !== "No changes") {
2099
- await accumulateReviewKnowledge(provider, currentDir, reviewResult);
2100
- }
2101
- }
2102
- }
2103
-
2104
- // ── Finish run tracking ────────────────────────────────────────────────────
2105
- updateLogger.finish();
2106
- updateLogger.printSummary();
2107
- if (updateSnapshot.fileCount > 0) {
2108
- console.log(chalk.gray(` To undo changes: ai-spec restore ${updateRunId}`));
2109
- }
2110
-
2111
- // ── Hints ─────────────────────────────────────────────────────────────────
2112
- if (!opts.codegen && result.affectedFiles.length > 0) {
2113
- console.log(chalk.blue("\n Next steps:"));
2114
- console.log(chalk.gray(` • Re-run with --codegen to regenerate affected files automatically`));
2115
- console.log(chalk.gray(` • Or update files manually based on the affected files list above`));
2116
- console.log(chalk.gray(` • Run \`ai-spec mock\` to refresh the mock server with the new DSL`));
2117
- }
2118
- });
2119
-
2120
- // ═══════════════════════════════════════════════════════════════════════════════
2121
- // Command: export — Export DSL to OpenAPI / other formats
2122
- // ═══════════════════════════════════════════════════════════════════════════════
2123
-
2124
- program
2125
- .command("export")
2126
- .description("Export the latest DSL to OpenAPI 3.1.0 (YAML or JSON)")
2127
- .option("--openapi", "Export as OpenAPI 3.1.0 (default behaviour)")
2128
- .option("--format <fmt>", "Output format: yaml | json (default: yaml)", "yaml")
2129
- .option("--output <path>", "Output file path (default: openapi.yaml)")
2130
- .option("--server <url>", "API server URL in the OpenAPI document (default: http://localhost:3000)")
2131
- .option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
2132
- .action(async (opts) => {
2133
- const currentDir = process.cwd();
2134
-
2135
- // ── Find DSL ──────────────────────────────────────────────────────────────
2136
- let dslPath: string | null = opts.dsl ?? null;
2137
- if (!dslPath) {
2138
- dslPath = await findLatestDslFile(currentDir);
2139
- if (!dslPath) {
2140
- console.error(chalk.red(" No .dsl.json file found. Run `ai-spec create` first or use --dsl <path>."));
2141
- process.exit(1);
2142
- }
2143
- console.log(chalk.gray(` Using DSL: ${path.relative(currentDir, dslPath)}`));
2144
- }
2145
-
2146
- let dsl: SpecDSL;
2147
- try {
2148
- dsl = await fs.readJson(dslPath);
2149
- } catch (err) {
2150
- console.error(chalk.red(` Failed to read DSL: ${(err as Error).message}`));
2151
- process.exit(1);
2152
- }
2153
-
2154
- // ── Export ────────────────────────────────────────────────────────────────
2155
- console.log(chalk.blue("\n─── ai-spec export ─────────────────────────────"));
2156
-
2157
- const format = (opts.format === "json" ? "json" : "yaml") as "yaml" | "json";
2158
- const serverUrl = opts.server || "http://localhost:3000";
2159
-
2160
- try {
2161
- const outputPath = await exportOpenApi(dsl, currentDir, {
2162
- format,
2163
- serverUrl,
2164
- outputPath: opts.output,
2165
- });
2166
- const rel = path.relative(currentDir, outputPath);
2167
- console.log(chalk.green(` ✔ OpenAPI ${format.toUpperCase()} exported: ${rel}`));
2168
- console.log(chalk.gray(` Feature : ${dsl.feature.title}`));
2169
- console.log(chalk.gray(` Endpoints: ${dsl.endpoints.length}`));
2170
- console.log(chalk.gray(` Models : ${dsl.models.length}`));
2171
- console.log(chalk.gray(` Server : ${serverUrl}`));
2172
- console.log(chalk.blue("\n Next steps:"));
2173
- console.log(chalk.gray(` • Import ${rel} into Postman / Insomnia / Swagger UI`));
2174
- console.log(chalk.gray(` • Use openapi-generator to generate client SDKs`));
2175
- } catch (err) {
2176
- console.error(chalk.red(` Export failed: ${(err as Error).message}`));
2177
- process.exit(1);
2178
- }
2179
- });
2180
-
2181
- // ═══════════════════════════════════════════════════════════════════════════════
2182
- // Command: mock — Generate mock server, proxy config, and MSW handlers from DSL
2183
- // ═══════════════════════════════════════════════════════════════════════════════
2184
-
2185
- program
2186
- .command("mock")
2187
- .description("Generate a standalone mock server + proxy config from the latest DSL")
2188
- .option("--port <n>", "Mock server port (default: 3001)", "3001")
2189
- .option("--msw", "Also generate MSW (Mock Service Worker) handlers at src/mocks/")
2190
- .option("--proxy", "Also generate frontend proxy config snippet")
2191
- .option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
2192
- .option("--workspace", "Generate mock assets for all backend repos in the workspace")
2193
- .option("--serve", "Start mock server in background + patch frontend proxy (use with --frontend)")
2194
- .option("--frontend <path>", "Path to frontend project for proxy patching (used with --serve/--restore)")
2195
- .option("--restore", "Undo proxy changes and stop mock server (requires --frontend or auto-detects)")
2196
- .action(async (opts) => {
2197
- const currentDir = process.cwd();
2198
- const port = parseInt(opts.port, 10) || 3001;
2199
-
2200
- console.log(chalk.blue("\n─── ai-spec mock ───────────────────────────────"));
2201
-
2202
- // ── Restore mode ──────────────────────────────────────────────────────────
2203
- if (opts.restore) {
2204
- const frontendDir = opts.frontend ? path.resolve(opts.frontend) : currentDir;
2205
- const r = await restoreMockProxy(frontendDir);
2206
- if (r.restored) {
2207
- console.log(chalk.green(" ✔ Proxy restored and mock server stopped."));
2208
- } else {
2209
- console.log(chalk.yellow(` ${r.note ?? "Nothing to restore."}`));
2210
- }
2211
- return;
2212
- }
2213
-
2214
- // ── Workspace mode ────────────────────────────────────────────────────────
2215
- if (opts.workspace) {
2216
- const workspaceLoader = new WorkspaceLoader(currentDir);
2217
- const workspaceConfig = await workspaceLoader.load();
2218
- if (!workspaceConfig) {
2219
- console.error(chalk.red(` No ${WORKSPACE_CONFIG_FILE} found. Run \`ai-spec workspace init\` first.`));
2220
- process.exit(1);
2221
- }
2222
-
2223
- const backendRepos = workspaceConfig.repos.filter((r) => r.role === "backend");
2224
- if (backendRepos.length === 0) {
2225
- console.log(chalk.yellow(" No backend repos found in workspace."));
2226
- return;
2227
- }
2228
-
2229
- for (const repo of backendRepos) {
2230
- const repoAbsPath = workspaceLoader.resolveAbsPath(repo);
2231
- console.log(chalk.cyan(`\n Repo: ${repo.name} (${repoAbsPath})`));
2232
-
2233
- const dslFile = await findLatestDslFile(repoAbsPath);
2234
- if (!dslFile) {
2235
- console.log(chalk.yellow(` No DSL file found — skipping.`));
2236
- continue;
2237
- }
2238
-
2239
- const dsl: SpecDSL = await fs.readJson(dslFile);
2240
- const result = await generateMockAssets(dsl, repoAbsPath, {
2241
- port,
2242
- msw: opts.msw,
2243
- proxy: opts.proxy,
2244
- });
2245
-
2246
- for (const f of result.files) {
2247
- console.log(chalk.green(` ✔ ${f.path}`));
2248
- console.log(chalk.gray(` ${f.description}`));
2249
- }
2250
- }
2251
- return;
2252
- }
2253
-
2254
- // ── Single-repo mode ──────────────────────────────────────────────────────
2255
- let dslPath: string | null = opts.dsl ?? null;
2256
-
2257
- if (!dslPath) {
2258
- dslPath = await findLatestDslFile(currentDir);
2259
- if (!dslPath) {
2260
- console.error(
2261
- chalk.red(
2262
- " No .dsl.json file found in .ai-spec/. Run `ai-spec create` first or use --dsl <path>."
2263
- )
2264
- );
2265
- process.exit(1);
2266
- }
2267
- console.log(chalk.gray(` Using DSL: ${path.relative(currentDir, dslPath)}`));
2268
- }
2269
-
2270
- let dsl: SpecDSL;
2271
- try {
2272
- dsl = await fs.readJson(dslPath);
2273
- } catch (err) {
2274
- console.error(chalk.red(` Failed to read DSL file: ${(err as Error).message}`));
2275
- process.exit(1);
2276
- }
2277
-
2278
- const result = await generateMockAssets(dsl, currentDir, {
2279
- port,
2280
- msw: opts.msw,
2281
- proxy: opts.proxy,
2282
- });
2283
-
2284
- console.log(chalk.green(`\n ✔ Mock assets generated (${result.files.length} file(s)):`));
2285
- for (const f of result.files) {
2286
- console.log(chalk.green(` ${f.path}`));
2287
- console.log(chalk.gray(` ${f.description}`));
2288
- }
2289
-
2290
- // ── Serve mode: start mock server + patch frontend proxy ──────────────────
2291
- if (opts.serve) {
2292
- const serverJsPath = path.join(currentDir, "mock", "server.js");
2293
- if (!(await fs.pathExists(serverJsPath))) {
2294
- console.error(chalk.red(" mock/server.js not found — generation may have failed."));
2295
- process.exit(1);
2296
- }
2297
-
2298
- // Start mock server in background
2299
- const pid = startMockServerBackground(serverJsPath, port);
2300
- console.log(chalk.green(`\n ✔ Mock server started (PID ${pid}) → http://localhost:${port}`));
2301
-
2302
- // Apply frontend proxy patch
2303
- if (opts.frontend) {
2304
- const frontendDir = path.resolve(opts.frontend);
2305
- const proxyResult = await applyMockProxy(frontendDir, port, dsl.endpoints);
2306
- await saveMockServerPid(frontendDir, pid);
2307
-
2308
- if (proxyResult.applied) {
2309
- console.log(chalk.green(` ✔ Frontend proxy patched (${proxyResult.framework})`));
2310
- console.log(chalk.bold.cyan(`\n Ready! Open a new terminal and run:`));
2311
- console.log(chalk.white(` cd ${frontendDir}`));
2312
- console.log(chalk.white(` ${proxyResult.devCommand}`));
2313
- console.log(chalk.gray(`\n When done: ai-spec mock --restore --frontend ${frontendDir}`));
2314
- } else {
2315
- console.log(chalk.yellow(` ⚠ Auto-patch not available for ${proxyResult.framework}.`));
2316
- if (proxyResult.note) console.log(chalk.gray(` ${proxyResult.note}`));
2317
- }
2318
- } else {
2319
- console.log(chalk.gray(` Tip: use --frontend <path> to also auto-patch your frontend proxy config.`));
2320
- console.log(chalk.gray(` Mock server: http://localhost:${port}`));
2321
- }
2322
- return;
2323
- }
2324
-
2325
- console.log(chalk.blue("\n─── Quick start ────────────────────────────────"));
2326
- console.log(chalk.white(` 1. Install express (if not already):`));
2327
- console.log(chalk.gray(` npm install --save-dev express`));
2328
- console.log(chalk.white(` 2. Start mock server:`));
2329
- console.log(chalk.gray(` node mock/server.js`));
2330
- console.log(chalk.gray(` # or: ai-spec mock --serve --frontend <path-to-frontend>`));
2331
- console.log(chalk.white(` 3. Configure your frontend to proxy API calls to:`));
2332
- console.log(chalk.gray(` http://localhost:${port}`));
2333
- if (opts.proxy) {
2334
- console.log(chalk.gray(` (See the generated proxy config file for framework-specific instructions)`));
2335
- }
2336
- if (opts.msw) {
2337
- console.log(chalk.white(` 4. MSW: import and start the worker in your app entry:`));
2338
- console.log(chalk.gray(` import { worker } from './mocks/browser';`));
2339
- console.log(chalk.gray(` if (process.env.NODE_ENV === 'development') worker.start();`));
2340
- }
2341
- });
2342
-
2343
- // ═══════════════════════════════════════════════════════════════════════════════
2344
- // Command: learn — zero-friction knowledge injection into constitution §9
2345
- // ═══════════════════════════════════════════════════════════════════════════════
2346
-
2347
- program
2348
- .command("learn")
2349
- .description("Append a lesson or engineering decision directly to constitution §9")
2350
- .argument("[lesson]", "The lesson or decision to record (prompted if omitted)")
2351
- .action(async (lesson: string | undefined) => {
2352
- const currentDir = process.cwd();
2353
-
2354
- if (!lesson) {
2355
- const { default: inquirerInput } = await import("@inquirer/prompts").then(
2356
- (m) => ({ default: m.input })
2357
- );
2358
- lesson = await inquirerInput({
2359
- message: "What lesson or engineering decision should be recorded?",
2360
- validate: (v) => v.trim().length > 0 || "Please enter a lesson",
2361
- });
2362
- }
2363
-
2364
- const result = await appendDirectLesson(currentDir, lesson.trim());
2365
-
2366
- if (result.appended) {
2367
- console.log(chalk.green(`\n ✔ Lesson appended to constitution §9`));
2368
- console.log(chalk.gray(` File: .ai-spec-constitution.md`));
2369
- } else {
2370
- console.log(chalk.yellow(`\n ⚠ Not appended: ${result.reason}`));
2371
- }
2372
- });
2373
-
2374
- // ═══════════════════════════════════════════════════════════════════════════════
2375
- // Command: restore
2376
- // ═══════════════════════════════════════════════════════════════════════════════
2377
-
2378
- program
2379
- .command("restore")
2380
- .description("Restore files modified by a previous run")
2381
- .argument("<runId>", "Run ID shown at the end of a create / generate run")
2382
- .action(async (runId: string) => {
2383
- const currentDir = process.cwd();
2384
- const snapshot = new RunSnapshot(currentDir, runId);
2385
- console.log(chalk.blue(`Restoring run: ${runId}...`));
2386
- const restored = await snapshot.restore();
2387
- if (restored.length === 0) {
2388
- console.log(chalk.yellow(" No backup found for this run ID."));
2389
- } else {
2390
- restored.forEach((f) => console.log(chalk.green(` ✔ restored: ${f}`)));
2391
- console.log(chalk.bold.green(`\n✔ ${restored.length} file(s) restored.`));
2392
- }
2393
- });
2394
-
2395
- // ═══════════════════════════════════════════════════════════════════════════════
2396
- // Command: trend
2397
- // ═══════════════════════════════════════════════════════════════════════════════
2398
-
2399
- program
2400
- .command("trend")
2401
- .description("Show harness score trend across past create runs")
2402
- .option("--last <n>", "Number of recent scored runs to show (default: 15)", "15")
2403
- .option("--prompt <hash>", "Filter to a specific prompt hash (prefix match)")
2404
- .option("--json", "Output raw JSON instead of formatted table")
2405
- .action(async (opts: { last: string; prompt?: string; json?: boolean }) => {
2406
- const currentDir = process.cwd();
2407
- const last = parseInt(opts.last, 10) || 15;
2408
-
2409
- const logs = await loadRunLogs(currentDir);
2410
- if (logs.length === 0) {
2411
- console.log(chalk.yellow(
2412
- "\n No run logs found. Run `ai-spec create` at least once to start tracking.\n"
2413
- ));
2414
- return;
2415
- }
2416
-
2417
- const report = buildTrendReport(logs, {
2418
- last,
2419
- promptFilter: opts.prompt,
2420
- });
2421
-
2422
- if (opts.json) {
2423
- console.log(JSON.stringify(report, null, 2));
2424
- return;
2425
- }
2426
-
2427
- printTrendReport(report, currentDir);
2428
- });
2429
-
2430
- // ═══════════════════════════════════════════════════════════════════════════════
2431
- // Command: logs
2432
- // ═══════════════════════════════════════════════════════════════════════════════
2433
-
2434
- program
2435
- .command("logs")
2436
- .description("List recent run logs with stage timing")
2437
- .argument("[runId]", "Show detailed stage breakdown for a specific run ID")
2438
- .option("--last <n>", "Number of runs to list (default: 10)", "10")
2439
- .action(async (runId: string | undefined, opts: { last: string }) => {
2440
- const currentDir = process.cwd();
2441
- const logDir = path.join(currentDir, ".ai-spec-logs");
2442
-
2443
- if (!(await fs.pathExists(logDir))) {
2444
- console.log(chalk.yellow("\n No run logs found (.ai-spec-logs/ does not exist).\n"));
2445
- return;
2446
- }
2447
-
2448
- if (runId) {
2449
- // ── Detail view for a single run ──────────────────────────────────────
2450
- const logPath = path.join(logDir, `${runId}.json`);
2451
- if (!(await fs.pathExists(logPath))) {
2452
- console.log(chalk.red(`\n Run not found: ${runId}\n`));
2453
- return;
2454
- }
2455
- const log = await fs.readJson(logPath);
2456
-
2457
- console.log(chalk.cyan(`\n─── Run: ${log.runId} ─────────────────────────────────`));
2458
- console.log(chalk.gray(` Started : ${log.startedAt}`));
2459
- if (log.endedAt) console.log(chalk.gray(` Ended : ${log.endedAt}`));
2460
- if (log.totalDurationMs !== undefined)
2461
- console.log(chalk.gray(` Duration: ${(log.totalDurationMs / 1000).toFixed(1)}s`));
2462
- if (log.provider) console.log(chalk.gray(` Provider: ${log.provider} / ${log.model ?? "?"}`));
2463
- if (log.promptHash) console.log(chalk.gray(` Prompt : ${log.promptHash}`));
2464
- if (log.harnessScore !== undefined)
2465
- console.log(chalk.white(` Score : ${log.harnessScore}/10`));
2466
- if (log.filesWritten?.length)
2467
- console.log(chalk.gray(` Files : ${log.filesWritten.length} written`));
2468
- if (log.errors?.length)
2469
- console.log(chalk.yellow(` Errors : ${log.errors.length}`));
2470
-
2471
- if (log.entries?.length) {
2472
- console.log(chalk.bold("\n Stages:\n"));
2473
- const doneEvents = (log.entries as Array<{ event: string; data?: Record<string, unknown>; ts: string }>)
2474
- .filter((e) => e.event.endsWith(":done") || e.event.endsWith(":failed"));
2475
-
2476
- for (const entry of doneEvents) {
2477
- const isOk = entry.event.endsWith(":done");
2478
- const stage = entry.event.replace(/:done$|:failed$/, "");
2479
- const dur = entry.data?.durationMs
2480
- ? chalk.gray(` ${(Number(entry.data.durationMs) / 1000).toFixed(1)}s`)
2481
- : "";
2482
- const mark = isOk ? chalk.green("✔") : chalk.red("✘");
2483
- console.log(` ${mark} ${stage.padEnd(20)}${dur}`);
2484
- }
2485
- }
2486
- console.log(chalk.cyan("─".repeat(52)));
2487
- return;
2488
- }
2489
-
2490
- // ── List view ─────────────────────────────────────────────────────────────
2491
- const logs = await loadRunLogs(currentDir);
2492
- const last = parseInt(opts.last, 10) || 10;
2493
- const shown = logs.slice(0, last);
2494
-
2495
- if (shown.length === 0) {
2496
- console.log(chalk.yellow("\n No run logs found.\n"));
2497
- return;
2498
- }
2499
-
2500
- console.log(chalk.cyan("\n─── Run Logs ────────────────────────────────────────────────"));
2501
- console.log(chalk.gray(
2502
- "\n " +
2503
- "Run ID ".padEnd(26) +
2504
- "Date " +
2505
- "Score ".padStart(6) +
2506
- " Files Dur\n"
2507
- ));
2508
-
2509
- for (const log of shown) {
2510
- const date = log.startedAt.slice(0, 10);
2511
- const score = log.harnessScore !== undefined
2512
- ? (log.harnessScore >= 8 ? chalk.green : log.harnessScore >= 6 ? chalk.yellow : chalk.red)(
2513
- log.harnessScore.toFixed(1).padStart(5)
2514
- )
2515
- : chalk.gray(" —");
2516
- const files = String(log.filesWritten?.length ?? 0).padStart(5);
2517
- const dur = log.totalDurationMs !== undefined
2518
- ? chalk.gray((log.totalDurationMs / 1000).toFixed(0) + "s")
2519
- : chalk.gray("—");
2520
- const errMark = (log.errors?.length ?? 0) > 0
2521
- ? chalk.yellow(` ⚠${log.errors.length}`)
2522
- : "";
2523
-
2524
- console.log(` ${chalk.white(log.runId.padEnd(25))} ${chalk.gray(date)} ${score} ${chalk.gray(files)} ${dur}${errMark}`);
2525
- }
2526
-
2527
- console.log(chalk.gray(`\n Showing ${shown.length} of ${logs.length} run(s) · logs: .ai-spec-logs/`));
2528
- console.log(chalk.cyan("─".repeat(63)));
2529
- console.log(chalk.gray(` Tip: ai-spec logs <runId> to see stage breakdown`));
2530
- console.log(chalk.gray(` ai-spec trend to see score trend by prompt version\n`));
2531
- });
32
+ registerCreate(program);
33
+ registerReview(program);
34
+ registerInit(program);
35
+ registerConfig(program);
36
+ registerModel(program);
37
+ registerWorkspace(program);
38
+ registerUpdate(program);
39
+ registerExport(program);
40
+ registerMock(program);
41
+ registerLearn(program);
42
+ registerRestore(program);
43
+ registerTrend(program);
44
+ registerLogs(program);
45
+ registerTypes(program);
46
+ registerDashboard(program);
47
+ registerVcr(program);
48
+ registerScan(program);
2532
49
 
2533
50
  program.parse();