ai-spec-dev 0.31.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude/commands/add-lesson.md +34 -0
  2. package/.claude/commands/check-layers.md +65 -0
  3. package/.claude/commands/installed-deps.md +35 -0
  4. package/.claude/commands/recall-lessons.md +40 -0
  5. package/.claude/commands/scan-singletons.md +45 -0
  6. package/.claude/commands/verify-imports.md +48 -0
  7. package/.claude/settings.local.json +15 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +460 -0
  10. package/cli/commands/config.ts +93 -0
  11. package/cli/commands/create.ts +1233 -0
  12. package/cli/commands/dashboard.ts +62 -0
  13. package/cli/commands/export.ts +66 -0
  14. package/cli/commands/init.ts +190 -0
  15. package/cli/commands/learn.ts +30 -0
  16. package/cli/commands/logs.ts +106 -0
  17. package/cli/commands/mock.ts +175 -0
  18. package/cli/commands/model.ts +156 -0
  19. package/cli/commands/restore.ts +22 -0
  20. package/cli/commands/review.ts +63 -0
  21. package/cli/commands/scan.ts +99 -0
  22. package/cli/commands/trend.ts +36 -0
  23. package/cli/commands/types.ts +69 -0
  24. package/cli/commands/update.ts +178 -0
  25. package/cli/commands/vcr.ts +70 -0
  26. package/cli/commands/workspace.ts +219 -0
  27. package/cli/index.ts +34 -2240
  28. package/cli/utils.ts +83 -0
  29. package/core/combined-generator.ts +13 -3
  30. package/core/dashboard-generator.ts +340 -0
  31. package/core/design-dialogue.ts +124 -0
  32. package/core/dsl-feedback.ts +285 -0
  33. package/core/error-feedback.ts +46 -2
  34. package/core/project-index.ts +301 -0
  35. package/core/reviewer.ts +84 -6
  36. package/core/run-logger.ts +109 -3
  37. package/core/run-trend.ts +261 -0
  38. package/core/self-evaluator.ts +139 -7
  39. package/core/spec-generator.ts +14 -8
  40. package/core/task-generator.ts +17 -0
  41. package/core/types-generator.ts +219 -0
  42. package/core/vcr.ts +210 -0
  43. package/dist/cli/index.js +6692 -4512
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/cli/index.mjs +6692 -4512
  46. package/dist/cli/index.mjs.map +1 -1
  47. package/dist/index.d.mts +19 -5
  48. package/dist/index.d.ts +19 -5
  49. package/dist/index.js +420 -224
  50. package/dist/index.js.map +1 -1
  51. package/dist/index.mjs +418 -224
  52. package/dist/index.mjs.map +1 -1
  53. package/docs-assets/purpose/architecture-overview.svg +64 -0
  54. package/docs-assets/purpose/create-pipeline.svg +113 -0
  55. package/docs-assets/purpose/task-layering.svg +74 -0
  56. package/package.json +6 -3
  57. package/prompts/codegen.prompt.ts +97 -9
  58. package/prompts/design.prompt.ts +59 -0
  59. package/prompts/spec.prompt.ts +8 -1
  60. package/prompts/tasks.prompt.ts +27 -2
  61. package/purpose.md +600 -174
  62. package/tests/dsl-extractor.test.ts +264 -0
  63. package/tests/dsl-feedback.test.ts +266 -0
  64. package/tests/dsl-validator.test.ts +283 -0
  65. package/tests/error-feedback.test.ts +292 -0
  66. package/tests/provider-utils.test.ts +173 -0
  67. package/tests/run-trend.test.ts +186 -0
  68. package/tests/self-evaluator.test.ts +339 -0
  69. package/tests/spec-assessor.test.ts +142 -0
  70. package/tests/task-generator.test.ts +230 -0
@@ -0,0 +1,62 @@
1
+ import { Command } from "commander";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import { execSync } from "child_process";
6
+ import { loadRunLogs } from "../../core/run-trend";
7
+ import { generateDashboard } from "../../core/dashboard-generator";
8
+
9
+ export function registerDashboard(program: Command): void {
10
+ program
11
+ .command("dashboard")
12
+ .description("Generate an HTML Harness Dashboard from run logs")
13
+ .option("--output <path>", "Output file path (default: .ai-spec/dashboard.html)")
14
+ .option("--open", "Auto-open the dashboard in the default browser after generation")
15
+ .option("--last <n>", "Limit to the last N runs (default: all)", "0")
16
+ .action(async (opts) => {
17
+ const currentDir = process.cwd();
18
+
19
+ // ── Load run logs ────────────────────────────────────────────────────────
20
+ let logs = await loadRunLogs(currentDir);
21
+ if (logs.length === 0) {
22
+ console.log(chalk.yellow("\n No run logs found. Run `ai-spec create` at least once first.\n"));
23
+ return;
24
+ }
25
+
26
+ const last = parseInt(opts.last, 10);
27
+ if (last > 0) logs = logs.slice(0, last);
28
+
29
+ // ── Generate HTML ────────────────────────────────────────────────────────
30
+ const html = generateDashboard(logs);
31
+
32
+ // ── Write file ───────────────────────────────────────────────────────────
33
+ const outputPath = opts.output
34
+ ? path.resolve(opts.output)
35
+ : path.join(currentDir, ".ai-spec", "dashboard.html");
36
+
37
+ await fs.ensureDir(path.dirname(outputPath));
38
+ await fs.writeFile(outputPath, html, "utf-8");
39
+ const relPath = path.relative(currentDir, outputPath);
40
+
41
+ console.log(chalk.green(`\n ✔ Dashboard generated: ${relPath}`));
42
+ console.log(chalk.gray(` Runs analyzed : ${logs.length}`));
43
+ console.log(chalk.gray(` Size : ${Math.round(html.length / 1024)}KB`));
44
+ console.log(chalk.blue(`\n Open in browser:`));
45
+ console.log(chalk.gray(` open ${relPath}\n`));
46
+
47
+ // ── Auto-open ────────────────────────────────────────────────────────────
48
+ if (opts.open) {
49
+ try {
50
+ const cmd =
51
+ process.platform === "darwin"
52
+ ? `open "${outputPath}"`
53
+ : process.platform === "win32"
54
+ ? `start "" "${outputPath}"`
55
+ : `xdg-open "${outputPath}"`;
56
+ execSync(cmd);
57
+ } catch {
58
+ // Non-fatal — file was already written
59
+ }
60
+ }
61
+ });
62
+ }
@@ -0,0 +1,66 @@
1
+ import { Command } from "commander";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import { SpecDSL } from "../../core/dsl-types";
6
+ import { exportOpenApi } from "../../core/openapi-exporter";
7
+ import { findLatestDslFile } from "../../core/mock-server-generator";
8
+
9
+ export function registerExport(program: Command): void {
10
+ program
11
+ .command("export")
12
+ .description("Export the latest DSL to OpenAPI 3.1.0 (YAML or JSON)")
13
+ .option("--openapi", "Export as OpenAPI 3.1.0 (default behaviour)")
14
+ .option("--format <fmt>", "Output format: yaml | json (default: yaml)", "yaml")
15
+ .option("--output <path>", "Output file path (default: openapi.yaml)")
16
+ .option("--server <url>", "API server URL in the OpenAPI document (default: http://localhost:3000)")
17
+ .option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
18
+ .action(async (opts) => {
19
+ const currentDir = process.cwd();
20
+
21
+ // ── Find DSL ────────────────────────────────────────────────────────────
22
+ let dslPath: string | null = opts.dsl ?? null;
23
+ if (!dslPath) {
24
+ dslPath = await findLatestDslFile(currentDir);
25
+ if (!dslPath) {
26
+ console.error(chalk.red(" No .dsl.json file found. Run `ai-spec create` first or use --dsl <path>."));
27
+ process.exit(1);
28
+ }
29
+ console.log(chalk.gray(` Using DSL: ${path.relative(currentDir, dslPath)}`));
30
+ }
31
+
32
+ let dsl: SpecDSL;
33
+ try {
34
+ dsl = await fs.readJson(dslPath);
35
+ } catch (err) {
36
+ console.error(chalk.red(` Failed to read DSL: ${(err as Error).message}`));
37
+ process.exit(1);
38
+ }
39
+
40
+ // ── Export ──────────────────────────────────────────────────────────────
41
+ console.log(chalk.blue("\n─── ai-spec export ─────────────────────────────"));
42
+
43
+ const format = (opts.format === "json" ? "json" : "yaml") as "yaml" | "json";
44
+ const serverUrl = opts.server || "http://localhost:3000";
45
+
46
+ try {
47
+ const outputPath = await exportOpenApi(dsl, currentDir, {
48
+ format,
49
+ serverUrl,
50
+ outputPath: opts.output,
51
+ });
52
+ const rel = path.relative(currentDir, outputPath);
53
+ console.log(chalk.green(` ✔ OpenAPI ${format.toUpperCase()} exported: ${rel}`));
54
+ console.log(chalk.gray(` Feature : ${dsl.feature.title}`));
55
+ console.log(chalk.gray(` Endpoints: ${dsl.endpoints.length}`));
56
+ console.log(chalk.gray(` Models : ${dsl.models.length}`));
57
+ console.log(chalk.gray(` Server : ${serverUrl}`));
58
+ console.log(chalk.blue("\n Next steps:"));
59
+ console.log(chalk.gray(` • Import ${rel} into Postman / Insomnia / Swagger UI`));
60
+ console.log(chalk.gray(` • Use openapi-generator to generate client SDKs`));
61
+ } catch (err) {
62
+ console.error(chalk.red(` Export failed: ${(err as Error).message}`));
63
+ process.exit(1);
64
+ }
65
+ });
66
+ }
@@ -0,0 +1,190 @@
1
+ import { Command } from "commander";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import { createProvider, DEFAULT_MODELS, SUPPORTED_PROVIDERS } from "../../core/spec-generator";
6
+ import { ContextLoader } from "../../core/context-loader";
7
+ import { ConstitutionGenerator, CONSTITUTION_FILE } from "../../core/constitution-generator";
8
+ import { ConstitutionConsolidator } from "../../core/constitution-consolidator";
9
+ import {
10
+ loadGlobalConstitution,
11
+ saveGlobalConstitution,
12
+ GLOBAL_CONSTITUTION_FILE,
13
+ } from "../../core/global-constitution";
14
+ import {
15
+ globalConstitutionSystemPrompt,
16
+ buildGlobalConstitutionPrompt,
17
+ } from "../../prompts/global-constitution.prompt";
18
+ import { loadConfig, resolveApiKey } from "../utils";
19
+ import { loadIndex, ProjectEntry } from "../../core/project-index";
20
+
21
+ export function registerInit(program: Command): void {
22
+ program
23
+ .command("init")
24
+ .description(`Analyze codebase and generate Project Constitution (${CONSTITUTION_FILE})`)
25
+ .option(
26
+ "--provider <name>",
27
+ `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
28
+ undefined
29
+ )
30
+ .option("--model <name>", "Model name")
31
+ .option("-k, --key <apiKey>", "API key")
32
+ .option("--force", "Overwrite existing constitution")
33
+ .option(
34
+ "--global",
35
+ `Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
36
+ )
37
+ .option("--consolidate", "Consolidate §9 accumulated lessons into §1–§8 core rules (prune & rebase)")
38
+ .option("--dry-run", "Preview consolidation result without writing (use with --consolidate)")
39
+ .action(async (opts) => {
40
+ const currentDir = process.cwd();
41
+ const config = await loadConfig(currentDir);
42
+
43
+ const providerName = opts.provider || config.provider || "gemini";
44
+ const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
45
+ const apiKey = await resolveApiKey(providerName, opts.key);
46
+ const provider = createProvider(providerName, apiKey, modelName);
47
+
48
+ // ── Consolidate mode ───────────────────────────────────────────────────
49
+ if (opts.consolidate) {
50
+ const consolidator = new ConstitutionConsolidator(provider);
51
+ try {
52
+ const result = await consolidator.consolidate(currentDir, {
53
+ dryRun: opts.dryRun,
54
+ auto: opts.auto,
55
+ });
56
+ if (result.written) {
57
+ console.log(chalk.blue("\n Summary:"));
58
+ 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)})`));
59
+ console.log(chalk.gray(` §9 : ${result.before.lessonCount} → ${result.after.lessonCount} lessons remaining`));
60
+ if (result.backupPath) {
61
+ console.log(chalk.gray(` Backup: ${path.basename(result.backupPath)}`));
62
+ }
63
+ }
64
+ } catch (err) {
65
+ console.error(chalk.red(` ✘ Consolidation failed: ${(err as Error).message}`));
66
+ process.exit(1);
67
+ }
68
+ return;
69
+ }
70
+
71
+ // ── Global constitution mode ───────────────────────────────────────────
72
+ if (opts.global) {
73
+ const existing = await loadGlobalConstitution([currentDir]);
74
+ if (existing && !opts.force) {
75
+ console.log(chalk.yellow(`\n Global constitution already exists at: ${existing.source}`));
76
+ console.log(chalk.gray(" Use --force to overwrite it."));
77
+ return;
78
+ }
79
+
80
+ console.log(chalk.blue("\n─── Generating Global Constitution ──────────────"));
81
+ console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
82
+
83
+ // ── Build per-project summaries ────────────────────────────────────
84
+ const projectSummaries: Array<{ name: string; summary: string }> = [];
85
+ const index = await loadIndex(currentDir);
86
+
87
+ if (index && index.projects.length > 0) {
88
+ const active = index.projects.filter((p: ProjectEntry) => !p.missing);
89
+ console.log(chalk.gray(` Found project index: ${active.length} project(s) — reading constitutions...`));
90
+
91
+ for (const entry of active) {
92
+ const absPath = path.join(currentDir, entry.path);
93
+ const lines: string[] = [
94
+ `Type: ${entry.type} (${entry.role})`,
95
+ `Tech stack: ${entry.techStack.join(", ") || "unknown"}`,
96
+ ];
97
+
98
+ // Include §1–§6 of project constitution if available (skip §9 lessons)
99
+ if (entry.hasConstitution) {
100
+ try {
101
+ const constitutionPath = path.join(absPath, CONSTITUTION_FILE);
102
+ const raw = await fs.readFile(constitutionPath, "utf-8");
103
+ // Take up to first 2000 chars (covers §1–§6 without §9 noise)
104
+ const excerpt = raw.slice(0, 2000);
105
+ lines.push("", "Constitution excerpt:", excerpt);
106
+ } catch { /* skip if unreadable */ }
107
+ }
108
+
109
+ projectSummaries.push({ name: entry.name, summary: lines.join("\n") });
110
+ }
111
+ } else {
112
+ // No index — fall back to scanning just the current directory
113
+ console.log(chalk.yellow(" No project index found. Run `ai-spec scan` first for better results."));
114
+ console.log(chalk.gray(" Falling back: scanning current directory only..."));
115
+ const loader = new ContextLoader(currentDir);
116
+ const ctx = await loader.loadProjectContext();
117
+ projectSummaries.push({
118
+ name: path.basename(currentDir),
119
+ summary: [
120
+ `Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
121
+ `Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`,
122
+ ].join("\n"),
123
+ });
124
+ }
125
+
126
+ console.log(chalk.gray(` Generating from ${projectSummaries.length} project(s)...`));
127
+ const prompt = buildGlobalConstitutionPrompt(projectSummaries);
128
+ let globalConstitution: string;
129
+ try {
130
+ globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
131
+ } catch (err) {
132
+ console.error(chalk.red(" ✘ Failed to generate global constitution:"), err);
133
+ process.exit(1);
134
+ }
135
+
136
+ const saved = await saveGlobalConstitution(globalConstitution, currentDir);
137
+ console.log(chalk.green(`\n ✔ Global constitution saved: ${saved}`));
138
+ console.log(chalk.gray(" This will be automatically merged into all project constitutions in this workspace."));
139
+ console.log(chalk.gray(" Project-level rules always override global rules.\n"));
140
+ console.log(chalk.bold(" Preview:"));
141
+ console.log(chalk.gray(globalConstitution.split("\n").slice(0, 12).join("\n")));
142
+ if (globalConstitution.split("\n").length > 12) {
143
+ console.log(chalk.gray(` ... (${globalConstitution.split("\n").length} lines total)`));
144
+ }
145
+ return;
146
+ }
147
+
148
+ // ── Project constitution mode (default) ───────────────────────────────
149
+ const constitutionPath = path.join(currentDir, CONSTITUTION_FILE);
150
+
151
+ if (!opts.force && (await fs.pathExists(constitutionPath))) {
152
+ console.log(chalk.yellow(`\n ${CONSTITUTION_FILE} already exists.`));
153
+ console.log(chalk.gray(" Use --force to overwrite it."));
154
+ console.log(chalk.gray(` Or edit it directly: ${constitutionPath}`));
155
+ return;
156
+ }
157
+
158
+ console.log(chalk.blue("\n─── Generating Project Constitution ─────────────"));
159
+ console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
160
+ console.log(chalk.gray(" Analyzing codebase..."));
161
+
162
+ const generator = new ConstitutionGenerator(provider);
163
+
164
+ let constitution: string;
165
+ try {
166
+ constitution = await generator.generate(currentDir);
167
+ } catch (err) {
168
+ console.error(chalk.red(" ✘ Failed to generate constitution:"), err);
169
+ process.exit(1);
170
+ }
171
+
172
+ const saved = await generator.saveConstitution(currentDir, constitution);
173
+
174
+ const globalResult = await loadGlobalConstitution([path.dirname(currentDir)]);
175
+ if (globalResult) {
176
+ console.log(chalk.cyan(`\n ℹ Global constitution detected: ${globalResult.source}`));
177
+ console.log(chalk.gray(" It will be merged with this project constitution at runtime."));
178
+ console.log(chalk.gray(" Project rules take priority over global rules."));
179
+ }
180
+
181
+ console.log(chalk.green(`\n ✔ Constitution saved: ${saved}`));
182
+ console.log(chalk.gray(" This file will be automatically used in all future `ai-spec create` runs."));
183
+ console.log(chalk.gray(" Edit it to add custom rules or red lines for your project.\n"));
184
+ console.log(chalk.bold(" Preview:"));
185
+ console.log(chalk.gray(constitution.split("\n").slice(0, 15).join("\n")));
186
+ if (constitution.split("\n").length > 15) {
187
+ console.log(chalk.gray(` ... (${constitution.split("\n").length} lines total)`));
188
+ }
189
+ });
190
+ }
@@ -0,0 +1,30 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { input } from "@inquirer/prompts";
4
+ import { appendDirectLesson } from "../../core/knowledge-memory";
5
+
6
+ export function registerLearn(program: Command): void {
7
+ program
8
+ .command("learn")
9
+ .description("Append a lesson or engineering decision directly to constitution §9")
10
+ .argument("[lesson]", "The lesson or decision to record (prompted if omitted)")
11
+ .action(async (lesson: string | undefined) => {
12
+ const currentDir = process.cwd();
13
+
14
+ if (!lesson) {
15
+ lesson = await input({
16
+ message: "What lesson or engineering decision should be recorded?",
17
+ validate: (v) => v.trim().length > 0 || "Please enter a lesson",
18
+ });
19
+ }
20
+
21
+ const result = await appendDirectLesson(currentDir, lesson.trim());
22
+
23
+ if (result.appended) {
24
+ console.log(chalk.green(`\n ✔ Lesson appended to constitution §9`));
25
+ console.log(chalk.gray(` File: .ai-spec-constitution.md`));
26
+ } else {
27
+ console.log(chalk.yellow(`\n ⚠ Not appended: ${result.reason}`));
28
+ }
29
+ });
30
+ }
@@ -0,0 +1,106 @@
1
+ import { Command } from "commander";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import { loadRunLogs } from "../../core/run-trend";
6
+
7
+ export function registerLogs(program: Command): void {
8
+ program
9
+ .command("logs")
10
+ .description("List recent run logs with stage timing")
11
+ .argument("[runId]", "Show detailed stage breakdown for a specific run ID")
12
+ .option("--last <n>", "Number of runs to list (default: 10)", "10")
13
+ .action(async (runId: string | undefined, opts: { last: string }) => {
14
+ const currentDir = process.cwd();
15
+ const logDir = path.join(currentDir, ".ai-spec-logs");
16
+
17
+ if (!(await fs.pathExists(logDir))) {
18
+ console.log(chalk.yellow("\n No run logs found (.ai-spec-logs/ does not exist).\n"));
19
+ return;
20
+ }
21
+
22
+ if (runId) {
23
+ // ── Detail view for a single run ──────────────────────────────────────
24
+ const logPath = path.join(logDir, `${runId}.json`);
25
+ if (!(await fs.pathExists(logPath))) {
26
+ console.log(chalk.red(`\n Run not found: ${runId}\n`));
27
+ return;
28
+ }
29
+ const log = await fs.readJson(logPath);
30
+
31
+ console.log(chalk.cyan(`\n─── Run: ${log.runId} ─────────────────────────────────`));
32
+ console.log(chalk.gray(` Started : ${log.startedAt}`));
33
+ if (log.endedAt) console.log(chalk.gray(` Ended : ${log.endedAt}`));
34
+ if (log.totalDurationMs !== undefined)
35
+ console.log(chalk.gray(` Duration: ${(log.totalDurationMs / 1000).toFixed(1)}s`));
36
+ if (log.provider) console.log(chalk.gray(` Provider: ${log.provider} / ${log.model ?? "?"}`));
37
+ if (log.promptHash) console.log(chalk.gray(` Prompt : ${log.promptHash}`));
38
+ if (log.harnessScore !== undefined)
39
+ console.log(chalk.white(` Score : ${log.harnessScore}/10`));
40
+ if (log.filesWritten?.length)
41
+ console.log(chalk.gray(` Files : ${log.filesWritten.length} written`));
42
+ if (log.errors?.length)
43
+ console.log(chalk.yellow(` Errors : ${log.errors.length}`));
44
+
45
+ if (log.entries?.length) {
46
+ console.log(chalk.bold("\n Stages:\n"));
47
+ const doneEvents = (log.entries as Array<{ event: string; data?: Record<string, unknown>; ts: string }>)
48
+ .filter((e) => e.event.endsWith(":done") || e.event.endsWith(":failed"));
49
+
50
+ for (const entry of doneEvents) {
51
+ const isOk = entry.event.endsWith(":done");
52
+ const stage = entry.event.replace(/:done$|:failed$/, "");
53
+ const dur = entry.data?.durationMs
54
+ ? chalk.gray(` ${(Number(entry.data.durationMs) / 1000).toFixed(1)}s`)
55
+ : "";
56
+ const mark = isOk ? chalk.green("✔") : chalk.red("✘");
57
+ console.log(` ${mark} ${stage.padEnd(20)}${dur}`);
58
+ }
59
+ }
60
+ console.log(chalk.cyan("─".repeat(52)));
61
+ return;
62
+ }
63
+
64
+ // ── List view ─────────────────────────────────────────────────────────────
65
+ const logs = await loadRunLogs(currentDir);
66
+ const last = parseInt(opts.last, 10) || 10;
67
+ const shown = logs.slice(0, last);
68
+
69
+ if (shown.length === 0) {
70
+ console.log(chalk.yellow("\n No run logs found.\n"));
71
+ return;
72
+ }
73
+
74
+ console.log(chalk.cyan("\n─── Run Logs ────────────────────────────────────────────────"));
75
+ console.log(chalk.gray(
76
+ "\n " +
77
+ "Run ID ".padEnd(26) +
78
+ "Date " +
79
+ "Score ".padStart(6) +
80
+ " Files Dur\n"
81
+ ));
82
+
83
+ for (const log of shown) {
84
+ const date = log.startedAt.slice(0, 10);
85
+ const score = log.harnessScore !== undefined
86
+ ? (log.harnessScore >= 8 ? chalk.green : log.harnessScore >= 6 ? chalk.yellow : chalk.red)(
87
+ log.harnessScore.toFixed(1).padStart(5)
88
+ )
89
+ : chalk.gray(" —");
90
+ const files = String(log.filesWritten?.length ?? 0).padStart(5);
91
+ const dur = log.totalDurationMs !== undefined
92
+ ? chalk.gray((log.totalDurationMs / 1000).toFixed(0) + "s")
93
+ : chalk.gray("—");
94
+ const errMark = (log.errors?.length ?? 0) > 0
95
+ ? chalk.yellow(` ⚠${log.errors.length}`)
96
+ : "";
97
+
98
+ console.log(` ${chalk.white(log.runId.padEnd(25))} ${chalk.gray(date)} ${score} ${chalk.gray(files)} ${dur}${errMark}`);
99
+ }
100
+
101
+ console.log(chalk.gray(`\n Showing ${shown.length} of ${logs.length} run(s) · logs: .ai-spec-logs/`));
102
+ console.log(chalk.cyan("─".repeat(63)));
103
+ console.log(chalk.gray(` Tip: ai-spec logs <runId> to see stage breakdown`));
104
+ console.log(chalk.gray(` ai-spec trend to see score trend by prompt version\n`));
105
+ });
106
+ }
@@ -0,0 +1,175 @@
1
+ import { Command } from "commander";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import {
6
+ WorkspaceLoader,
7
+ WORKSPACE_CONFIG_FILE,
8
+ } from "../../core/workspace-loader";
9
+ import { SpecDSL } from "../../core/dsl-types";
10
+ import {
11
+ generateMockAssets,
12
+ findLatestDslFile,
13
+ applyMockProxy,
14
+ restoreMockProxy,
15
+ startMockServerBackground,
16
+ saveMockServerPid,
17
+ } from "../../core/mock-server-generator";
18
+
19
+ export function registerMock(program: Command): void {
20
+ program
21
+ .command("mock")
22
+ .description("Generate a standalone mock server + proxy config from the latest DSL")
23
+ .option("--port <n>", "Mock server port (default: 3001)", "3001")
24
+ .option("--msw", "Also generate MSW (Mock Service Worker) handlers at src/mocks/")
25
+ .option("--proxy", "Also generate frontend proxy config snippet")
26
+ .option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
27
+ .option("--workspace", "Generate mock assets for all backend repos in the workspace")
28
+ .option("--serve", "Start mock server in background + patch frontend proxy (use with --frontend)")
29
+ .option("--frontend <path>", "Path to frontend project for proxy patching (used with --serve/--restore)")
30
+ .option("--restore", "Undo proxy changes and stop mock server (requires --frontend or auto-detects)")
31
+ .action(async (opts) => {
32
+ const currentDir = process.cwd();
33
+ const port = parseInt(opts.port, 10) || 3001;
34
+
35
+ console.log(chalk.blue("\n─── ai-spec mock ───────────────────────────────"));
36
+
37
+ // ── Restore mode ────────────────────────────────────────────────────────
38
+ if (opts.restore) {
39
+ const frontendDir = opts.frontend ? path.resolve(opts.frontend) : currentDir;
40
+ const r = await restoreMockProxy(frontendDir);
41
+ if (r.restored) {
42
+ console.log(chalk.green(" ✔ Proxy restored and mock server stopped."));
43
+ } else {
44
+ console.log(chalk.yellow(` ${r.note ?? "Nothing to restore."}`));
45
+ }
46
+ return;
47
+ }
48
+
49
+ // ── Workspace mode ──────────────────────────────────────────────────────
50
+ if (opts.workspace) {
51
+ const workspaceLoader = new WorkspaceLoader(currentDir);
52
+ const workspaceConfig = await workspaceLoader.load();
53
+ if (!workspaceConfig) {
54
+ console.error(chalk.red(` No ${WORKSPACE_CONFIG_FILE} found. Run \`ai-spec workspace init\` first.`));
55
+ process.exit(1);
56
+ }
57
+
58
+ const backendRepos = workspaceConfig.repos.filter((r) => r.role === "backend");
59
+ if (backendRepos.length === 0) {
60
+ console.log(chalk.yellow(" No backend repos found in workspace."));
61
+ return;
62
+ }
63
+
64
+ for (const repo of backendRepos) {
65
+ const repoAbsPath = workspaceLoader.resolveAbsPath(repo);
66
+ console.log(chalk.cyan(`\n Repo: ${repo.name} (${repoAbsPath})`));
67
+
68
+ const dslFile = await findLatestDslFile(repoAbsPath);
69
+ if (!dslFile) {
70
+ console.log(chalk.yellow(` No DSL file found — skipping.`));
71
+ continue;
72
+ }
73
+
74
+ const dsl: SpecDSL = await fs.readJson(dslFile);
75
+ const result = await generateMockAssets(dsl, repoAbsPath, {
76
+ port,
77
+ msw: opts.msw,
78
+ proxy: opts.proxy,
79
+ });
80
+
81
+ for (const f of result.files) {
82
+ console.log(chalk.green(` ✔ ${f.path}`));
83
+ console.log(chalk.gray(` ${f.description}`));
84
+ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ // ── Single-repo mode ────────────────────────────────────────────────────
90
+ let dslPath: string | null = opts.dsl ?? null;
91
+
92
+ if (!dslPath) {
93
+ dslPath = await findLatestDslFile(currentDir);
94
+ if (!dslPath) {
95
+ console.error(
96
+ chalk.red(
97
+ " No .dsl.json file found in .ai-spec/. Run `ai-spec create` first or use --dsl <path>."
98
+ )
99
+ );
100
+ process.exit(1);
101
+ }
102
+ console.log(chalk.gray(` Using DSL: ${path.relative(currentDir, dslPath)}`));
103
+ }
104
+
105
+ let dsl: SpecDSL;
106
+ try {
107
+ dsl = await fs.readJson(dslPath);
108
+ } catch (err) {
109
+ console.error(chalk.red(` Failed to read DSL file: ${(err as Error).message}`));
110
+ process.exit(1);
111
+ }
112
+
113
+ const result = await generateMockAssets(dsl, currentDir, {
114
+ port,
115
+ msw: opts.msw,
116
+ proxy: opts.proxy,
117
+ });
118
+
119
+ console.log(chalk.green(`\n ✔ Mock assets generated (${result.files.length} file(s)):`));
120
+ for (const f of result.files) {
121
+ console.log(chalk.green(` ${f.path}`));
122
+ console.log(chalk.gray(` ${f.description}`));
123
+ }
124
+
125
+ // ── Serve mode: start mock server + patch frontend proxy ────────────────
126
+ if (opts.serve) {
127
+ const serverJsPath = path.join(currentDir, "mock", "server.js");
128
+ if (!(await fs.pathExists(serverJsPath))) {
129
+ console.error(chalk.red(" mock/server.js not found — generation may have failed."));
130
+ process.exit(1);
131
+ }
132
+
133
+ const pid = startMockServerBackground(serverJsPath, port);
134
+ console.log(chalk.green(`\n ✔ Mock server started (PID ${pid}) → http://localhost:${port}`));
135
+
136
+ if (opts.frontend) {
137
+ const frontendDir = path.resolve(opts.frontend);
138
+ const proxyResult = await applyMockProxy(frontendDir, port, dsl.endpoints);
139
+ await saveMockServerPid(frontendDir, pid);
140
+
141
+ if (proxyResult.applied) {
142
+ console.log(chalk.green(` ✔ Frontend proxy patched (${proxyResult.framework})`));
143
+ console.log(chalk.bold.cyan(`\n Ready! Open a new terminal and run:`));
144
+ console.log(chalk.white(` cd ${frontendDir}`));
145
+ console.log(chalk.white(` ${proxyResult.devCommand}`));
146
+ console.log(chalk.gray(`\n When done: ai-spec mock --restore --frontend ${frontendDir}`));
147
+ } else {
148
+ console.log(chalk.yellow(` ⚠ Auto-patch not available for ${proxyResult.framework}.`));
149
+ if (proxyResult.note) console.log(chalk.gray(` ${proxyResult.note}`));
150
+ }
151
+ } else {
152
+ console.log(chalk.gray(` Tip: use --frontend <path> to also auto-patch your frontend proxy config.`));
153
+ console.log(chalk.gray(` Mock server: http://localhost:${port}`));
154
+ }
155
+ return;
156
+ }
157
+
158
+ console.log(chalk.blue("\n─── Quick start ────────────────────────────────"));
159
+ console.log(chalk.white(` 1. Install express (if not already):`));
160
+ console.log(chalk.gray(` npm install --save-dev express`));
161
+ console.log(chalk.white(` 2. Start mock server:`));
162
+ console.log(chalk.gray(` node mock/server.js`));
163
+ console.log(chalk.gray(` # or: ai-spec mock --serve --frontend <path-to-frontend>`));
164
+ console.log(chalk.white(` 3. Configure your frontend to proxy API calls to:`));
165
+ console.log(chalk.gray(` http://localhost:${port}`));
166
+ if (opts.proxy) {
167
+ console.log(chalk.gray(` (See the generated proxy config file for framework-specific instructions)`));
168
+ }
169
+ if (opts.msw) {
170
+ console.log(chalk.white(` 4. MSW: import and start the worker in your app entry:`));
171
+ console.log(chalk.gray(` import { worker } from './mocks/browser';`));
172
+ console.log(chalk.gray(` if (process.env.NODE_ENV === 'development') worker.start();`));
173
+ }
174
+ });
175
+ }