ai-spec-dev 0.17.0 → 0.25.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.
package/cli/index.ts CHANGED
@@ -38,7 +38,7 @@ import { DslExtractor } from "../core/dsl-extractor";
38
38
  import { TestGenerator } from "../core/test-generator";
39
39
  import { runErrorFeedback } from "../core/error-feedback";
40
40
  import { assessSpec, printSpecAssessment } from "../core/spec-assessor";
41
- import { accumulateReviewKnowledge } from "../core/knowledge-memory";
41
+ import { accumulateReviewKnowledge, appendDirectLesson } from "../core/knowledge-memory";
42
42
  import {
43
43
  WorkspaceLoader,
44
44
  WorkspaceConfig,
@@ -62,7 +62,14 @@ import { buildFrontendApiContract, buildContractContextSection } from "../core/c
62
62
  import { loadFrontendContext, buildFrontendContextSection } from "../core/frontend-context-loader";
63
63
  import { buildFrontendSpecPrompt, frontendSpecSystemPrompt } from "../prompts/frontend-spec.prompt";
64
64
  import { SpecDSL } from "../core/dsl-types";
65
- import { generateMockAssets, findLatestDslFile } from "../core/mock-server-generator";
65
+ import {
66
+ generateMockAssets,
67
+ findLatestDslFile,
68
+ applyMockProxy,
69
+ restoreMockProxy,
70
+ startMockServerBackground,
71
+ saveMockServerPid,
72
+ } from "../core/mock-server-generator";
66
73
  import { SpecUpdater } from "../core/spec-updater";
67
74
  import { exportOpenApi } from "../core/openapi-exporter";
68
75
 
@@ -74,6 +81,8 @@ interface AiSpecConfig {
74
81
  codegen?: CodeGenMode;
75
82
  codegenProvider?: string;
76
83
  codegenModel?: string;
84
+ /** Minimum overall spec score (1-10) required to pass Approval Gate. 0 = disabled (default). */
85
+ minSpecScore?: number;
77
86
  }
78
87
 
79
88
  const CONFIG_FILE = ".ai-spec.json";
@@ -86,22 +95,6 @@ async function loadConfig(dir: string): Promise<AiSpecConfig> {
86
95
  return {};
87
96
  }
88
97
 
89
- // ─── Constitution Length Warning ──────────────────────────────────────────────
90
-
91
- const CONSTITUTION_WARN_CHARS = 6000;
92
-
93
- function warnIfConstitutionLong(context: { constitution?: string }): void {
94
- if (!context.constitution) return;
95
- const len = context.constitution.length;
96
- if (len > CONSTITUTION_WARN_CHARS) {
97
- console.log(
98
- chalk.yellow(
99
- ` ⚠ Constitution is long (${len.toLocaleString()} chars). Consider running: ai-spec init --consolidate`
100
- )
101
- );
102
- }
103
- }
104
-
105
98
  // ─── API Key Resolution ────────────────────────────────────────────────────────
106
99
 
107
100
  async function resolveApiKey(
@@ -205,6 +198,8 @@ program
205
198
  .option("--skip-error-feedback", "Skip error feedback loop (test/lint auto-fix)")
206
199
  .option("--tdd", "TDD mode: generate failing tests first, then generate implementation to pass them")
207
200
  .option("--skip-assessment", "Skip spec quality pre-assessment before the Approval Gate")
201
+ .option("--force", "Bypass the spec quality score gate even if score is below minSpecScore")
202
+ .option("--serve", "After workspace pipeline completes, auto-start mock server + patch frontend proxy")
208
203
  .action(async (idea: string | undefined, opts) => {
209
204
  const currentDir = process.cwd();
210
205
  const config = await loadConfig(currentDir);
@@ -224,7 +219,49 @@ program
224
219
  if (workspaceConfig) {
225
220
  console.log(chalk.cyan(`\n[Workspace] Detected workspace: ${workspaceConfig.name}`));
226
221
  console.log(chalk.gray(` Repos: ${workspaceConfig.repos.map((r) => r.name).join(", ")}`));
227
- await runMultiRepoPipeline(idea!, workspaceConfig, opts, currentDir, config);
222
+ const pipelineResults = await runMultiRepoPipeline(idea!, workspaceConfig, opts, currentDir, config);
223
+
224
+ // ── Auto-serve: start mock server + patch frontend proxy ──────────────
225
+ if (opts.serve) {
226
+ console.log(chalk.blue("\n─── Auto-serve: starting mock server ───────────"));
227
+ const backendResult = pipelineResults.find((r) => r.role === "backend" && r.status === "success" && r.dsl);
228
+ const frontendResult = pipelineResults.find((r) => (r.role === "frontend" || r.role === "mobile") && r.status === "success");
229
+
230
+ if (!backendResult) {
231
+ console.log(chalk.yellow(" No successful backend with DSL found — skipping auto-serve."));
232
+ } else {
233
+ const mockPort = 3001;
234
+ // Generate mock assets in backend repo
235
+ const mockResult = await generateMockAssets(backendResult.dsl!, backendResult.repoAbsPath, { port: mockPort });
236
+ const serverJsPath = path.join(backendResult.repoAbsPath, "mock", "server.js");
237
+ console.log(chalk.green(` ✔ Mock assets generated (${mockResult.files.length} file(s))`));
238
+
239
+ // Start mock server
240
+ const pid = startMockServerBackground(serverJsPath, mockPort);
241
+ console.log(chalk.green(` ✔ Mock server started (PID ${pid}) → http://localhost:${mockPort}`));
242
+
243
+ // Patch frontend proxy
244
+ if (frontendResult) {
245
+ const proxyResult = await applyMockProxy(frontendResult.repoAbsPath, mockPort, backendResult.dsl!.endpoints);
246
+ await saveMockServerPid(frontendResult.repoAbsPath, pid);
247
+ if (proxyResult.applied) {
248
+ console.log(chalk.green(` ✔ Frontend proxy patched (${proxyResult.framework})`));
249
+ console.log(chalk.bold.cyan(`\n Ready! Run your frontend dev server:`));
250
+ console.log(chalk.white(` cd ${frontendResult.repoAbsPath}`));
251
+ console.log(chalk.white(` ${proxyResult.devCommand}`));
252
+ console.log(chalk.gray(`\n When done, restore: ai-spec mock --restore --frontend ${frontendResult.repoAbsPath}`));
253
+ } else {
254
+ console.log(chalk.yellow(` ⚠ Auto-patch not available for ${proxyResult.framework}.`));
255
+ if (proxyResult.note) console.log(chalk.gray(` ${proxyResult.note}`));
256
+ console.log(chalk.gray(` Mock server: http://localhost:${mockPort}`));
257
+ }
258
+ } else {
259
+ console.log(chalk.gray(` No frontend repo found — mock server is running at http://localhost:${mockPort}`));
260
+ console.log(chalk.gray(` Configure your frontend proxy manually to point to http://localhost:${mockPort}`));
261
+ }
262
+ }
263
+ }
264
+
228
265
  return;
229
266
  }
230
267
 
@@ -263,13 +300,15 @@ program
263
300
  const { type: detectedRepoType } = await detectRepoType(currentDir);
264
301
  console.log(chalk.gray(` Tech stack : ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
265
302
  console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
266
- warnIfConstitutionLong(context);
267
303
  console.log(chalk.gray(` API files : ${context.apiStructure.length} files`));
268
304
  if (context.schema) {
269
305
  console.log(chalk.gray(` Prisma schema: found`));
270
306
  }
271
307
  if (context.constitution) {
272
308
  console.log(chalk.green(` Constitution : found (.ai-spec-constitution.md)`));
309
+ if (context.constitution.length > 6000) {
310
+ console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
311
+ }
273
312
  } else {
274
313
  // Auto-run init: generate constitution before proceeding
275
314
  console.log(chalk.yellow(" Constitution : not found — auto-generating..."));
@@ -331,13 +370,35 @@ program
331
370
  const featureSlug = slugify(idea!);
332
371
 
333
372
  // ── Step 3.4: Spec Quality Pre-Assessment ────────────────────────────────
334
- // Advisory only — never blocks the flow. Skipped in --auto mode and with --skip-assessment.
335
- if (!opts.auto && !opts.skipAssessment) {
336
- console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
373
+ // Skipped in --skip-assessment mode.
374
+ // In --auto mode: skipped UNLESS minSpecScore is configured — the hard gate
375
+ // must still enforce even in CI/auto runs (only the interactive display is skipped).
376
+ const minScore = config.minSpecScore ?? 0;
377
+ const shouldRunAssessment = !opts.skipAssessment && (!opts.auto || minScore > 0);
378
+
379
+ if (shouldRunAssessment) {
380
+ if (!opts.auto) {
381
+ console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
382
+ }
337
383
  const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? undefined);
338
384
  if (assessment) {
339
- printSpecAssessment(assessment);
340
- } else {
385
+ if (!opts.auto) printSpecAssessment(assessment);
386
+
387
+ if (minScore > 0 && assessment.overallScore < minScore) {
388
+ if (opts.force) {
389
+ console.log(chalk.yellow(`\n ⚠ Score gate: ${assessment.overallScore}/10 < minimum ${minScore}/10 — bypassed with --force.`));
390
+ } else {
391
+ console.log(chalk.red(`\n ✘ Spec quality gate failed: overallScore ${assessment.overallScore}/10 < minimum ${minScore}/10`));
392
+ if (!opts.auto) {
393
+ console.log(chalk.gray(` Address the issues above and re-run, or use --force to bypass.`));
394
+ } else {
395
+ console.log(chalk.gray(` Auto mode: gate enforced. Fix the spec or lower minSpecScore, or use --force to bypass.`));
396
+ }
397
+ console.log(chalk.gray(` Gate threshold set in .ai-spec.json → "minSpecScore": ${minScore}`));
398
+ process.exit(1);
399
+ }
400
+ }
401
+ } else if (!opts.auto) {
341
402
  console.log(chalk.gray(" (Assessment skipped — AI call failed or timed out)"));
342
403
  }
343
404
  }
@@ -792,6 +853,7 @@ program
792
853
  )
793
854
  .option("--codegen-provider <name>", "Default provider for code generation")
794
855
  .option("--codegen-model <name>", "Default model for code generation")
856
+ .option("--min-spec-score <score>", "Minimum overall spec score (1-10) to pass Approval Gate (0 = disabled)")
795
857
  .option("--show", "Print current configuration")
796
858
  .option("--reset", "Reset configuration to empty")
797
859
  .option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json")
@@ -853,6 +915,14 @@ program
853
915
  if (opts.codegen) updated.codegen = opts.codegen as CodeGenMode;
854
916
  if (opts.codegenProvider) updated.codegenProvider = opts.codegenProvider;
855
917
  if (opts.codegenModel) updated.codegenModel = opts.codegenModel;
918
+ if (opts.minSpecScore !== undefined) {
919
+ const score = parseInt(opts.minSpecScore, 10);
920
+ if (isNaN(score) || score < 0 || score > 10) {
921
+ console.error(chalk.red(" --min-spec-score must be a number between 0 and 10"));
922
+ process.exit(1);
923
+ }
924
+ updated.minSpecScore = score;
925
+ }
856
926
 
857
927
  await fs.writeJson(configPath, updated, { spaces: 2 });
858
928
  console.log(chalk.green(`✔ Config saved to ${configPath}`));
@@ -1055,7 +1125,9 @@ async function runSingleRepoPipelineInWorkspace(opts: {
1055
1125
 
1056
1126
  console.log(chalk.gray(` Tech stack: ${context.techStack.join(", ") || "unknown"} [${detectedRepoType}]`));
1057
1127
  console.log(chalk.gray(` Dependencies: ${context.dependencies.length} packages`));
1058
- warnIfConstitutionLong(context);
1128
+ if (context.constitution && context.constitution.length > 6000) {
1129
+ console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
1130
+ }
1059
1131
 
1060
1132
  if (!context.constitution) {
1061
1133
  console.log(chalk.yellow(` Constitution: not found — auto-generating...`));
@@ -1194,13 +1266,22 @@ async function runSingleRepoPipelineInWorkspace(opts: {
1194
1266
  /**
1195
1267
  * Multi-repo pipeline: decompose → order repos → run each repo in order → bridge contracts.
1196
1268
  */
1269
+ type MultiRepoResult = {
1270
+ repoName: string;
1271
+ status: "success" | "failed" | "skipped";
1272
+ specFile: string | null;
1273
+ dsl: SpecDSL | null;
1274
+ repoAbsPath: string;
1275
+ role: string;
1276
+ };
1277
+
1197
1278
  async function runMultiRepoPipeline(
1198
1279
  idea: string,
1199
1280
  workspace: WorkspaceConfig,
1200
1281
  opts: Record<string, unknown>,
1201
1282
  currentDir: string,
1202
1283
  config: AiSpecConfig
1203
- ): Promise<void> {
1284
+ ): Promise<MultiRepoResult[]> {
1204
1285
  // ── Resolve providers ──────────────────────────────────────────────────────
1205
1286
  const specProviderName = (opts.provider as string) || config.provider || "gemini";
1206
1287
  const specModelName = (opts.model as string) || config.model || DEFAULT_MODELS[specProviderName];
@@ -1239,7 +1320,6 @@ async function runMultiRepoPipeline(
1239
1320
  try {
1240
1321
  const loader = new ContextLoader(repoAbsPath);
1241
1322
  const ctx = await loader.loadProjectContext();
1242
- warnIfConstitutionLong(ctx);
1243
1323
  contexts.set(repo.name, ctx);
1244
1324
 
1245
1325
  // Load frontend context for frontend/mobile repos
@@ -1334,19 +1414,14 @@ async function runMultiRepoPipeline(
1334
1414
  // ── Step 5: Run each repo's pipeline ──────────────────────────────────────
1335
1415
  console.log(chalk.blue(`\n[W4] Running pipeline for ${sortedRepoRequirements.length} repo(s)...`));
1336
1416
 
1337
- const results: Array<{
1338
- repoName: string;
1339
- status: "success" | "failed" | "skipped";
1340
- specFile: string | null;
1341
- dsl: SpecDSL | null;
1342
- }> = [];
1417
+ const results: MultiRepoResult[] = [];
1343
1418
 
1344
1419
  for (const repoReq of sortedRepoRequirements) {
1345
1420
  // Find repo config in workspace
1346
1421
  const repoConfig = workspace.repos.find((r) => r.name === repoReq.repoName);
1347
1422
  if (!repoConfig) {
1348
1423
  console.log(chalk.yellow(` Skipping ${repoReq.repoName} — not found in workspace config.`));
1349
- results.push({ repoName: repoReq.repoName, status: "skipped", specFile: null, dsl: null });
1424
+ results.push({ repoName: repoReq.repoName, status: "skipped", specFile: null, dsl: null, repoAbsPath: "", role: repoReq.role });
1350
1425
  continue;
1351
1426
  }
1352
1427
 
@@ -1413,11 +1488,11 @@ async function runMultiRepoPipeline(
1413
1488
  console.log(chalk.green(` Contract stored for downstream repos.`));
1414
1489
  }
1415
1490
 
1416
- results.push({ repoName: repoReq.repoName, status: "success", specFile, dsl });
1491
+ results.push({ repoName: repoReq.repoName, status: "success", specFile, dsl, repoAbsPath, role: repoReq.role });
1417
1492
  console.log(chalk.green(` ✔ ${repoReq.repoName} complete`));
1418
1493
  } catch (err) {
1419
1494
  console.error(chalk.red(` ✘ ${repoReq.repoName} failed: ${(err as Error).message}`));
1420
- results.push({ repoName: repoReq.repoName, status: "failed", specFile: null, dsl: null });
1495
+ results.push({ repoName: repoReq.repoName, status: "failed", specFile: null, dsl: null, repoAbsPath, role: repoReq.role });
1421
1496
  // Continue — don't abort other repos
1422
1497
  }
1423
1498
  }
@@ -1432,6 +1507,8 @@ async function runMultiRepoPipeline(
1432
1507
  const specInfo = r.specFile ? chalk.gray(` → ${r.specFile}`) : "";
1433
1508
  console.log(` ${icon} ${r.repoName} (${r.status})${specInfo}`);
1434
1509
  }
1510
+
1511
+ return results;
1435
1512
  }
1436
1513
 
1437
1514
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1703,7 +1780,9 @@ program
1703
1780
  console.log(chalk.gray(" Loading project context..."));
1704
1781
  const loader = new ContextLoader(currentDir);
1705
1782
  const context = await loader.loadProjectContext();
1706
- warnIfConstitutionLong(context);
1783
+ if (context.constitution && context.constitution.length > 6000) {
1784
+ console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
1785
+ }
1707
1786
 
1708
1787
  // ── Detect repo type ──────────────────────────────────────────────────────
1709
1788
  const { detectRepoType: _detectRepoType } = await import("../core/workspace-loader");
@@ -1866,16 +1945,28 @@ program
1866
1945
  .option("--msw", "Also generate MSW (Mock Service Worker) handlers at src/mocks/")
1867
1946
  .option("--proxy", "Also generate frontend proxy config snippet")
1868
1947
  .option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
1869
- .option(
1870
- "--workspace",
1871
- "Generate mock assets for all backend repos in the workspace"
1872
- )
1948
+ .option("--workspace", "Generate mock assets for all backend repos in the workspace")
1949
+ .option("--serve", "Start mock server in background + patch frontend proxy (use with --frontend)")
1950
+ .option("--frontend <path>", "Path to frontend project for proxy patching (used with --serve/--restore)")
1951
+ .option("--restore", "Undo proxy changes and stop mock server (requires --frontend or auto-detects)")
1873
1952
  .action(async (opts) => {
1874
1953
  const currentDir = process.cwd();
1875
1954
  const port = parseInt(opts.port, 10) || 3001;
1876
1955
 
1877
1956
  console.log(chalk.blue("\n─── ai-spec mock ───────────────────────────────"));
1878
1957
 
1958
+ // ── Restore mode ──────────────────────────────────────────────────────────
1959
+ if (opts.restore) {
1960
+ const frontendDir = opts.frontend ? path.resolve(opts.frontend) : currentDir;
1961
+ const r = await restoreMockProxy(frontendDir);
1962
+ if (r.restored) {
1963
+ console.log(chalk.green(" ✔ Proxy restored and mock server stopped."));
1964
+ } else {
1965
+ console.log(chalk.yellow(` ${r.note ?? "Nothing to restore."}`));
1966
+ }
1967
+ return;
1968
+ }
1969
+
1879
1970
  // ── Workspace mode ────────────────────────────────────────────────────────
1880
1971
  if (opts.workspace) {
1881
1972
  const workspaceLoader = new WorkspaceLoader(currentDir);
@@ -1952,11 +2043,47 @@ program
1952
2043
  console.log(chalk.gray(` ${f.description}`));
1953
2044
  }
1954
2045
 
2046
+ // ── Serve mode: start mock server + patch frontend proxy ──────────────────
2047
+ if (opts.serve) {
2048
+ const serverJsPath = path.join(currentDir, "mock", "server.js");
2049
+ if (!(await fs.pathExists(serverJsPath))) {
2050
+ console.error(chalk.red(" mock/server.js not found — generation may have failed."));
2051
+ process.exit(1);
2052
+ }
2053
+
2054
+ // Start mock server in background
2055
+ const pid = startMockServerBackground(serverJsPath, port);
2056
+ console.log(chalk.green(`\n ✔ Mock server started (PID ${pid}) → http://localhost:${port}`));
2057
+
2058
+ // Apply frontend proxy patch
2059
+ if (opts.frontend) {
2060
+ const frontendDir = path.resolve(opts.frontend);
2061
+ const proxyResult = await applyMockProxy(frontendDir, port, dsl.endpoints);
2062
+ await saveMockServerPid(frontendDir, pid);
2063
+
2064
+ if (proxyResult.applied) {
2065
+ console.log(chalk.green(` ✔ Frontend proxy patched (${proxyResult.framework})`));
2066
+ console.log(chalk.bold.cyan(`\n Ready! Open a new terminal and run:`));
2067
+ console.log(chalk.white(` cd ${frontendDir}`));
2068
+ console.log(chalk.white(` ${proxyResult.devCommand}`));
2069
+ console.log(chalk.gray(`\n When done: ai-spec mock --restore --frontend ${frontendDir}`));
2070
+ } else {
2071
+ console.log(chalk.yellow(` ⚠ Auto-patch not available for ${proxyResult.framework}.`));
2072
+ if (proxyResult.note) console.log(chalk.gray(` ${proxyResult.note}`));
2073
+ }
2074
+ } else {
2075
+ console.log(chalk.gray(` Tip: use --frontend <path> to also auto-patch your frontend proxy config.`));
2076
+ console.log(chalk.gray(` Mock server: http://localhost:${port}`));
2077
+ }
2078
+ return;
2079
+ }
2080
+
1955
2081
  console.log(chalk.blue("\n─── Quick start ────────────────────────────────"));
1956
2082
  console.log(chalk.white(` 1. Install express (if not already):`));
1957
2083
  console.log(chalk.gray(` npm install --save-dev express`));
1958
2084
  console.log(chalk.white(` 2. Start mock server:`));
1959
2085
  console.log(chalk.gray(` node mock/server.js`));
2086
+ console.log(chalk.gray(` # or: ai-spec mock --serve --frontend <path-to-frontend>`));
1960
2087
  console.log(chalk.white(` 3. Configure your frontend to proxy API calls to:`));
1961
2088
  console.log(chalk.gray(` http://localhost:${port}`));
1962
2089
  if (opts.proxy) {
@@ -1969,6 +2096,37 @@ program
1969
2096
  }
1970
2097
  });
1971
2098
 
2099
+ // ═══════════════════════════════════════════════════════════════════════════════
2100
+ // Command: learn — zero-friction knowledge injection into constitution §9
2101
+ // ═══════════════════════════════════════════════════════════════════════════════
2102
+
2103
+ program
2104
+ .command("learn")
2105
+ .description("Append a lesson or engineering decision directly to constitution §9")
2106
+ .argument("[lesson]", "The lesson or decision to record (prompted if omitted)")
2107
+ .action(async (lesson: string | undefined) => {
2108
+ const currentDir = process.cwd();
2109
+
2110
+ if (!lesson) {
2111
+ const { default: inquirerInput } = await import("@inquirer/prompts").then(
2112
+ (m) => ({ default: m.input })
2113
+ );
2114
+ lesson = await inquirerInput({
2115
+ message: "What lesson or engineering decision should be recorded?",
2116
+ validate: (v) => v.trim().length > 0 || "Please enter a lesson",
2117
+ });
2118
+ }
2119
+
2120
+ const result = await appendDirectLesson(currentDir, lesson.trim());
2121
+
2122
+ if (result.appended) {
2123
+ console.log(chalk.green(`\n ✔ Lesson appended to constitution §9`));
2124
+ console.log(chalk.gray(` File: .ai-spec-constitution.md`));
2125
+ } else {
2126
+ console.log(chalk.yellow(`\n ⚠ Not appended: ${result.reason}`));
2127
+ }
2128
+ });
2129
+
1972
2130
  // Show welcome screen when invoked with no arguments
1973
2131
  if (process.argv.length <= 2) {
1974
2132
  (async () => {