ai-spec-dev 0.35.0 → 0.37.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 (38) hide show
  1. package/RELEASE_LOG.md +139 -0
  2. package/cli/commands/config.ts +18 -0
  3. package/cli/commands/create.ts +16 -1
  4. package/cli/utils.ts +4 -0
  5. package/core/code-generator.ts +6 -4
  6. package/core/dsl-extractor.ts +9 -1
  7. package/core/dsl-feedback.ts +7 -1
  8. package/core/dsl-validator.ts +32 -0
  9. package/core/key-store.ts +5 -4
  10. package/core/provider-utils.ts +39 -4
  11. package/dist/cli/index.js +121 -14
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/index.mjs +122 -15
  14. package/dist/cli/index.mjs.map +1 -1
  15. package/dist/index.d.mts +16 -1
  16. package/dist/index.d.ts +16 -1
  17. package/dist/index.js +77 -8
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +77 -9
  20. package/dist/index.mjs.map +1 -1
  21. package/package.json +1 -1
  22. package/tests/code-generator.test.ts +253 -0
  23. package/tests/context-loader.test.ts +207 -0
  24. package/tests/dsl-validator.test.ts +105 -0
  25. package/tests/mock-server-generator.test.ts +404 -0
  26. package/tests/openapi-exporter.test.ts +310 -0
  27. package/tests/reviewer.test.ts +214 -0
  28. package/tests/spec-generator.test.ts +228 -0
  29. package/tests/spec-versioning.test.ts +205 -0
  30. package/tests/types-generator.test.ts +347 -0
  31. package/tests/vcr.test.ts +355 -0
  32. package/.claude/commands/add-lesson.md +0 -34
  33. package/.claude/commands/check-layers.md +0 -65
  34. package/.claude/commands/installed-deps.md +0 -35
  35. package/.claude/commands/recall-lessons.md +0 -40
  36. package/.claude/commands/scan-singletons.md +0 -45
  37. package/.claude/commands/verify-imports.md +0 -48
  38. package/.claude/settings.local.json +0 -24
package/dist/cli/index.js CHANGED
@@ -152,14 +152,49 @@ function classifyError(err, label) {
152
152
  const e = err;
153
153
  const status = e.status ?? e.response?.status;
154
154
  if (status === 401 || status === 403)
155
- return new ProviderError(`Auth error \u2014 check your API key (${label})`, "auth", err);
155
+ return new ProviderError(
156
+ `Auth error (${label}): API key is invalid or expired.
157
+ \u2192 Check that the correct API key is set in your environment or ~/.ai-spec-keys.json
158
+ \u2192 Run "ai-spec model" to reconfigure your provider and key`,
159
+ "auth",
160
+ err
161
+ );
156
162
  if (status === 429)
157
- return new ProviderError(`Rate limit hit (${label}) \u2014 try again later or switch provider`, "rate_limit", err);
163
+ return new ProviderError(
164
+ `Rate limit hit (${label}): too many requests.
165
+ \u2192 Wait a few minutes and retry, or switch to a different provider/model
166
+ \u2192 Check your provider's billing dashboard for quota status`,
167
+ "rate_limit",
168
+ err
169
+ );
158
170
  if (e._timeout || e.message?.toLowerCase().includes("timed out"))
159
171
  return new ProviderError(`Request timed out (${label})`, "timeout", err);
160
172
  if (e.code === "ECONNRESET" || e.code === "ENOTFOUND" || e.code === "ECONNREFUSED")
161
- return new ProviderError(`Network error \u2014 check connection/proxy (${label}): ${e.message}`, "network", err);
162
- return new ProviderError(`Provider error (${label}): ${e.message}`, "provider", err);
173
+ return new ProviderError(
174
+ `Network error (${label}): ${e.message}
175
+ \u2192 Check your internet connection and proxy settings (HTTPS_PROXY)
176
+ \u2192 If behind a firewall, ensure the provider's API endpoint is reachable`,
177
+ "network",
178
+ err
179
+ );
180
+ const msg = e.message ?? "";
181
+ if (status === 404 || msg.includes("model") && (msg.includes("not found") || msg.includes("does not exist")))
182
+ return new ProviderError(
183
+ `Model not found (${label}): ${msg}
184
+ \u2192 Run "ai-spec model" to see available models for your provider
185
+ \u2192 The model name may have changed \u2014 check your provider's documentation`,
186
+ "provider",
187
+ err
188
+ );
189
+ if (msg.includes("insufficient") || msg.includes("quota") || msg.includes("balance"))
190
+ return new ProviderError(
191
+ `Quota/balance error (${label}): ${msg}
192
+ \u2192 Check your provider's billing dashboard
193
+ \u2192 Consider switching to a different provider with "ai-spec model"`,
194
+ "provider",
195
+ err
196
+ );
197
+ return new ProviderError(`Provider error (${label}): ${msg}`, "provider", err);
163
198
  }
164
199
  function isRetryable(err) {
165
200
  const e = err;
@@ -4813,6 +4848,21 @@ function validateDsl(raw) {
4813
4848
  for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
4814
4849
  validateEndpoint(eps[i], `endpoints[${i}]`, errors);
4815
4850
  }
4851
+ const seenEpIds = /* @__PURE__ */ new Set();
4852
+ for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
4853
+ const ep = eps[i];
4854
+ if (ep && typeof ep === "object" && typeof ep["id"] === "string") {
4855
+ const id = ep["id"];
4856
+ if (seenEpIds.has(id)) {
4857
+ errors.push({
4858
+ path: `endpoints[${i}].id`,
4859
+ message: `Duplicate endpoint id "${id}" \u2014 each endpoint must have a unique id`
4860
+ });
4861
+ } else {
4862
+ seenEpIds.add(id);
4863
+ }
4864
+ }
4865
+ }
4816
4866
  }
4817
4867
  if (obj["behaviors"] !== void 0) {
4818
4868
  if (!Array.isArray(obj["behaviors"])) {
@@ -4869,6 +4919,21 @@ function validateModel(raw, path40, errors) {
4869
4919
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
4870
4920
  validateModelField(fields[j2], `${path40}.fields[${j2}]`, errors);
4871
4921
  }
4922
+ const seenFieldNames = /* @__PURE__ */ new Set();
4923
+ for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
4924
+ const f = fields[j2];
4925
+ if (f && typeof f === "object" && typeof f["name"] === "string") {
4926
+ const name = f["name"];
4927
+ if (seenFieldNames.has(name)) {
4928
+ errors.push({
4929
+ path: `${path40}.fields[${j2}].name`,
4930
+ message: `Duplicate field name "${name}" \u2014 each field within a model must have a unique name`
4931
+ });
4932
+ } else {
4933
+ seenFieldNames.add(name);
4934
+ }
4935
+ }
4936
+ }
4872
4937
  }
4873
4938
  if (m["relations"] !== void 0) {
4874
4939
  if (!Array.isArray(m["relations"])) {
@@ -5336,7 +5401,10 @@ var DslExtractor = class {
5336
5401
  * - throws if user chose to abort
5337
5402
  */
5338
5403
  async extract(specContent, opts = {}) {
5339
- const specForAI = specContent.length > MAX_SPEC_CHARS ? specContent.slice(0, MAX_SPEC_CHARS) + "\n... (truncated for DSL extraction)" : specContent;
5404
+ const specForAI = specContent.length > MAX_SPEC_CHARS ? (() => {
5405
+ console.log(import_chalk6.default.yellow(` \u26A0 Spec is ${specContent.length} chars \u2014 truncating to ${MAX_SPEC_CHARS} for DSL extraction. Details at the end may be lost.`));
5406
+ return specContent.slice(0, MAX_SPEC_CHARS) + "\n... (truncated for DSL extraction)";
5407
+ })() : specContent;
5340
5408
  let lastRawOutput = "";
5341
5409
  let lastErrors = [];
5342
5410
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
@@ -5360,6 +5428,11 @@ var DslExtractor = class {
5360
5428
  parsed = parseJsonFromOutput(rawOutput);
5361
5429
  } catch (parseErr) {
5362
5430
  console.log(import_chalk6.default.red(` \u2718 Failed to parse JSON from AI output: ${parseErr.message}`));
5431
+ const preview = rawOutput.slice(0, 500).replace(/\n/g, "\\n");
5432
+ console.log(import_chalk6.default.gray(` AI output preview (first 500 chars): ${preview}`));
5433
+ if (rawOutput.length > MAX_SPEC_CHARS) {
5434
+ console.log(import_chalk6.default.gray(` Note: spec was truncated to ${MAX_SPEC_CHARS} chars \u2014 long specs may lose context`));
5435
+ }
5363
5436
  lastErrors = [{ path: "root", message: "Output is not valid JSON \u2014 see raw output above" }];
5364
5437
  if (attempt < MAX_RETRIES) continue;
5365
5438
  return this.handleFailure(opts, "AI produced invalid JSON after retries");
@@ -6468,9 +6541,10 @@ ${tasks.map((t) => `${t.id} [${t.layer}] ${t.title}
6468
6541
  console.log(import_chalk8.default.cyan(` \u{1F916} Auto mode: running claude -p (non-interactive)...`));
6469
6542
  console.log(import_chalk8.default.gray(` Spec: ${specFilePath}`));
6470
6543
  try {
6471
- (0, import_child_process.execSync)(`${claudeCmd} -p "${promptContent.replace(/"/g, '\\"')}"`, {
6544
+ (0, import_child_process.spawnSync)(claudeCmd, ["-p", promptContent], {
6472
6545
  cwd: workingDir,
6473
- stdio: "inherit"
6546
+ stdio: "inherit",
6547
+ shell: false
6474
6548
  });
6475
6549
  console.log(import_chalk8.default.green("\n \u2714 Claude Code completed."));
6476
6550
  } catch {
@@ -6522,9 +6596,10 @@ Full spec is at: ${specFilePath}
6522
6596
  Implement ONLY this task. Do not implement other tasks.`;
6523
6597
  let taskStatus = "done";
6524
6598
  try {
6525
- (0, import_child_process.execSync)(`${claudeCmd} -p "${taskPrompt.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`, {
6599
+ (0, import_child_process.spawnSync)(claudeCmd, ["-p", taskPrompt], {
6526
6600
  cwd: workingDir,
6527
- stdio: "inherit"
6601
+ stdio: "inherit",
6602
+ shell: false
6528
6603
  });
6529
6604
  completed++;
6530
6605
  } catch {
@@ -9306,7 +9381,7 @@ function assessDslRichness(dsl) {
9306
9381
  const endpointsWithoutErrors = dsl.endpoints.filter(
9307
9382
  (ep) => !ep.errors || ep.errors.length === 0
9308
9383
  );
9309
- if (endpointsWithoutErrors.length > 0 && dsl.endpoints.length >= 2) {
9384
+ if (endpointsWithoutErrors.length === dsl.endpoints.length && dsl.endpoints.length >= 2) {
9310
9385
  gaps.push({
9311
9386
  code: "missing_errors",
9312
9387
  message: `${endpointsWithoutErrors.length}/${dsl.endpoints.length} endpoints have no error definitions`,
@@ -9892,13 +9967,15 @@ async function readStore() {
9892
9967
  if (await fs19.pathExists(KEY_STORE_FILE)) {
9893
9968
  return await fs19.readJson(KEY_STORE_FILE);
9894
9969
  }
9895
- } catch {
9970
+ } catch (err) {
9971
+ console.warn(`Warning: Could not read key store at ${KEY_STORE_FILE}: ${err.message}. Using empty store.`);
9896
9972
  }
9897
9973
  return {};
9898
9974
  }
9899
9975
  async function writeStore(store) {
9900
- await fs19.writeJson(KEY_STORE_FILE, store, { spaces: 2 });
9976
+ await fs19.ensureFile(KEY_STORE_FILE);
9901
9977
  await fs19.chmod(KEY_STORE_FILE, 384);
9978
+ await fs19.writeJson(KEY_STORE_FILE, store, { spaces: 2 });
9902
9979
  }
9903
9980
  async function getSavedKey(provider) {
9904
9981
  const store = await readStore();
@@ -10991,8 +11068,10 @@ function registerCreate(program2) {
10991
11068
  console.log(import_chalk20.default.cyan("[8/9] TDD mode \u2014 error feedback loop driving implementation to pass tests..."));
10992
11069
  }
10993
11070
  runLogger.stageStart("error_feedback");
11071
+ const defaultCycles = opts.tdd ? 3 : 2;
11072
+ const maxCycles = config2.maxErrorCycles ?? defaultCycles;
10994
11073
  compilePassed = await runErrorFeedback(codegenProvider, workingDir, extractedDsl, {
10995
- maxCycles: opts.tdd ? 3 : 2
11074
+ maxCycles
10996
11075
  });
10997
11076
  runLogger.stageEnd("error_feedback");
10998
11077
  }
@@ -11095,6 +11174,18 @@ function registerCreate(program2) {
11095
11174
  logger: runLogger
11096
11175
  });
11097
11176
  printSelfEval(selfEvalResult);
11177
+ const minHarness = config2.minHarnessScore ?? 0;
11178
+ if (minHarness > 0 && selfEvalResult.harnessScore < minHarness && !opts.force) {
11179
+ console.log(import_chalk20.default.red(
11180
+ `
11181
+ \u2718 Harness score ${selfEvalResult.harnessScore}/10 is below the minimum threshold ${minHarness}/10.`
11182
+ ));
11183
+ console.log(import_chalk20.default.gray(` Gate threshold set in .ai-spec.json \u2192 "minHarnessScore": ${minHarness}`));
11184
+ console.log(import_chalk20.default.gray(` Use --force to bypass, or improve the spec and re-run.`));
11185
+ runLogger.stageEnd("self_eval", { gateBlocked: true, score: selfEvalResult.harnessScore, threshold: minHarness });
11186
+ runLogger.finish();
11187
+ process.exit(1);
11188
+ }
11098
11189
  if (accumulatePromise) await accumulatePromise;
11099
11190
  if (specVcrRecorder) {
11100
11191
  const vcrPath = await specVcrRecorder.save(currentDir, runId, codegenVcrRecorder ?? void 0);
@@ -11666,7 +11757,7 @@ var path26 = __toESM(require("path"));
11666
11757
  var fs27 = __toESM(require("fs-extra"));
11667
11758
  var import_chalk24 = __toESM(require("chalk"));
11668
11759
  function registerConfig(program2) {
11669
- program2.command("config").description(`Set default configuration for this project (saved to ${CONFIG_FILE})`).option("--provider <name>", "Default AI provider for spec generation").option("--model <name>", "Default model for spec generation").option("--codegen <mode>", "Default code generation mode (claude-code|api|plan)").option("--codegen-provider <name>", "Default provider for code generation").option("--codegen-model <name>", "Default model for code generation").option("--min-spec-score <score>", "Minimum overall spec score (1-10) to pass Approval Gate (0 = disabled)").option("--show", "Print current configuration").option("--reset", "Reset configuration to empty").option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json").option("--clear-key <provider>", "Delete saved API key for a specific provider").option("--list-keys", "Show which providers have a saved key").action(async (opts) => {
11760
+ program2.command("config").description(`Set default configuration for this project (saved to ${CONFIG_FILE})`).option("--provider <name>", "Default AI provider for spec generation").option("--model <name>", "Default model for spec generation").option("--codegen <mode>", "Default code generation mode (claude-code|api|plan)").option("--codegen-provider <name>", "Default provider for code generation").option("--codegen-model <name>", "Default model for code generation").option("--min-spec-score <score>", "Minimum overall spec score (1-10) to pass Approval Gate (0 = disabled)").option("--min-harness-score <score>", "Minimum harness score (1-10) for pipeline success (0 = disabled)").option("--max-error-cycles <n>", "Maximum error-feedback fix cycles (1-10, default: 2)").option("--show", "Print current configuration").option("--reset", "Reset configuration to empty").option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json").option("--clear-key <provider>", "Delete saved API key for a specific provider").option("--list-keys", "Show which providers have a saved key").action(async (opts) => {
11670
11761
  const currentDir = process.cwd();
11671
11762
  const configPath = path26.join(currentDir, CONFIG_FILE);
11672
11763
  if (opts.clearKeys) {
@@ -11724,6 +11815,22 @@ File: ${KEY_STORE_FILE}`));
11724
11815
  }
11725
11816
  updated.minSpecScore = score;
11726
11817
  }
11818
+ if (opts.minHarnessScore !== void 0) {
11819
+ const score = parseInt(opts.minHarnessScore, 10);
11820
+ if (isNaN(score) || score < 0 || score > 10) {
11821
+ console.error(import_chalk24.default.red(" --min-harness-score must be a number between 0 and 10"));
11822
+ process.exit(1);
11823
+ }
11824
+ updated.minHarnessScore = score;
11825
+ }
11826
+ if (opts.maxErrorCycles !== void 0) {
11827
+ const cycles = parseInt(opts.maxErrorCycles, 10);
11828
+ if (isNaN(cycles) || cycles < 1 || cycles > 10) {
11829
+ console.error(import_chalk24.default.red(" --max-error-cycles must be a number between 1 and 10"));
11830
+ process.exit(1);
11831
+ }
11832
+ updated.maxErrorCycles = cycles;
11833
+ }
11727
11834
  await fs27.writeJson(configPath, updated, { spaces: 2 });
11728
11835
  console.log(import_chalk24.default.green(`\u2714 Config saved to ${configPath}`));
11729
11836
  console.log(JSON.stringify(updated, null, 2));