ai-spec-dev 0.42.0 → 0.46.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 (49) hide show
  1. package/README.md +33 -17
  2. package/cli/commands/create.ts +232 -11
  3. package/cli/commands/init.ts +310 -107
  4. package/cli/commands/model.ts +7 -11
  5. package/cli/index.ts +1 -1
  6. package/cli/utils.ts +72 -4
  7. package/core/config-defaults.ts +44 -0
  8. package/core/constitution-generator.ts +2 -1
  9. package/core/dsl-extractor.ts +2 -1
  10. package/core/error-feedback.ts +3 -2
  11. package/core/openapi-exporter.ts +3 -2
  12. package/core/repo-store.ts +95 -0
  13. package/core/reviewer.ts +14 -13
  14. package/core/run-logger.ts +3 -4
  15. package/core/run-snapshot.ts +2 -3
  16. package/core/run-trend.ts +3 -4
  17. package/core/spec-generator.ts +27 -42
  18. package/core/token-budget.ts +3 -8
  19. package/core/vcr.ts +3 -1
  20. package/dist/cli/index.js +919 -519
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/index.mjs +912 -512
  23. package/dist/cli/index.mjs.map +1 -1
  24. package/dist/index.d.mts +3 -2
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.js +43 -53
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +43 -53
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +1 -1
  31. package/demo-backend/.ai-spec-constitution.md +0 -65
  32. package/demo-backend/package.json +0 -21
  33. package/demo-backend/prisma/schema.prisma +0 -22
  34. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +0 -186
  35. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +0 -211
  36. package/demo-backend/src/controllers/bookmark.controller.test.ts +0 -255
  37. package/demo-backend/src/controllers/bookmark.controller.ts +0 -187
  38. package/demo-backend/src/index.ts +0 -17
  39. package/demo-backend/src/routes/bookmark.routes.test.ts +0 -264
  40. package/demo-backend/src/routes/bookmark.routes.ts +0 -11
  41. package/demo-backend/src/routes/index.ts +0 -8
  42. package/demo-backend/src/services/bookmark.service.test.ts +0 -433
  43. package/demo-backend/src/services/bookmark.service.ts +0 -261
  44. package/demo-backend/tsconfig.json +0 -12
  45. package/demo-frontend/.ai-spec-constitution.md +0 -95
  46. package/demo-frontend/package.json +0 -23
  47. package/demo-frontend/src/App.tsx +0 -12
  48. package/demo-frontend/src/main.tsx +0 -9
  49. package/demo-frontend/tsconfig.json +0 -13
@@ -4,6 +4,9 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
4
4
  var __esm = (fn, res) => function __init() {
5
5
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
6
  };
7
+ var __commonJS = (cb, mod) => function __require() {
8
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
+ };
7
10
  var __export = (target, all) => {
8
11
  for (var name in all)
9
12
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -341,7 +344,6 @@ __export(spec_generator_exports, {
341
344
  import { GoogleGenerativeAI } from "@google/generative-ai";
342
345
  import Anthropic from "@anthropic-ai/sdk";
343
346
  import OpenAI from "openai";
344
- import axios from "axios";
345
347
  import { ProxyAgent } from "undici";
346
348
  function geminiRequestOptions() {
347
349
  const proxyUrl = process.env.GEMINI_PROXY || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
@@ -387,7 +389,9 @@ var init_spec_generator = __esm({
387
389
  displayName: "MiMo (Xiaomi)",
388
390
  description: "\u5C0F\u7C73 MiMo \u2014 mimo-v2-pro (Anthropic-compatible API)",
389
391
  models: ["mimo-v2-pro"],
390
- envKey: "MIMO_API_KEY"
392
+ envKey: "MIMO_API_KEY",
393
+ // Fallback env var — MiMo's token plan uses ANTHROPIC_AUTH_TOKEN
394
+ fallbackEnvKeys: ["ANTHROPIC_AUTH_TOKEN"]
391
395
  // baseURL not used — MiMo has a dedicated provider class
392
396
  },
393
397
  gemini: {
@@ -556,8 +560,8 @@ var init_spec_generator = __esm({
556
560
  ...systemInstruction ? { system: systemInstruction } : {},
557
561
  messages: [{ role: "user", content: prompt }]
558
562
  });
559
- const block = message.content[0];
560
- if (block.type === "text") return block.text;
563
+ const textBlock = message.content.find((b) => b.type === "text");
564
+ if (textBlock) return textBlock.text;
561
565
  throw new Error("Unexpected response type from Claude API");
562
566
  },
563
567
  { label: `${this.providerName}/${this.modelName}` }
@@ -602,43 +606,29 @@ var init_spec_generator = __esm({
602
606
  }
603
607
  };
604
608
  MiMoProvider = class {
609
+ client;
605
610
  providerName = "mimo";
606
611
  modelName;
607
- apiKey;
608
- baseUrl = "https://api.xiaomimimo.com/anthropic/v1/messages";
609
612
  constructor(apiKey, modelName = PROVIDER_CATALOG.mimo.models[0]) {
610
- this.apiKey = apiKey;
613
+ const baseURL = process.env["MIMO_BASE_URL"] || process.env["ANTHROPIC_BASE_URL"] || "https://token-plan-cn.xiaomimimo.com/anthropic";
614
+ this.client = new Anthropic({ apiKey, baseURL });
611
615
  this.modelName = modelName;
612
616
  }
613
617
  async generate(prompt, systemInstruction) {
614
618
  return withReliability(
615
619
  async () => {
616
- const body = {
620
+ const stream = this.client.messages.stream({
617
621
  model: this.modelName,
618
- max_tokens: 16384,
619
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
620
- top_p: 0.95,
621
- stream: false,
622
- temperature: 1,
623
- stop_sequences: null
624
- };
625
- if (systemInstruction) {
626
- body.system = systemInstruction;
627
- }
628
- const response = await axios.post(this.baseUrl, body, {
629
- headers: {
630
- "api-key": this.apiKey,
631
- "Content-Type": "application/json"
632
- }
622
+ max_tokens: 65536,
623
+ ...systemInstruction ? { system: systemInstruction } : {},
624
+ messages: [{ role: "user", content: prompt }]
633
625
  });
634
- const data = response.data;
635
- const blocks = data?.content ?? [];
636
- const textBlock = blocks.find((b) => b.type === "text");
637
- if (textBlock?.text) return textBlock.text;
638
- if (data?.stop_reason === "max_tokens") {
639
- throw new Error(`MiMo response truncated (max_tokens reached). The prompt may be too long. Try a shorter spec or switch to a model with larger context.`);
640
- }
641
- throw new Error(`Unexpected MiMo response: ${JSON.stringify(response.data).slice(0, 200)}`);
626
+ const message = await stream.finalMessage();
627
+ const textBlock = message.content.find((b) => b.type === "text");
628
+ if (textBlock) return textBlock.text;
629
+ const thinkBlock = message.content.find((b) => b.type === "thinking");
630
+ if (thinkBlock) return thinkBlock.thinking;
631
+ return message.content.map((b) => b.text ?? "").join("");
642
632
  },
643
633
  { label: `${this.providerName}/${this.modelName}` }
644
634
  );
@@ -699,14 +689,71 @@ ${context.schema.slice(0, 3e3)}`);
699
689
  }
700
690
  });
701
691
 
692
+ // package.json
693
+ var require_package = __commonJS({
694
+ "package.json"(exports, module) {
695
+ module.exports = {
696
+ name: "ai-spec-dev",
697
+ version: "0.46.0",
698
+ description: "AI-driven Development Orchestrator SDK & CLI",
699
+ main: "dist/index.js",
700
+ types: "dist/index.d.ts",
701
+ bin: {
702
+ "ai-spec": "dist/cli/index.js"
703
+ },
704
+ scripts: {
705
+ build: "tsup",
706
+ dev: "tsup --watch",
707
+ test: "vitest run",
708
+ "test:watch": "vitest"
709
+ },
710
+ keywords: [
711
+ "ai",
712
+ "spec",
713
+ "codegen",
714
+ "gemini",
715
+ "claude"
716
+ ],
717
+ author: "",
718
+ license: "MIT",
719
+ dependencies: {
720
+ "@anthropic-ai/sdk": "^0.38.0",
721
+ "@google/generative-ai": "^0.21.0",
722
+ "@inquirer/editor": "^5.0.10",
723
+ "@inquirer/prompts": "^8.3.2",
724
+ "@rollup/rollup-darwin-arm64": "^4.60.1",
725
+ axios: "^1.13.6",
726
+ chalk: "^4.1.2",
727
+ commander: "^13.1.0",
728
+ dotenv: "^16.4.7",
729
+ "fs-extra": "^11.3.0",
730
+ openai: "^6.31.0",
731
+ undici: "^7.24.4"
732
+ },
733
+ devDependencies: {
734
+ "@types/fs-extra": "^11.0.4",
735
+ "@types/glob": "^8.1.0",
736
+ "@types/node": "^22.13.5",
737
+ glob: "^13.0.6",
738
+ "ts-node": "^10.9.2",
739
+ tsup: "^8.4.0",
740
+ typescript: "^5.7.3",
741
+ vitest: "^2.1.0"
742
+ }
743
+ };
744
+ }
745
+ });
746
+
702
747
  // cli/index.ts
703
748
  import { Command } from "commander";
704
749
  import * as dotenv from "dotenv";
705
750
 
706
751
  // cli/commands/create.ts
707
752
  init_spec_generator();
753
+ import * as path26 from "path";
754
+ import * as fs27 from "fs-extra";
708
755
  import chalk26 from "chalk";
709
- import { input as input2 } from "@inquirer/prompts";
756
+ import { input as input2, select as select7, checkbox } from "@inquirer/prompts";
710
757
 
711
758
  // core/workspace-loader.ts
712
759
  import * as fs from "fs-extra";
@@ -806,8 +853,8 @@ var WorkspaceLoader = class {
806
853
  const repos = [];
807
854
  for (const entry of entries) {
808
855
  const absPath = path.join(this.workspaceRoot, entry);
809
- const stat4 = await fs.stat(absPath).catch(() => null);
810
- if (!stat4 || !stat4.isDirectory()) continue;
856
+ const stat5 = await fs.stat(absPath).catch(() => null);
857
+ if (!stat5 || !stat5.isDirectory()) continue;
811
858
  if (entry.startsWith(".") || entry === "node_modules") continue;
812
859
  if (names && !names.includes(entry)) continue;
813
860
  const hasManifest = await fs.pathExists(path.join(absPath, "package.json")) || await fs.pathExists(path.join(absPath, "go.mod")) || await fs.pathExists(path.join(absPath, "Cargo.toml")) || await fs.pathExists(path.join(absPath, "pom.xml")) || await fs.pathExists(path.join(absPath, "build.gradle")) || await fs.pathExists(path.join(absPath, "requirements.txt")) || await fs.pathExists(path.join(absPath, "pyproject.toml")) || await fs.pathExists(path.join(absPath, "composer.json"));
@@ -871,6 +918,7 @@ var WorkspaceLoader = class {
871
918
  init_spec_generator();
872
919
  import * as path3 from "path";
873
920
  import * as fs3 from "fs-extra";
921
+ import * as os2 from "os";
874
922
  import chalk2 from "chalk";
875
923
  import { input, select } from "@inquirer/prompts";
876
924
 
@@ -916,17 +964,42 @@ async function clearKey(provider) {
916
964
 
917
965
  // cli/utils.ts
918
966
  var CONFIG_FILE = ".ai-spec.json";
967
+ var GLOBAL_CONFIG_FILE = path3.join(os2.homedir(), ".ai-spec-config.json");
968
+ async function loadGlobalConfig() {
969
+ try {
970
+ if (await fs3.pathExists(GLOBAL_CONFIG_FILE)) {
971
+ return await fs3.readJson(GLOBAL_CONFIG_FILE);
972
+ }
973
+ } catch {
974
+ }
975
+ return {};
976
+ }
977
+ async function saveGlobalConfig(config2) {
978
+ await fs3.ensureFile(GLOBAL_CONFIG_FILE);
979
+ await fs3.writeJson(GLOBAL_CONFIG_FILE, config2, { spaces: 2 });
980
+ }
919
981
  async function loadConfig(dir) {
982
+ const globalConfig = await loadGlobalConfig();
983
+ let localConfig = {};
920
984
  const p = path3.join(dir, CONFIG_FILE);
921
985
  if (await fs3.pathExists(p)) {
922
- return fs3.readJson(p);
986
+ try {
987
+ localConfig = await fs3.readJson(p);
988
+ } catch {
989
+ }
923
990
  }
924
- return {};
991
+ return { ...globalConfig, ...localConfig };
925
992
  }
926
993
  async function resolveApiKey(providerName, cliKey) {
927
994
  if (cliKey) return cliKey;
928
995
  const envVar = ENV_KEY_MAP[providerName];
929
996
  if (envVar && process.env[envVar]) return process.env[envVar];
997
+ const meta = PROVIDER_CATALOG[providerName];
998
+ if (meta?.fallbackEnvKeys) {
999
+ for (const key of meta.fallbackEnvKeys) {
1000
+ if (process.env[key]) return process.env[key];
1001
+ }
1002
+ }
930
1003
  const savedKey = await getSavedKey(providerName);
931
1004
  if (savedKey) {
932
1005
  const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);
@@ -1000,7 +1073,7 @@ var pe = "\0PERIOD" + Math.random() + "\0";
1000
1073
  var is = new RegExp(fe, "g");
1001
1074
  var rs = new RegExp(ue, "g");
1002
1075
  var ns = new RegExp(qt, "g");
1003
- var os2 = new RegExp(de, "g");
1076
+ var os3 = new RegExp(de, "g");
1004
1077
  var hs = new RegExp(pe, "g");
1005
1078
  var as = /\\\\/g;
1006
1079
  var ls = /\\{/g;
@@ -1015,7 +1088,7 @@ function ps(n7) {
1015
1088
  return n7.replace(as, fe).replace(ls, ue).replace(cs, qt).replace(fs4, de).replace(us, pe);
1016
1089
  }
1017
1090
  function ms(n7) {
1018
- return n7.replace(is, "\\").replace(rs, "{").replace(ns, "}").replace(os2, ",").replace(hs, ".");
1091
+ return n7.replace(is, "\\").replace(rs, "{").replace(ns, "}").replace(os3, ",").replace(hs, ".");
1019
1092
  }
1020
1093
  function me(n7) {
1021
1094
  if (!n7) return [""];
@@ -3945,11 +4018,11 @@ Ze.glob = Ze;
3945
4018
  // core/global-constitution.ts
3946
4019
  import * as fs5 from "fs-extra";
3947
4020
  import * as path4 from "path";
3948
- import * as os3 from "os";
4021
+ import * as os4 from "os";
3949
4022
  var GLOBAL_CONSTITUTION_FILE = ".ai-spec-global-constitution.md";
3950
4023
  var SEARCH_ROOTS = [
3951
4024
  // Workspace root is injected at runtime — see loadGlobalConstitution()
3952
- os3.homedir()
4025
+ os4.homedir()
3953
4026
  ];
3954
4027
  async function loadGlobalConstitution(extraRoots = []) {
3955
4028
  const roots = [...extraRoots, ...SEARCH_ROOTS];
@@ -3978,7 +4051,7 @@ function mergeConstitutions(globalContent, projectContent) {
3978
4051
  }
3979
4052
  return parts.join("\n");
3980
4053
  }
3981
- async function saveGlobalConstitution(content, targetDir = os3.homedir()) {
4054
+ async function saveGlobalConstitution(content, targetDir = os4.homedir()) {
3982
4055
  const filePath = path4.join(targetDir, GLOBAL_CONSTITUTION_FILE);
3983
4056
  await fs5.writeFile(filePath, content, "utf-8");
3984
4057
  return filePath;
@@ -5011,32 +5084,32 @@ function validateDsl(raw) {
5011
5084
  }
5012
5085
  return { valid: true, dsl: raw };
5013
5086
  }
5014
- function validateFeature(raw, path42, errors) {
5087
+ function validateFeature(raw, path43, errors) {
5015
5088
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5016
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5089
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5017
5090
  return;
5018
5091
  }
5019
5092
  const f = raw;
5020
- requireNonEmptyString(f["id"], `${path42}.id`, errors);
5021
- requireNonEmptyString(f["title"], `${path42}.title`, errors);
5022
- requireNonEmptyString(f["description"], `${path42}.description`, errors);
5093
+ requireNonEmptyString(f["id"], `${path43}.id`, errors);
5094
+ requireNonEmptyString(f["title"], `${path43}.title`, errors);
5095
+ requireNonEmptyString(f["description"], `${path43}.description`, errors);
5023
5096
  }
5024
- function validateModel(raw, path42, errors) {
5097
+ function validateModel(raw, path43, errors) {
5025
5098
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5026
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5099
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5027
5100
  return;
5028
5101
  }
5029
5102
  const m = raw;
5030
- requireNonEmptyString(m["name"], `${path42}.name`, errors);
5103
+ requireNonEmptyString(m["name"], `${path43}.name`, errors);
5031
5104
  if (!Array.isArray(m["fields"])) {
5032
- errors.push({ path: `${path42}.fields`, message: `Must be an array, got: ${typeLabel(m["fields"])}` });
5105
+ errors.push({ path: `${path43}.fields`, message: `Must be an array, got: ${typeLabel(m["fields"])}` });
5033
5106
  } else {
5034
5107
  const fields = m["fields"];
5035
5108
  if (fields.length > MAX_FIELDS_PER_MODEL) {
5036
- errors.push({ path: `${path42}.fields`, message: `Too many fields (${fields.length} > ${MAX_FIELDS_PER_MODEL})` });
5109
+ errors.push({ path: `${path43}.fields`, message: `Too many fields (${fields.length} > ${MAX_FIELDS_PER_MODEL})` });
5037
5110
  }
5038
5111
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
5039
- validateModelField(fields[j2], `${path42}.fields[${j2}]`, errors);
5112
+ validateModelField(fields[j2], `${path43}.fields[${j2}]`, errors);
5040
5113
  }
5041
5114
  const seenFieldNames = /* @__PURE__ */ new Set();
5042
5115
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
@@ -5045,7 +5118,7 @@ function validateModel(raw, path42, errors) {
5045
5118
  const name = f["name"];
5046
5119
  if (seenFieldNames.has(name)) {
5047
5120
  errors.push({
5048
- path: `${path42}.fields[${j2}].name`,
5121
+ path: `${path43}.fields[${j2}].name`,
5049
5122
  message: `Duplicate field name "${name}" \u2014 each field within a model must have a unique name`
5050
5123
  });
5051
5124
  } else {
@@ -5056,184 +5129,184 @@ function validateModel(raw, path42, errors) {
5056
5129
  }
5057
5130
  if (m["relations"] !== void 0) {
5058
5131
  if (!Array.isArray(m["relations"])) {
5059
- errors.push({ path: `${path42}.relations`, message: "Must be an array of strings if present" });
5132
+ errors.push({ path: `${path43}.relations`, message: "Must be an array of strings if present" });
5060
5133
  } else {
5061
5134
  const rels = m["relations"];
5062
5135
  for (let j2 = 0; j2 < rels.length; j2++) {
5063
5136
  if (typeof rels[j2] !== "string") {
5064
- errors.push({ path: `${path42}.relations[${j2}]`, message: "Must be a string" });
5137
+ errors.push({ path: `${path43}.relations[${j2}]`, message: "Must be a string" });
5065
5138
  }
5066
5139
  }
5067
5140
  }
5068
5141
  }
5069
5142
  }
5070
- function validateModelField(raw, path42, errors) {
5143
+ function validateModelField(raw, path43, errors) {
5071
5144
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5072
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5145
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5073
5146
  return;
5074
5147
  }
5075
5148
  const f = raw;
5076
- requireNonEmptyString(f["name"], `${path42}.name`, errors);
5077
- requireNonEmptyString(f["type"], `${path42}.type`, errors);
5149
+ requireNonEmptyString(f["name"], `${path43}.name`, errors);
5150
+ requireNonEmptyString(f["type"], `${path43}.type`, errors);
5078
5151
  if (typeof f["required"] !== "boolean") {
5079
- errors.push({ path: `${path42}.required`, message: `Must be boolean, got: ${typeLabel(f["required"])}` });
5152
+ errors.push({ path: `${path43}.required`, message: `Must be boolean, got: ${typeLabel(f["required"])}` });
5080
5153
  }
5081
5154
  }
5082
- function validateEndpoint(raw, path42, errors) {
5155
+ function validateEndpoint(raw, path43, errors) {
5083
5156
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5084
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5157
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5085
5158
  return;
5086
5159
  }
5087
5160
  const e = raw;
5088
- requireNonEmptyString(e["id"], `${path42}.id`, errors);
5089
- requireNonEmptyString(e["description"], `${path42}.description`, errors);
5161
+ requireNonEmptyString(e["id"], `${path43}.id`, errors);
5162
+ requireNonEmptyString(e["description"], `${path43}.description`, errors);
5090
5163
  if (!VALID_METHODS.includes(e["method"])) {
5091
5164
  errors.push({
5092
- path: `${path42}.method`,
5165
+ path: `${path43}.method`,
5093
5166
  message: `Must be one of ${VALID_METHODS.join("|")}, got: ${JSON.stringify(e["method"])}`
5094
5167
  });
5095
5168
  }
5096
5169
  if (typeof e["path"] !== "string" || !e["path"].startsWith("/")) {
5097
5170
  errors.push({
5098
- path: `${path42}.path`,
5171
+ path: `${path43}.path`,
5099
5172
  message: `Must be a string starting with "/", got: ${JSON.stringify(e["path"])}`
5100
5173
  });
5101
5174
  }
5102
5175
  if (typeof e["auth"] !== "boolean") {
5103
- errors.push({ path: `${path42}.auth`, message: `Must be boolean, got: ${typeLabel(e["auth"])}` });
5176
+ errors.push({ path: `${path43}.auth`, message: `Must be boolean, got: ${typeLabel(e["auth"])}` });
5104
5177
  }
5105
5178
  if (typeof e["successStatus"] !== "number" || e["successStatus"] < 100 || e["successStatus"] > 599) {
5106
5179
  errors.push({
5107
- path: `${path42}.successStatus`,
5180
+ path: `${path43}.successStatus`,
5108
5181
  message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["successStatus"])}`
5109
5182
  });
5110
5183
  }
5111
- requireNonEmptyString(e["successDescription"], `${path42}.successDescription`, errors);
5184
+ requireNonEmptyString(e["successDescription"], `${path43}.successDescription`, errors);
5112
5185
  if (e["request"] !== void 0) {
5113
- validateRequestSchema(e["request"], `${path42}.request`, errors);
5186
+ validateRequestSchema(e["request"], `${path43}.request`, errors);
5114
5187
  }
5115
5188
  if (e["errors"] !== void 0) {
5116
5189
  if (!Array.isArray(e["errors"])) {
5117
- errors.push({ path: `${path42}.errors`, message: "Must be an array if present" });
5190
+ errors.push({ path: `${path43}.errors`, message: "Must be an array if present" });
5118
5191
  } else {
5119
5192
  const errs = e["errors"];
5120
5193
  if (errs.length > MAX_ERRORS_PER_ENDPOINT) {
5121
- errors.push({ path: `${path42}.errors`, message: `Too many error entries (${errs.length} > ${MAX_ERRORS_PER_ENDPOINT})` });
5194
+ errors.push({ path: `${path43}.errors`, message: `Too many error entries (${errs.length} > ${MAX_ERRORS_PER_ENDPOINT})` });
5122
5195
  }
5123
5196
  for (let j2 = 0; j2 < Math.min(errs.length, MAX_ERRORS_PER_ENDPOINT); j2++) {
5124
- validateResponseError(errs[j2], `${path42}.errors[${j2}]`, errors);
5197
+ validateResponseError(errs[j2], `${path43}.errors[${j2}]`, errors);
5125
5198
  }
5126
5199
  }
5127
5200
  }
5128
5201
  }
5129
- function validateRequestSchema(raw, path42, errors) {
5202
+ function validateRequestSchema(raw, path43, errors) {
5130
5203
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5131
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5204
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5132
5205
  return;
5133
5206
  }
5134
5207
  const r = raw;
5135
5208
  for (const key of ["body", "query", "params"]) {
5136
5209
  if (r[key] !== void 0) {
5137
- validateFieldMap(r[key], `${path42}.${key}`, errors);
5210
+ validateFieldMap(r[key], `${path43}.${key}`, errors);
5138
5211
  }
5139
5212
  }
5140
5213
  }
5141
- function validateFieldMap(raw, path42, errors) {
5214
+ function validateFieldMap(raw, path43, errors) {
5142
5215
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5143
- errors.push({ path: path42, message: `Must be a flat object (FieldMap), got: ${typeLabel(raw)}` });
5216
+ errors.push({ path: path43, message: `Must be a flat object (FieldMap), got: ${typeLabel(raw)}` });
5144
5217
  return;
5145
5218
  }
5146
5219
  const map = raw;
5147
5220
  for (const [k2, v2] of Object.entries(map)) {
5148
5221
  if (typeof v2 !== "string") {
5149
- errors.push({ path: `${path42}.${k2}`, message: `Value must be a type-description string, got: ${typeLabel(v2)}` });
5222
+ errors.push({ path: `${path43}.${k2}`, message: `Value must be a type-description string, got: ${typeLabel(v2)}` });
5150
5223
  }
5151
5224
  }
5152
5225
  }
5153
- function validateResponseError(raw, path42, errors) {
5226
+ function validateResponseError(raw, path43, errors) {
5154
5227
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5155
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5228
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5156
5229
  return;
5157
5230
  }
5158
5231
  const e = raw;
5159
5232
  if (typeof e["status"] !== "number" || e["status"] < 100 || e["status"] > 599) {
5160
- errors.push({ path: `${path42}.status`, message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["status"])}` });
5233
+ errors.push({ path: `${path43}.status`, message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["status"])}` });
5161
5234
  }
5162
- requireNonEmptyString(e["code"], `${path42}.code`, errors);
5163
- requireNonEmptyString(e["description"], `${path42}.description`, errors);
5235
+ requireNonEmptyString(e["code"], `${path43}.code`, errors);
5236
+ requireNonEmptyString(e["description"], `${path43}.description`, errors);
5164
5237
  }
5165
- function validateBehavior(raw, path42, errors) {
5238
+ function validateBehavior(raw, path43, errors) {
5166
5239
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5167
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5240
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5168
5241
  return;
5169
5242
  }
5170
5243
  const b = raw;
5171
- requireNonEmptyString(b["id"], `${path42}.id`, errors);
5172
- requireNonEmptyString(b["description"], `${path42}.description`, errors);
5244
+ requireNonEmptyString(b["id"], `${path43}.id`, errors);
5245
+ requireNonEmptyString(b["description"], `${path43}.description`, errors);
5173
5246
  if (b["constraints"] !== void 0) {
5174
5247
  if (!Array.isArray(b["constraints"])) {
5175
- errors.push({ path: `${path42}.constraints`, message: "Must be an array of strings if present" });
5248
+ errors.push({ path: `${path43}.constraints`, message: "Must be an array of strings if present" });
5176
5249
  } else {
5177
5250
  const cs2 = b["constraints"];
5178
5251
  for (let j2 = 0; j2 < cs2.length; j2++) {
5179
5252
  if (typeof cs2[j2] !== "string") {
5180
- errors.push({ path: `${path42}.constraints[${j2}]`, message: "Must be a string" });
5253
+ errors.push({ path: `${path43}.constraints[${j2}]`, message: "Must be a string" });
5181
5254
  }
5182
5255
  }
5183
5256
  }
5184
5257
  }
5185
5258
  }
5186
- function validateComponent(raw, path42, errors) {
5259
+ function validateComponent(raw, path43, errors) {
5187
5260
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5188
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5261
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5189
5262
  return;
5190
5263
  }
5191
5264
  const c = raw;
5192
- requireNonEmptyString(c["id"], `${path42}.id`, errors);
5193
- requireNonEmptyString(c["name"], `${path42}.name`, errors);
5194
- requireNonEmptyString(c["description"], `${path42}.description`, errors);
5265
+ requireNonEmptyString(c["id"], `${path43}.id`, errors);
5266
+ requireNonEmptyString(c["name"], `${path43}.name`, errors);
5267
+ requireNonEmptyString(c["description"], `${path43}.description`, errors);
5195
5268
  if (c["props"] !== void 0) {
5196
5269
  if (!Array.isArray(c["props"])) {
5197
- errors.push({ path: `${path42}.props`, message: "Must be an array if present" });
5270
+ errors.push({ path: `${path43}.props`, message: "Must be an array if present" });
5198
5271
  } else {
5199
5272
  const props = c["props"];
5200
5273
  for (let j2 = 0; j2 < props.length; j2++) {
5201
5274
  const p = props[j2];
5202
5275
  if (typeof p !== "object" || p === null) {
5203
- errors.push({ path: `${path42}.props[${j2}]`, message: "Must be an object" });
5276
+ errors.push({ path: `${path43}.props[${j2}]`, message: "Must be an object" });
5204
5277
  continue;
5205
5278
  }
5206
- requireNonEmptyString(p["name"], `${path42}.props[${j2}].name`, errors);
5207
- requireNonEmptyString(p["type"], `${path42}.props[${j2}].type`, errors);
5279
+ requireNonEmptyString(p["name"], `${path43}.props[${j2}].name`, errors);
5280
+ requireNonEmptyString(p["type"], `${path43}.props[${j2}].type`, errors);
5208
5281
  if (typeof p["required"] !== "boolean") {
5209
- errors.push({ path: `${path42}.props[${j2}].required`, message: "Must be boolean" });
5282
+ errors.push({ path: `${path43}.props[${j2}].required`, message: "Must be boolean" });
5210
5283
  }
5211
5284
  }
5212
5285
  }
5213
5286
  }
5214
5287
  if (c["events"] !== void 0) {
5215
5288
  if (!Array.isArray(c["events"])) {
5216
- errors.push({ path: `${path42}.events`, message: "Must be an array if present" });
5289
+ errors.push({ path: `${path43}.events`, message: "Must be an array if present" });
5217
5290
  } else {
5218
5291
  const events = c["events"];
5219
5292
  for (let j2 = 0; j2 < events.length; j2++) {
5220
5293
  const e = events[j2];
5221
5294
  if (typeof e !== "object" || e === null) {
5222
- errors.push({ path: `${path42}.events[${j2}]`, message: "Must be an object" });
5295
+ errors.push({ path: `${path43}.events[${j2}]`, message: "Must be an object" });
5223
5296
  continue;
5224
5297
  }
5225
- requireNonEmptyString(e["name"], `${path42}.events[${j2}].name`, errors);
5298
+ requireNonEmptyString(e["name"], `${path43}.events[${j2}].name`, errors);
5226
5299
  }
5227
5300
  }
5228
5301
  }
5229
5302
  if (c["state"] !== void 0) {
5230
5303
  if (typeof c["state"] !== "object" || Array.isArray(c["state"]) || c["state"] === null) {
5231
- errors.push({ path: `${path42}.state`, message: "Must be a flat object (Record<string, string>) if present" });
5304
+ errors.push({ path: `${path43}.state`, message: "Must be a flat object (Record<string, string>) if present" });
5232
5305
  }
5233
5306
  }
5234
5307
  if (c["apiCalls"] !== void 0) {
5235
5308
  if (!Array.isArray(c["apiCalls"])) {
5236
- errors.push({ path: `${path42}.apiCalls`, message: "Must be an array of strings if present" });
5309
+ errors.push({ path: `${path43}.apiCalls`, message: "Must be an array of strings if present" });
5237
5310
  }
5238
5311
  }
5239
5312
  }
@@ -5295,10 +5368,10 @@ function crossReferenceChecks(obj, errors) {
5295
5368
  }
5296
5369
  }
5297
5370
  }
5298
- function requireNonEmptyString(v2, path42, errors) {
5371
+ function requireNonEmptyString(v2, path43, errors) {
5299
5372
  if (typeof v2 !== "string" || v2.trim().length === 0) {
5300
5373
  errors.push({
5301
- path: path42,
5374
+ path: path43,
5302
5375
  message: `Must be a non-empty string, got: ${typeLabel(v2)}`
5303
5376
  });
5304
5377
  }
@@ -5509,13 +5582,18 @@ Output ONLY the corrected JSON. No explanation.`;
5509
5582
 
5510
5583
  // core/token-budget.ts
5511
5584
  import chalk5 from "chalk";
5512
- var CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g;
5513
- function estimateTokens(text) {
5514
- if (!text) return 0;
5515
- const cjkCount = (text.match(CJK_RANGE) ?? []).length;
5516
- const nonCjkLength = text.length - cjkCount;
5517
- return Math.ceil(cjkCount + nonCjkLength / 4);
5518
- }
5585
+
5586
+ // core/config-defaults.ts
5587
+ var DEFAULT_LOG_DIR = ".ai-spec-logs";
5588
+ var DEFAULT_VCR_DIR = ".ai-spec-vcr";
5589
+ var DEFAULT_BACKUP_DIR = ".ai-spec-backup";
5590
+ var DEFAULT_REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
5591
+ var DEFAULT_OPENAPI_SERVER_URL = "http://localhost:3000";
5592
+ var DEFAULT_MAX_COMMAND_OUTPUT_CHARS = 3e4;
5593
+ var DEFAULT_MAX_FIX_FILE_CHARS = 6e4;
5594
+ var DEFAULT_DSL_MAX_RETRIES = 2;
5595
+ var DEFAULT_MAX_CONSTITUTION_CHARS = 4e3;
5596
+ var DEFAULT_MAX_REVIEW_FILE_CHARS = 3e3;
5519
5597
  var DEFAULT_TOKEN_BUDGETS = {
5520
5598
  gemini: 9e5,
5521
5599
  claude: 18e4,
@@ -5523,8 +5601,18 @@ var DEFAULT_TOKEN_BUDGETS = {
5523
5601
  deepseek: 6e4,
5524
5602
  default: 1e5
5525
5603
  };
5604
+
5605
+ // core/token-budget.ts
5606
+ var CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g;
5607
+ function estimateTokens(text) {
5608
+ if (!text) return 0;
5609
+ const cjkCount = (text.match(CJK_RANGE) ?? []).length;
5610
+ const nonCjkLength = text.length - cjkCount;
5611
+ return Math.ceil(cjkCount + nonCjkLength / 4);
5612
+ }
5613
+ var DEFAULT_TOKEN_BUDGETS2 = DEFAULT_TOKEN_BUDGETS;
5526
5614
  function getDefaultBudget(providerName) {
5527
- return DEFAULT_TOKEN_BUDGETS[providerName] ?? DEFAULT_TOKEN_BUDGETS.default;
5615
+ return DEFAULT_TOKEN_BUDGETS2[providerName] ?? DEFAULT_TOKEN_BUDGETS2.default;
5528
5616
  }
5529
5617
 
5530
5618
  // core/safe-json.ts
@@ -5595,7 +5683,7 @@ function sanitizeDsl(raw) {
5595
5683
  }
5596
5684
  return dsl;
5597
5685
  }
5598
- var MAX_RETRIES = 2;
5686
+ var MAX_RETRIES = DEFAULT_DSL_MAX_RETRIES;
5599
5687
  var DEFAULT_MAX_SPEC_CHARS = 12e3;
5600
5688
  function dslFilePath(specFilePath) {
5601
5689
  const dir = path7.dirname(specFilePath);
@@ -6319,12 +6407,11 @@ Shared component structure patterns:`);
6319
6407
  // core/run-snapshot.ts
6320
6408
  import * as fs10 from "fs-extra";
6321
6409
  import * as path9 from "path";
6322
- var BACKUP_DIR = ".ai-spec-backup";
6323
6410
  var RunSnapshot = class {
6324
6411
  constructor(workingDir, runId) {
6325
6412
  this.workingDir = workingDir;
6326
6413
  this.runId = runId;
6327
- this.backupRoot = path9.join(workingDir, BACKUP_DIR, runId);
6414
+ this.backupRoot = path9.join(workingDir, DEFAULT_BACKUP_DIR, runId);
6328
6415
  }
6329
6416
  backupRoot;
6330
6417
  snapshotted = /* @__PURE__ */ new Set();
@@ -6380,7 +6467,6 @@ function getActiveSnapshot() {
6380
6467
  import * as fs11 from "fs-extra";
6381
6468
  import * as path10 from "path";
6382
6469
  import chalk7 from "chalk";
6383
- var LOG_DIR = ".ai-spec-logs";
6384
6470
  function appendJsonlLine(filePath, record) {
6385
6471
  try {
6386
6472
  fs11.appendFileSync(filePath, JSON.stringify(record) + "\n");
@@ -6451,8 +6537,8 @@ var RunLogger = class {
6451
6537
  this.workingDir = workingDir;
6452
6538
  this.runId = runId;
6453
6539
  this.startMs = Date.now();
6454
- this.logPath = path10.join(workingDir, LOG_DIR, `${runId}.json`);
6455
- this.jsonlPath = path10.join(workingDir, LOG_DIR, `${runId}.jsonl`);
6540
+ this.logPath = path10.join(workingDir, DEFAULT_LOG_DIR, `${runId}.json`);
6541
+ this.jsonlPath = path10.join(workingDir, DEFAULT_LOG_DIR, `${runId}.jsonl`);
6456
6542
  this.log = {
6457
6543
  runId,
6458
6544
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -7394,7 +7480,7 @@ ${context.routeSummary}
7394
7480
  }
7395
7481
  if (context.schema) {
7396
7482
  parts.push(`=== Prisma Schema ===
7397
- ${context.schema.slice(0, 4e3)}
7483
+ ${context.schema.slice(0, DEFAULT_MAX_CONSTITUTION_CHARS)}
7398
7484
  `);
7399
7485
  }
7400
7486
  if (context.errorPatterns) {
@@ -7442,7 +7528,7 @@ async function loadAccumulatedLessons(projectRoot) {
7442
7528
  const nextSection = section.slice(marker.length).match(/\n## \d/);
7443
7529
  return nextSection ? section.slice(0, marker.length + nextSection.index) : section;
7444
7530
  }
7445
- var REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
7531
+ var REVIEW_HISTORY_FILE = DEFAULT_REVIEW_HISTORY_FILE;
7446
7532
  async function loadReviewHistory(projectRoot) {
7447
7533
  const historyPath = path13.join(projectRoot, REVIEW_HISTORY_FILE);
7448
7534
  try {
@@ -7573,15 +7659,13 @@ ${specContent || "(No spec \u2014 review for general code quality)"}
7573
7659
  ${codeContext}`;
7574
7660
  const archReview = await this.provider.generate(archPrompt, reviewArchitectureSystemPrompt);
7575
7661
  console.log(chalk11.gray(" Pass 2/3: Implementation review..."));
7662
+ const specDigest = specContent && specContent.length > 600 ? specContent.slice(0, 600) + "\n... [spec truncated \u2014 see Pass 0/1 for full text]" : specContent || "(No spec)";
7576
7663
  const history = await loadReviewHistory(this.projectRoot);
7577
7664
  const historyContext = buildHistoryContext(history);
7578
7665
  const implPrompt = `Review the implementation details of this change.
7579
7666
 
7580
- === Feature Spec ===
7581
- ${specContent || "(No spec \u2014 review for general code quality)"}
7582
-
7583
- === Code ===
7584
- ${codeContext}
7667
+ === Feature Spec (digest \u2014 full spec was provided in Pass 0/1) ===
7668
+ ${specDigest}
7585
7669
 
7586
7670
  === Architecture Review (Pass 1 \u2014 do NOT repeat these findings) ===
7587
7671
  ${archReview}
@@ -7590,11 +7674,8 @@ ${historyContext}`;
7590
7674
  console.log(chalk11.gray(" Pass 3/3: Impact & complexity assessment..."));
7591
7675
  const impactPrompt = `Assess the impact and complexity of this change.
7592
7676
 
7593
- === Feature Spec ===
7594
- ${specContent || "(No spec \u2014 review for general code quality)"}
7595
-
7596
- === Code ===
7597
- ${codeContext}
7677
+ === Feature Spec (digest) ===
7678
+ ${specDigest}
7598
7679
 
7599
7680
  === Architecture Review (Pass 1 \u2014 do NOT repeat) ===
7600
7681
  ${archReview}
@@ -7668,8 +7749,8 @@ ${sep}
7668
7749
  filesSection += `
7669
7750
 
7670
7751
  === ${filePath} ===
7671
- ${content.slice(0, 3e3)}`;
7672
- if (content.length > 3e3) filesSection += `
7752
+ ${content.slice(0, DEFAULT_MAX_REVIEW_FILE_CHARS)}`;
7753
+ if (content.length > DEFAULT_MAX_REVIEW_FILE_CHARS) filesSection += `
7673
7754
  ... (truncated, ${content.length} chars total)`;
7674
7755
  } catch {
7675
7756
  filesSection += `
@@ -8274,8 +8355,8 @@ import { execSync as execSync5 } from "child_process";
8274
8355
  import * as fs18 from "fs-extra";
8275
8356
  import * as path17 from "path";
8276
8357
  init_cli_ui();
8277
- var MAX_COMMAND_OUTPUT_CHARS = 5e4;
8278
- var MAX_FIX_FILE_CHARS = 6e4;
8358
+ var MAX_COMMAND_OUTPUT_CHARS = DEFAULT_MAX_COMMAND_OUTPUT_CHARS;
8359
+ var MAX_FIX_FILE_CHARS = DEFAULT_MAX_FIX_FILE_CHARS;
8279
8360
  function runCommand(cmd, cwd) {
8280
8361
  try {
8281
8362
  const output = execSync5(cmd, { cwd, encoding: "utf-8", timeout: 6e4 });
@@ -9484,8 +9565,8 @@ async function findLatestDslFile(projectDir) {
9484
9565
  const entries = await fs22.readdir(dir);
9485
9566
  for (const entry of entries) {
9486
9567
  const abs = path21.join(dir, entry);
9487
- const stat4 = await fs22.stat(abs);
9488
- if (stat4.isDirectory()) {
9568
+ const stat5 = await fs22.stat(abs);
9569
+ if (stat5.isDirectory()) {
9489
9570
  await scan(abs);
9490
9571
  } else if (entry.endsWith(".dsl.json")) {
9491
9572
  allFiles.push(abs);
@@ -11223,7 +11304,7 @@ ${optionsText}`;
11223
11304
  import { createHash as createHash2 } from "crypto";
11224
11305
  import * as fs24 from "fs-extra";
11225
11306
  import * as path23 from "path";
11226
- var VCR_DIR = ".ai-spec-vcr";
11307
+ var VCR_DIR = DEFAULT_VCR_DIR;
11227
11308
  var VcrRecordingProvider = class {
11228
11309
  constructor(inner) {
11229
11310
  this.inner = inner;
@@ -11921,7 +12002,147 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11921
12002
  }
11922
12003
  }
11923
12004
 
12005
+ // core/repo-store.ts
12006
+ import * as fs26 from "fs-extra";
12007
+ import * as path25 from "path";
12008
+ import * as os5 from "os";
12009
+ var REPO_STORE_FILE = path25.join(os5.homedir(), ".ai-spec-repos.json");
12010
+ async function readStore2() {
12011
+ try {
12012
+ if (await fs26.pathExists(REPO_STORE_FILE)) {
12013
+ return await fs26.readJson(REPO_STORE_FILE);
12014
+ }
12015
+ } catch (err) {
12016
+ console.warn(`Warning: Could not read repo store at ${REPO_STORE_FILE}: ${err.message}.`);
12017
+ }
12018
+ return { repos: [] };
12019
+ }
12020
+ async function writeStore2(store) {
12021
+ await fs26.ensureFile(REPO_STORE_FILE);
12022
+ await fs26.writeJson(REPO_STORE_FILE, store, { spaces: 2 });
12023
+ }
12024
+ async function getRegisteredRepos() {
12025
+ const store = await readStore2();
12026
+ return store.repos;
12027
+ }
12028
+ async function registerRepo(repo) {
12029
+ const store = await readStore2();
12030
+ const idx = store.repos.findIndex((r) => r.path === repo.path);
12031
+ if (idx >= 0) {
12032
+ store.repos[idx] = repo;
12033
+ } else {
12034
+ store.repos.push(repo);
12035
+ }
12036
+ await writeStore2(store);
12037
+ }
12038
+
11924
12039
  // cli/commands/create.ts
12040
+ init_spec_generator();
12041
+ async function promptRepoRole() {
12042
+ return select7({
12043
+ message: "What type of repo is this?",
12044
+ choices: [
12045
+ { name: "Frontend", value: "frontend" },
12046
+ { name: "Backend", value: "backend" },
12047
+ { name: "Mobile", value: "mobile" },
12048
+ { name: "Shared / Other", value: "shared" }
12049
+ ]
12050
+ });
12051
+ }
12052
+ async function quickRegisterRepo(provider) {
12053
+ const roleOverride = await promptRepoRole();
12054
+ const roleLabels = {
12055
+ frontend: "frontend",
12056
+ backend: "backend",
12057
+ mobile: "mobile",
12058
+ shared: "shared"
12059
+ };
12060
+ const raw = await input2({
12061
+ message: `Enter your ${roleLabels[roleOverride]} repo path (absolute path):`,
12062
+ validate: (v2) => {
12063
+ const trimmed = v2.trim();
12064
+ if (trimmed.length === 0) return "Path cannot be empty";
12065
+ if (!path26.isAbsolute(trimmed)) return "Please provide an absolute path";
12066
+ return true;
12067
+ }
12068
+ });
12069
+ const cleaned = raw.trim().replace(/\\ /g, " ");
12070
+ const resolved = path26.resolve(cleaned);
12071
+ if (!await fs27.pathExists(resolved)) {
12072
+ console.log(chalk26.red(` Path does not exist: ${resolved}`));
12073
+ return quickRegisterRepo(provider);
12074
+ }
12075
+ const { type, role: detectedRole } = await detectRepoType(resolved);
12076
+ const role = roleOverride ?? detectedRole;
12077
+ const repoName = path26.basename(resolved);
12078
+ console.log(chalk26.gray(` Detected: ${repoName} \u2192 ${type} (${role})`));
12079
+ const constitutionPath = path26.join(resolved, CONSTITUTION_FILE);
12080
+ let hasConstitution = await fs27.pathExists(constitutionPath);
12081
+ if (!hasConstitution) {
12082
+ console.log(chalk26.blue(` Generating constitution for ${repoName}...`));
12083
+ try {
12084
+ const gen = new ConstitutionGenerator(provider);
12085
+ const content = await gen.generate(resolved);
12086
+ await gen.saveConstitution(resolved, content);
12087
+ hasConstitution = true;
12088
+ console.log(chalk26.green(` \u2714 Constitution saved`));
12089
+ } catch (err) {
12090
+ console.log(chalk26.yellow(` \u26A0 Constitution failed: ${err.message}`));
12091
+ }
12092
+ }
12093
+ const entry = {
12094
+ name: repoName,
12095
+ path: resolved,
12096
+ type,
12097
+ role,
12098
+ hasConstitution,
12099
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
12100
+ };
12101
+ await registerRepo(entry);
12102
+ console.log(chalk26.green(` \u2714 Repo registered: ${repoName}`));
12103
+ return entry;
12104
+ }
12105
+ async function selectRepos(registeredRepos, provider) {
12106
+ const ADD_NEW = "__add_new__";
12107
+ const choices = [
12108
+ ...registeredRepos.map((r) => ({
12109
+ name: `${r.name} (${r.type} / ${r.role}) \u2192 ${r.path}`,
12110
+ value: r.path
12111
+ })),
12112
+ { name: chalk26.cyan("+ Add new repo"), value: ADD_NEW }
12113
+ ];
12114
+ const selected = await checkbox({
12115
+ message: "Select repo(s) for this feature (space to toggle, enter to confirm):",
12116
+ choices,
12117
+ required: true
12118
+ });
12119
+ const result = [];
12120
+ for (const val of selected) {
12121
+ if (val === ADD_NEW) {
12122
+ const newRepo = await quickRegisterRepo(provider);
12123
+ result.push(newRepo);
12124
+ } else {
12125
+ const repo = registeredRepos.find((r) => r.path === val);
12126
+ if (repo) result.push(repo);
12127
+ }
12128
+ }
12129
+ if (result.length === 0) {
12130
+ console.log(chalk26.yellow(" No repos selected. Please select at least one."));
12131
+ return selectRepos(registeredRepos, provider);
12132
+ }
12133
+ return result;
12134
+ }
12135
+ function buildWorkspaceConfig(repos) {
12136
+ return {
12137
+ name: "ai-spec-workspace",
12138
+ repos: repos.map((r) => ({
12139
+ name: r.name,
12140
+ path: r.path,
12141
+ type: r.type,
12142
+ role: r.role
12143
+ }))
12144
+ };
12145
+ }
11925
12146
  function registerCreate(program2) {
11926
12147
  program2.command("create").description("Generate a feature spec and kick off code generation").argument("[idea]", "Feature idea in natural language (prompted if omitted)").option(
11927
12148
  "--provider <name>",
@@ -11944,25 +12165,76 @@ function registerCreate(program2) {
11944
12165
  });
11945
12166
  }
11946
12167
  const workspaceLoader = new WorkspaceLoader(currentDir);
11947
- const workspaceConfig = await workspaceLoader.load();
11948
- if (workspaceConfig) {
12168
+ const existingWorkspaceConfig = await workspaceLoader.load();
12169
+ if (existingWorkspaceConfig) {
11949
12170
  console.log(chalk26.cyan(`
11950
- [Workspace] Detected workspace: ${workspaceConfig.name}`));
11951
- console.log(chalk26.gray(` Repos: ${workspaceConfig.repos.map((r) => r.name).join(", ")}`));
11952
- const pipelineResults = await runMultiRepoPipeline(idea, workspaceConfig, opts, currentDir, config2);
12171
+ [Workspace] Detected workspace: ${existingWorkspaceConfig.name}`));
12172
+ console.log(chalk26.gray(` Repos: ${existingWorkspaceConfig.repos.map((r) => r.name).join(", ")}`));
12173
+ const pipelineResults = await runMultiRepoPipeline(idea, existingWorkspaceConfig, opts, currentDir, config2);
11953
12174
  if (opts.serve) {
11954
12175
  await handleAutoServe(pipelineResults);
11955
12176
  }
11956
12177
  return;
11957
12178
  }
11958
- await runSingleRepoPipeline(idea, opts, currentDir, config2);
12179
+ const providerName = opts.provider || config2.provider || "gemini";
12180
+ const modelName = opts.model || config2.model || DEFAULT_MODELS[providerName];
12181
+ const apiKey = await resolveApiKey(providerName, opts.key);
12182
+ const specProvider = createProvider(providerName, apiKey, modelName);
12183
+ const registeredRepos = await getRegisteredRepos();
12184
+ if (registeredRepos.length === 0) {
12185
+ console.log(chalk26.yellow("\n No repos registered. Please register repos first."));
12186
+ console.log(chalk26.gray(" Run: ai-spec init"));
12187
+ console.log(chalk26.gray(" Or add a repo now:\n"));
12188
+ const addNow = await select7({
12189
+ message: "Add a repo now?",
12190
+ choices: [
12191
+ { name: "Yes \u2014 register a repo and continue", value: "yes" },
12192
+ { name: "No \u2014 exit", value: "no" }
12193
+ ]
12194
+ });
12195
+ if (addNow === "no") {
12196
+ process.exit(0);
12197
+ }
12198
+ const newRepo = await quickRegisterRepo(specProvider);
12199
+ registeredRepos.push(newRepo);
12200
+ }
12201
+ const validRepos = [];
12202
+ for (const r of registeredRepos) {
12203
+ if (await fs27.pathExists(r.path)) {
12204
+ validRepos.push(r);
12205
+ } else {
12206
+ console.log(chalk26.yellow(` \u26A0 Skipping ${r.name}: path not found (${r.path})`));
12207
+ }
12208
+ }
12209
+ if (validRepos.length === 0) {
12210
+ console.log(chalk26.red(" No valid repos available. Run: ai-spec init"));
12211
+ process.exit(1);
12212
+ }
12213
+ const selectedRepos = await selectRepos(validRepos, specProvider);
12214
+ if (selectedRepos.length === 1) {
12215
+ const repo = selectedRepos[0];
12216
+ console.log(chalk26.cyan(`
12217
+ [Repo] ${repo.name} (${repo.type}/${repo.role}) \u2192 ${repo.path}`));
12218
+ await runSingleRepoPipeline(idea, opts, repo.path, config2);
12219
+ } else {
12220
+ const workspaceConfig = buildWorkspaceConfig(selectedRepos);
12221
+ console.log(chalk26.cyan(`
12222
+ [Workspace] ${selectedRepos.length} repo(s) selected:`));
12223
+ for (const repo of selectedRepos) {
12224
+ console.log(chalk26.gray(` ${repo.name} (${repo.type}/${repo.role}) \u2192 ${repo.path}`));
12225
+ }
12226
+ const pipelineResults = await runMultiRepoPipeline(idea, workspaceConfig, opts, currentDir, config2);
12227
+ if (opts.serve) {
12228
+ await handleAutoServe(pipelineResults);
12229
+ }
12230
+ }
11959
12231
  });
11960
12232
  }
11961
12233
 
11962
12234
  // cli/commands/review.ts
11963
12235
  init_spec_generator();
11964
- import * as path25 from "path";
11965
- import * as fs26 from "fs-extra";
12236
+ import * as path27 from "path";
12237
+ import * as fs28 from "fs-extra";
11966
12238
  import chalk27 from "chalk";
11967
12239
  function registerReview(program2) {
11968
12240
  program2.command("review").description("Run AI code review on current git diff against a spec").argument("[specFile]", "Path to spec file (auto-detects latest in specs/ if omitted)").option(
@@ -11979,17 +12251,17 @@ function registerReview(program2) {
11979
12251
  const reviewer = new CodeReviewer(provider, currentDir);
11980
12252
  let specContent = "";
11981
12253
  let resolvedSpecFile;
11982
- if (specFile && await fs26.pathExists(specFile)) {
11983
- specContent = await fs26.readFile(specFile, "utf-8");
12254
+ if (specFile && await fs28.pathExists(specFile)) {
12255
+ specContent = await fs28.readFile(specFile, "utf-8");
11984
12256
  resolvedSpecFile = specFile;
11985
12257
  console.log(chalk27.gray(`Using spec: ${specFile}`));
11986
12258
  } else {
11987
- const specsDir = path25.join(currentDir, "specs");
11988
- if (await fs26.pathExists(specsDir)) {
11989
- const files = (await fs26.readdir(specsDir)).filter((f) => f.endsWith(".md")).sort().reverse();
12259
+ const specsDir = path27.join(currentDir, "specs");
12260
+ if (await fs28.pathExists(specsDir)) {
12261
+ const files = (await fs28.readdir(specsDir)).filter((f) => f.endsWith(".md")).sort().reverse();
11990
12262
  if (files.length > 0) {
11991
- const latest = path25.join(specsDir, files[0]);
11992
- specContent = await fs26.readFile(latest, "utf-8");
12263
+ const latest = path27.join(specsDir, files[0]);
12264
+ specContent = await fs28.readFile(latest, "utf-8");
11993
12265
  resolvedSpecFile = latest;
11994
12266
  console.log(chalk27.gray(`Auto-detected spec: specs/${files[0]}`));
11995
12267
  }
@@ -12005,9 +12277,10 @@ function registerReview(program2) {
12005
12277
 
12006
12278
  // cli/commands/init.ts
12007
12279
  init_spec_generator();
12008
- import * as path27 from "path";
12009
- import * as fs28 from "fs-extra";
12280
+ import * as path28 from "path";
12281
+ import * as fs29 from "fs-extra";
12010
12282
  import chalk28 from "chalk";
12283
+ import { input as input3, select as select8, confirm as confirm2 } from "@inquirer/prompts";
12011
12284
 
12012
12285
  // prompts/global-constitution.prompt.ts
12013
12286
  var globalConstitutionSystemPrompt = `You are a Senior Software Architect. Analyze the provided multi-project context and generate a "Global Constitution" \u2014 a team-level baseline document that captures cross-project rules, shared conventions, and universal constraints that every repository in this workspace must follow.
@@ -12068,240 +12341,139 @@ ${summary}
12068
12341
  return parts.join("\n");
12069
12342
  }
12070
12343
 
12071
- // core/project-index.ts
12072
- import * as fs27 from "fs-extra";
12073
- import * as path26 from "path";
12074
- var INDEX_FILE = ".ai-spec-index.json";
12075
- var KEY_DEPS = [
12076
- // Frameworks
12077
- "express",
12078
- "fastify",
12079
- "koa",
12080
- "@nestjs/core",
12081
- "hapi",
12082
- "next",
12083
- "react",
12084
- "vue",
12085
- "nuxt",
12086
- "svelte",
12087
- "react-native",
12088
- "expo",
12089
- // DB / ORM
12090
- "prisma",
12091
- "@prisma/client",
12092
- "mongoose",
12093
- "typeorm",
12094
- "sequelize",
12095
- "drizzle-orm",
12096
- // Auth
12097
- "jsonwebtoken",
12098
- "passport",
12099
- "next-auth",
12100
- "@clerk/nextjs",
12101
- // Build / Lang
12102
- "typescript",
12103
- "vite",
12104
- "webpack",
12105
- "esbuild",
12106
- "turbo",
12107
- // Testing
12108
- "jest",
12109
- "vitest",
12110
- "mocha",
12111
- "cypress",
12112
- "playwright",
12113
- // Infra
12114
- "redis",
12115
- "bull",
12116
- "socket.io",
12117
- "graphql",
12118
- "@trpc/server"
12119
- ];
12120
- var SKIP_DIRS = /* @__PURE__ */ new Set([
12121
- "node_modules",
12122
- ".git",
12123
- ".svn",
12124
- "dist",
12125
- "build",
12126
- "out",
12127
- ".next",
12128
- ".nuxt",
12129
- "coverage",
12130
- ".turbo",
12131
- ".cache",
12132
- "__pycache__",
12133
- "vendor",
12134
- ".ai-spec-vcr",
12135
- ".ai-spec-logs",
12136
- "specs"
12137
- ]);
12138
- var MANIFEST_FILES = [
12139
- "package.json",
12140
- "go.mod",
12141
- "Cargo.toml",
12142
- "pom.xml",
12143
- "build.gradle",
12144
- "build.gradle.kts",
12145
- "requirements.txt",
12146
- "pyproject.toml",
12147
- "setup.py",
12148
- "composer.json"
12149
- ];
12150
- async function isProjectRoot(absPath) {
12151
- for (const manifest of MANIFEST_FILES) {
12152
- if (await fs27.pathExists(path26.join(absPath, manifest))) return true;
12153
- }
12154
- return false;
12344
+ // cli/commands/init.ts
12345
+ var ROLE_LABELS = {
12346
+ frontend: "frontend",
12347
+ backend: "backend",
12348
+ mobile: "mobile",
12349
+ shared: "shared"
12350
+ };
12351
+ async function promptRepoRole2() {
12352
+ return select8({
12353
+ message: "What type of repo is this?",
12354
+ choices: [
12355
+ { name: "Frontend", value: "frontend" },
12356
+ { name: "Backend", value: "backend" },
12357
+ { name: "Mobile", value: "mobile" },
12358
+ { name: "Shared / Other", value: "shared" }
12359
+ ]
12360
+ });
12155
12361
  }
12156
- async function extractTechStack(absPath, type) {
12157
- const stack = [];
12158
- if (type === "go") stack.push("go");
12159
- if (type === "rust") stack.push("rust");
12160
- if (type === "java") stack.push("java");
12161
- if (type === "python") stack.push("python");
12162
- if (type === "php") stack.push("php");
12163
- const pkgPath = path26.join(absPath, "package.json");
12164
- if (!await fs27.pathExists(pkgPath)) return stack;
12165
- let pkg = {};
12166
- try {
12167
- pkg = await fs27.readJson(pkgPath);
12168
- } catch {
12169
- return stack;
12170
- }
12171
- const allDeps = {
12172
- ...pkg.dependencies ?? {},
12173
- ...pkg.devDependencies ?? {}
12362
+ async function promptRepoPath(role) {
12363
+ const label = ROLE_LABELS[role];
12364
+ const raw = await input3({
12365
+ message: `Enter your ${label} repo path (absolute path):`,
12366
+ validate: (v2) => {
12367
+ const trimmed = v2.trim();
12368
+ if (trimmed.length === 0) return "Path cannot be empty";
12369
+ if (!path28.isAbsolute(trimmed)) return "Please provide an absolute path";
12370
+ return true;
12371
+ }
12372
+ });
12373
+ const cleaned = raw.trim().replace(/\\ /g, " ");
12374
+ const resolved = path28.resolve(cleaned);
12375
+ if (!await fs29.pathExists(resolved)) {
12376
+ console.log(chalk28.red(` Path does not exist: ${resolved}`));
12377
+ return promptRepoPath(role);
12378
+ }
12379
+ const stat5 = await fs29.stat(resolved);
12380
+ if (!stat5.isDirectory()) {
12381
+ console.log(chalk28.red(` Not a directory: ${resolved}`));
12382
+ return promptRepoPath(role);
12383
+ }
12384
+ return resolved;
12385
+ }
12386
+ async function registerSingleRepo(repoPath, provider, roleOverride) {
12387
+ const { type, role: detectedRole } = await detectRepoType(repoPath);
12388
+ const role = roleOverride ?? detectedRole;
12389
+ const repoName = path28.basename(repoPath);
12390
+ console.log(chalk28.gray(` Detected: ${repoName} \u2192 ${type} (${role})`));
12391
+ const constitutionPath = path28.join(repoPath, CONSTITUTION_FILE);
12392
+ let hasConstitution = await fs29.pathExists(constitutionPath);
12393
+ if (!hasConstitution) {
12394
+ console.log(chalk28.blue(` Generating project constitution for ${repoName}...`));
12395
+ try {
12396
+ const gen = new ConstitutionGenerator(provider);
12397
+ const content = await gen.generate(repoPath);
12398
+ await gen.saveConstitution(repoPath, content);
12399
+ hasConstitution = true;
12400
+ console.log(chalk28.green(` \u2714 Constitution saved: ${constitutionPath}`));
12401
+ } catch (err) {
12402
+ console.log(chalk28.yellow(` \u26A0 Constitution generation failed: ${err.message}`));
12403
+ }
12404
+ } else {
12405
+ console.log(chalk28.green(` \u2714 Constitution already exists: ${constitutionPath}`));
12406
+ }
12407
+ const entry = {
12408
+ name: repoName,
12409
+ path: repoPath,
12410
+ type,
12411
+ role,
12412
+ hasConstitution,
12413
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
12174
12414
  };
12175
- const depKeys = new Set(Object.keys(allDeps));
12176
- for (const dep of KEY_DEPS) {
12177
- if (depKeys.has(dep)) stack.push(dep);
12178
- }
12179
- return stack;
12180
- }
12181
- async function discoverProjects(rootDir, maxDepth) {
12182
- const found = [];
12183
- async function walk(absDir, depth) {
12184
- if (depth > maxDepth) return;
12185
- let entries;
12415
+ await registerRepo(entry);
12416
+ return entry;
12417
+ }
12418
+ async function buildProjectSummaries(repos) {
12419
+ const summaries = [];
12420
+ for (const repo of repos) {
12421
+ if (!await fs29.pathExists(repo.path)) continue;
12422
+ const lines = [
12423
+ `Type: ${repo.type} (${repo.role})`
12424
+ ];
12186
12425
  try {
12187
- entries = await fs27.readdir(absDir, { withFileTypes: true });
12426
+ const loader = new ContextLoader(repo.path);
12427
+ const ctx = await loader.loadProjectContext();
12428
+ lines.push(`Tech stack: ${ctx.techStack.join(", ") || "unknown"}`);
12429
+ lines.push(`Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`);
12188
12430
  } catch {
12189
- return;
12431
+ lines.push(`Tech stack: ${repo.type}`);
12190
12432
  }
12191
- for (const entry of entries) {
12192
- if (!entry.isDirectory()) continue;
12193
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
12194
- const childAbs = path26.join(absDir, entry.name);
12195
- const gitPath = path26.join(childAbs, ".git");
12196
- if (await fs27.pathExists(gitPath)) {
12197
- const gitStat = await fs27.stat(gitPath);
12198
- if (gitStat.isFile()) continue;
12199
- }
12200
- if (await isProjectRoot(childAbs)) {
12201
- found.push(path26.relative(rootDir, childAbs));
12202
- } else {
12203
- await walk(childAbs, depth + 1);
12433
+ if (repo.hasConstitution) {
12434
+ try {
12435
+ const constitutionPath = path28.join(repo.path, CONSTITUTION_FILE);
12436
+ const raw = await fs29.readFile(constitutionPath, "utf-8");
12437
+ lines.push("", "Constitution excerpt:", raw.slice(0, 2e3));
12438
+ } catch {
12204
12439
  }
12205
12440
  }
12441
+ summaries.push({ name: repo.name, summary: lines.join("\n") });
12206
12442
  }
12207
- await walk(rootDir, 0);
12208
- return found;
12443
+ return summaries;
12209
12444
  }
12210
- async function loadIndex(scanRoot) {
12211
- const filePath = path26.join(scanRoot, INDEX_FILE);
12212
- try {
12213
- return await fs27.readJson(filePath);
12214
- } catch {
12215
- return null;
12445
+ async function generateGlobalConstitution(provider, repos, currentDir) {
12446
+ console.log(chalk28.blue("\n\u2500\u2500\u2500 Generating Global Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12447
+ console.log(chalk28.gray(` Based on ${repos.length} registered repo(s)`));
12448
+ const summaries = await buildProjectSummaries(repos);
12449
+ if (summaries.length === 0) {
12450
+ console.log(chalk28.yellow(" No valid repos found \u2014 skipping global constitution."));
12451
+ return;
12216
12452
  }
12217
- }
12218
- async function saveIndex(scanRoot, index) {
12219
- const filePath = path26.join(scanRoot, INDEX_FILE);
12220
- await fs27.writeJson(filePath, index, { spaces: 2 });
12221
- return filePath;
12222
- }
12223
- async function runScan(scanRoot, maxDepth = 2) {
12224
- const now = (/* @__PURE__ */ new Date()).toISOString();
12225
- const existing = await loadIndex(scanRoot);
12226
- const existingMap = new Map(
12227
- (existing?.projects ?? []).map((p) => [p.path, p])
12228
- );
12229
- const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
12230
- const added = [];
12231
- const updated = [];
12232
- const unchanged = [];
12233
- const seenPaths = /* @__PURE__ */ new Set();
12234
- for (const relPath of discoveredPaths) {
12235
- const absPath = path26.join(scanRoot, relPath);
12236
- seenPaths.add(relPath);
12237
- const { type, role } = await detectRepoType(absPath);
12238
- const techStack = await extractTechStack(absPath, type);
12239
- const hasConstitution = await fs27.pathExists(path26.join(absPath, CONSTITUTION_FILE));
12240
- const hasWorkspace = await fs27.pathExists(path26.join(absPath, WORKSPACE_CONFIG_FILE));
12241
- const name = path26.basename(relPath);
12242
- const prev = existingMap.get(relPath);
12243
- if (!prev) {
12244
- const entry = {
12245
- name,
12246
- path: relPath,
12247
- type,
12248
- role,
12249
- techStack,
12250
- hasConstitution,
12251
- hasWorkspace,
12252
- firstSeen: now,
12253
- lastSeen: now
12254
- };
12255
- added.push(entry);
12256
- existingMap.set(relPath, entry);
12257
- } else {
12258
- const changed = prev.type !== type || prev.role !== role || prev.hasConstitution !== hasConstitution || prev.hasWorkspace !== hasWorkspace || JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
12259
- const entry = {
12260
- ...prev,
12261
- type,
12262
- role,
12263
- techStack,
12264
- hasConstitution,
12265
- hasWorkspace,
12266
- lastSeen: now,
12267
- missing: void 0
12268
- // clear missing flag if it came back
12269
- };
12270
- existingMap.set(relPath, entry);
12271
- if (changed) {
12272
- updated.push(entry);
12273
- } else {
12274
- unchanged.push(entry);
12275
- }
12276
- }
12453
+ const prompt = buildGlobalConstitutionPrompt(summaries);
12454
+ let globalConstitution;
12455
+ try {
12456
+ globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
12457
+ } catch (err) {
12458
+ console.error(chalk28.red(` \u2718 Failed to generate global constitution: ${err.message}`));
12459
+ return;
12277
12460
  }
12278
- const nowMissing = [];
12279
- for (const [relPath, entry] of existingMap) {
12280
- if (!seenPaths.has(relPath) && !entry.missing) {
12281
- const gone = { ...entry, missing: true };
12282
- existingMap.set(relPath, gone);
12283
- nowMissing.push(gone);
12284
- }
12461
+ const saved = await saveGlobalConstitution(globalConstitution, currentDir);
12462
+ console.log(chalk28.green(` \u2714 Global constitution saved: ${saved}`));
12463
+ console.log(chalk28.gray(" Project constitutions will be merged with this at runtime."));
12464
+ const lines = globalConstitution.split("\n");
12465
+ console.log(chalk28.bold("\n Preview:"));
12466
+ console.log(chalk28.gray(lines.slice(0, 10).join("\n")));
12467
+ if (lines.length > 10) {
12468
+ console.log(chalk28.gray(` ... (${lines.length} lines total)`));
12285
12469
  }
12286
- const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
12287
- const index = {
12288
- scanRoot,
12289
- lastScanned: now,
12290
- projects
12291
- };
12292
- return { index, added, updated, unchanged, nowMissing };
12293
12470
  }
12294
-
12295
- // cli/commands/init.ts
12296
12471
  function registerInit(program2) {
12297
- program2.command("init").description(`Analyze codebase and generate Project Constitution (${CONSTITUTION_FILE})`).option(
12472
+ program2.command("init").description("Setup workspace: register repos, generate constitutions").option(
12298
12473
  "--provider <name>",
12299
12474
  `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
12300
12475
  void 0
12301
- ).option("--model <name>", "Model name").option("-k, --key <apiKey>", "API key").option("--force", "Overwrite existing constitution").option(
12302
- "--global",
12303
- `Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
12304
- ).option("--consolidate", "Consolidate \xA79 accumulated lessons into \xA71\u2013\xA78 core rules (prune & rebase)").option("--dry-run", "Preview consolidation result without writing (use with --consolidate)").action(async (opts) => {
12476
+ ).option("--model <name>", "Model name").option("-k, --key <apiKey>", "API key").option("--force", "Overwrite existing constitutions").option("--consolidate", "Consolidate \xA79 accumulated lessons into \xA71\u2013\xA78 core rules").option("--dry-run", "Preview consolidation result without writing (use with --consolidate)").option("--add-repo", "Add a new repo to the registered list").action(async (opts) => {
12305
12477
  const currentDir = process.cwd();
12306
12478
  const config2 = await loadConfig(currentDir);
12307
12479
  const providerName = opts.provider || config2.provider || "gemini";
@@ -12320,7 +12492,7 @@ function registerInit(program2) {
12320
12492
  console.log(chalk28.gray(` Lines : ${result.before.totalLines} \u2192 ${result.after.totalLines} (${result.before.totalLines - result.after.totalLines > 0 ? "-" : "+"}${Math.abs(result.before.totalLines - result.after.totalLines)})`));
12321
12493
  console.log(chalk28.gray(` \xA79 : ${result.before.lessonCount} \u2192 ${result.after.lessonCount} lessons remaining`));
12322
12494
  if (result.backupPath) {
12323
- console.log(chalk28.gray(` Backup: ${path27.basename(result.backupPath)}`));
12495
+ console.log(chalk28.gray(` Backup: ${path28.basename(result.backupPath)}`));
12324
12496
  }
12325
12497
  }
12326
12498
  } catch (err) {
@@ -12329,119 +12501,125 @@ function registerInit(program2) {
12329
12501
  }
12330
12502
  return;
12331
12503
  }
12332
- if (opts.global) {
12333
- const existing = await loadGlobalConstitution([currentDir]);
12334
- if (existing && !opts.force) {
12335
- console.log(chalk28.yellow(`
12336
- Global constitution already exists at: ${existing.source}`));
12337
- console.log(chalk28.gray(" Use --force to overwrite it."));
12338
- return;
12504
+ if (opts.addRepo) {
12505
+ console.log(chalk28.blue("\n\u2500\u2500\u2500 Register New Repo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12506
+ const role = await promptRepoRole2();
12507
+ const repoPath = await promptRepoPath(role);
12508
+ const entry = await registerSingleRepo(repoPath, provider, role);
12509
+ console.log(chalk28.green(`
12510
+ \u2714 Repo registered: ${entry.name} (${entry.type} / ${entry.role})`));
12511
+ console.log(chalk28.gray(` Saved to: ${REPO_STORE_FILE}`));
12512
+ const updateGlobal = await confirm2({
12513
+ message: "Update global constitution with this repo's context?",
12514
+ default: true
12515
+ });
12516
+ if (updateGlobal) {
12517
+ const allRepos2 = await getRegisteredRepos();
12518
+ await generateGlobalConstitution(provider, allRepos2, currentDir);
12339
12519
  }
12340
- console.log(chalk28.blue("\n\u2500\u2500\u2500 Generating Global Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12341
- console.log(chalk28.gray(` Provider: ${providerName}/${modelName}`));
12342
- const projectSummaries = [];
12343
- const index = await loadIndex(currentDir);
12344
- if (index && index.projects.length > 0) {
12345
- const active = index.projects.filter((p) => !p.missing);
12346
- console.log(chalk28.gray(` Found project index: ${active.length} project(s) \u2014 reading constitutions...`));
12347
- for (const entry of active) {
12348
- const absPath = path27.join(currentDir, entry.path);
12349
- const lines = [
12350
- `Type: ${entry.type} (${entry.role})`,
12351
- `Tech stack: ${entry.techStack.join(", ") || "unknown"}`
12352
- ];
12353
- if (entry.hasConstitution) {
12354
- try {
12355
- const constitutionPath2 = path27.join(absPath, CONSTITUTION_FILE);
12356
- const raw = await fs28.readFile(constitutionPath2, "utf-8");
12357
- const excerpt = raw.slice(0, 2e3);
12358
- lines.push("", "Constitution excerpt:", excerpt);
12359
- } catch {
12360
- }
12361
- }
12362
- projectSummaries.push({ name: entry.name, summary: lines.join("\n") });
12520
+ return;
12521
+ }
12522
+ console.log(chalk28.blue("\n" + "\u2500".repeat(52)));
12523
+ console.log(chalk28.bold(" ai-spec init \u2014 Workspace Setup"));
12524
+ console.log(chalk28.blue("\u2500".repeat(52)));
12525
+ console.log(chalk28.gray(` Provider: ${providerName}/${modelName}
12526
+ `));
12527
+ const existingRepos = await getRegisteredRepos();
12528
+ if (existingRepos.length > 0) {
12529
+ console.log(chalk28.cyan(" Registered repos:"));
12530
+ for (const r of existingRepos) {
12531
+ const constitutionIcon = r.hasConstitution ? chalk28.green("\u2714") : chalk28.gray("\u25CB");
12532
+ console.log(chalk28.gray(` ${constitutionIcon} ${r.name} (${r.type} / ${r.role}) \u2192 ${r.path}`));
12533
+ }
12534
+ console.log();
12535
+ }
12536
+ const action = existingRepos.length > 0 ? await select8({
12537
+ message: "What would you like to do?",
12538
+ choices: [
12539
+ { name: "Add new repo(s)", value: "add" },
12540
+ { name: "Re-generate constitutions for existing repos", value: "regen" },
12541
+ { name: "Skip \u2014 proceed to global constitution", value: "skip" }
12542
+ ]
12543
+ }) : "add";
12544
+ const newRepos = [];
12545
+ if (action === "add") {
12546
+ let addMore = true;
12547
+ while (addMore) {
12548
+ console.log(chalk28.blue(`
12549
+ \u2500\u2500 Register Repo #${existingRepos.length + newRepos.length + 1} \u2500\u2500`));
12550
+ const role = await promptRepoRole2();
12551
+ const repoPath = await promptRepoPath(role);
12552
+ const alreadyRegistered = [...existingRepos, ...newRepos].find((r) => r.path === repoPath);
12553
+ if (alreadyRegistered) {
12554
+ console.log(chalk28.yellow(` Already registered: ${alreadyRegistered.name}`));
12555
+ } else {
12556
+ const entry = await registerSingleRepo(repoPath, provider, role);
12557
+ newRepos.push(entry);
12558
+ console.log(chalk28.green(` \u2714 Registered: ${entry.name}`));
12363
12559
  }
12364
- } else {
12365
- console.log(chalk28.yellow(" No project index found. Run `ai-spec scan` first for better results."));
12366
- console.log(chalk28.gray(" Falling back: scanning current directory only..."));
12367
- const loader = new ContextLoader(currentDir);
12368
- const ctx = await loader.loadProjectContext();
12369
- projectSummaries.push({
12370
- name: path27.basename(currentDir),
12371
- summary: [
12372
- `Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
12373
- `Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`
12374
- ].join("\n")
12560
+ addMore = await confirm2({
12561
+ message: "Add another repo?",
12562
+ default: false
12375
12563
  });
12376
12564
  }
12377
- console.log(chalk28.gray(` Generating from ${projectSummaries.length} project(s)...`));
12378
- const prompt = buildGlobalConstitutionPrompt(projectSummaries);
12379
- let globalConstitution;
12380
- try {
12381
- globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
12382
- } catch (err) {
12383
- console.error(chalk28.red(" \u2718 Failed to generate global constitution:"), err);
12384
- process.exit(1);
12385
- }
12386
- const saved2 = await saveGlobalConstitution(globalConstitution, currentDir);
12387
- console.log(chalk28.green(`
12388
- \u2714 Global constitution saved: ${saved2}`));
12389
- console.log(chalk28.gray(" This will be automatically merged into all project constitutions in this workspace."));
12390
- console.log(chalk28.gray(" Project-level rules always override global rules.\n"));
12391
- console.log(chalk28.bold(" Preview:"));
12392
- console.log(chalk28.gray(globalConstitution.split("\n").slice(0, 12).join("\n")));
12393
- if (globalConstitution.split("\n").length > 12) {
12394
- console.log(chalk28.gray(` ... (${globalConstitution.split("\n").length} lines total)`));
12565
+ }
12566
+ if (action === "regen") {
12567
+ console.log(chalk28.blue("\n Re-generating project constitutions..."));
12568
+ for (const repo of existingRepos) {
12569
+ if (!await fs29.pathExists(repo.path)) {
12570
+ console.log(chalk28.yellow(` \u26A0 ${repo.name}: path not found \u2014 skipping`));
12571
+ continue;
12572
+ }
12573
+ const constitutionPath = path28.join(repo.path, CONSTITUTION_FILE);
12574
+ if (await fs29.pathExists(constitutionPath) && !opts.force) {
12575
+ console.log(chalk28.gray(` ${repo.name}: constitution exists (use --force to overwrite)`));
12576
+ continue;
12577
+ }
12578
+ console.log(chalk28.blue(` ${repo.name}: generating constitution...`));
12579
+ try {
12580
+ const gen = new ConstitutionGenerator(provider);
12581
+ const content = await gen.generate(repo.path);
12582
+ await gen.saveConstitution(repo.path, content);
12583
+ console.log(chalk28.green(` \u2714 ${repo.name}: constitution saved`));
12584
+ } catch (err) {
12585
+ console.log(chalk28.yellow(` \u26A0 ${repo.name}: failed \u2014 ${err.message}`));
12586
+ }
12395
12587
  }
12396
- return;
12397
12588
  }
12398
- const constitutionPath = path27.join(currentDir, CONSTITUTION_FILE);
12399
- if (!opts.force && await fs28.pathExists(constitutionPath)) {
12400
- console.log(chalk28.yellow(`
12401
- ${CONSTITUTION_FILE} already exists.`));
12402
- console.log(chalk28.gray(" Use --force to overwrite it."));
12403
- console.log(chalk28.gray(` Or edit it directly: ${constitutionPath}`));
12589
+ const allRepos = await getRegisteredRepos();
12590
+ if (allRepos.length === 0) {
12591
+ console.log(chalk28.yellow("\n No repos registered. Run `ai-spec init` again to add repos."));
12404
12592
  return;
12405
12593
  }
12406
- console.log(chalk28.blue("\n\u2500\u2500\u2500 Generating Project Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12407
- console.log(chalk28.gray(` Provider: ${providerName}/${modelName}`));
12408
- console.log(chalk28.gray(" Analyzing codebase..."));
12409
- const generator = new ConstitutionGenerator(provider);
12410
- let constitution;
12411
- try {
12412
- constitution = await generator.generate(currentDir);
12413
- } catch (err) {
12414
- console.error(chalk28.red(" \u2718 Failed to generate constitution:"), err);
12415
- process.exit(1);
12416
- }
12417
- const saved = await generator.saveConstitution(currentDir, constitution);
12418
- const globalResult = await loadGlobalConstitution([path27.dirname(currentDir)]);
12419
- if (globalResult) {
12420
- console.log(chalk28.cyan(`
12421
- \u2139 Global constitution detected: ${globalResult.source}`));
12422
- console.log(chalk28.gray(" It will be merged with this project constitution at runtime."));
12423
- console.log(chalk28.gray(" Project rules take priority over global rules."));
12424
- }
12425
- console.log(chalk28.green(`
12426
- \u2714 Constitution saved: ${saved}`));
12427
- console.log(chalk28.gray(" This file will be automatically used in all future `ai-spec create` runs."));
12428
- console.log(chalk28.gray(" Edit it to add custom rules or red lines for your project.\n"));
12429
- console.log(chalk28.bold(" Preview:"));
12430
- console.log(chalk28.gray(constitution.split("\n").slice(0, 15).join("\n")));
12431
- if (constitution.split("\n").length > 15) {
12432
- console.log(chalk28.gray(` ... (${constitution.split("\n").length} lines total)`));
12433
- }
12594
+ const existingGlobal = await loadGlobalConstitution([currentDir]);
12595
+ const shouldGenerateGlobal = !existingGlobal || opts.force || newRepos.length > 0 ? true : await confirm2({
12596
+ message: "Global constitution exists. Re-generate it?",
12597
+ default: false
12598
+ });
12599
+ if (shouldGenerateGlobal) {
12600
+ await generateGlobalConstitution(provider, allRepos, currentDir);
12601
+ }
12602
+ console.log(chalk28.bold.green("\n\u2714 Init complete!"));
12603
+ console.log(chalk28.gray(` Repos registered: ${allRepos.length}`));
12604
+ for (const r of allRepos) {
12605
+ const icon = r.hasConstitution ? chalk28.green("\u2714") : chalk28.gray("\u25CB");
12606
+ console.log(chalk28.gray(` ${icon} ${r.name} (${r.type}/${r.role})`));
12607
+ }
12608
+ console.log(chalk28.gray(`
12609
+ Repo store: ${REPO_STORE_FILE}`));
12610
+ console.log(chalk28.gray(` Next step: ai-spec create "your feature idea"`));
12611
+ process.exit(0);
12434
12612
  });
12435
12613
  }
12436
12614
 
12437
12615
  // cli/commands/config.ts
12438
- import * as path28 from "path";
12439
- import * as fs29 from "fs-extra";
12616
+ import * as path29 from "path";
12617
+ import * as fs30 from "fs-extra";
12440
12618
  import chalk29 from "chalk";
12441
12619
  function registerConfig(program2) {
12442
12620
  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) => {
12443
12621
  const currentDir = process.cwd();
12444
- const configPath = path28.join(currentDir, CONFIG_FILE);
12622
+ const configPath = path29.join(currentDir, CONFIG_FILE);
12445
12623
  if (opts.clearKeys) {
12446
12624
  await clearAllKeys();
12447
12625
  console.log(chalk29.green(`\u2714 All saved API keys cleared.`));
@@ -12453,7 +12631,7 @@ function registerConfig(program2) {
12453
12631
  return;
12454
12632
  }
12455
12633
  if (opts.listKeys) {
12456
- const store = await fs29.readJson(KEY_STORE_FILE).catch(() => ({}));
12634
+ const store = await fs30.readJson(KEY_STORE_FILE).catch(() => ({}));
12457
12635
  const providers = Object.keys(store);
12458
12636
  if (providers.length === 0) {
12459
12637
  console.log(chalk29.gray("No saved API keys."));
@@ -12469,7 +12647,7 @@ File: ${KEY_STORE_FILE}`));
12469
12647
  return;
12470
12648
  }
12471
12649
  if (opts.reset) {
12472
- await fs29.writeJson(configPath, {}, { spaces: 2 });
12650
+ await fs30.writeJson(configPath, {}, { spaces: 2 });
12473
12651
  console.log(chalk29.green(`\u2714 Config reset: ${configPath}`));
12474
12652
  return;
12475
12653
  }
@@ -12513,7 +12691,7 @@ File: ${KEY_STORE_FILE}`));
12513
12691
  }
12514
12692
  updated.maxErrorCycles = cycles;
12515
12693
  }
12516
- await fs29.writeJson(configPath, updated, { spaces: 2 });
12694
+ await fs30.writeJson(configPath, updated, { spaces: 2 });
12517
12695
  console.log(chalk29.green(`\u2714 Config saved to ${configPath}`));
12518
12696
  console.log(JSON.stringify(updated, null, 2));
12519
12697
  });
@@ -12521,14 +12699,10 @@ File: ${KEY_STORE_FILE}`));
12521
12699
 
12522
12700
  // cli/commands/model.ts
12523
12701
  init_spec_generator();
12524
- import * as path29 from "path";
12525
- import * as fs30 from "fs-extra";
12526
12702
  import chalk30 from "chalk";
12527
- import { input as input3, select as select7, confirm as confirm2 } from "@inquirer/prompts";
12703
+ import { input as input4, select as select9, confirm as confirm3 } from "@inquirer/prompts";
12528
12704
  function registerModel(program2) {
12529
12705
  program2.command("model").description("Interactively switch the active AI provider/model and save to .ai-spec.json").option("--list", "List all available providers and models").action(async (opts) => {
12530
- const currentDir = process.cwd();
12531
- const configPath = path29.join(currentDir, CONFIG_FILE);
12532
12706
  if (opts.list) {
12533
12707
  console.log(chalk30.bold("\nAvailable providers & models:\n"));
12534
12708
  for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
@@ -12545,8 +12719,9 @@ function registerModel(program2) {
12545
12719
  }
12546
12720
  return;
12547
12721
  }
12548
- const existing = await loadConfig(currentDir);
12722
+ const existing = await loadGlobalConfig();
12549
12723
  console.log(chalk30.blue("\n\u2500\u2500\u2500 Model Switcher \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12724
+ console.log(chalk30.gray(` Config: ${GLOBAL_CONFIG_FILE}`));
12550
12725
  if (Object.keys(existing).length > 0) {
12551
12726
  console.log(
12552
12727
  chalk30.gray(
@@ -12555,7 +12730,7 @@ function registerModel(program2) {
12555
12730
  );
12556
12731
  }
12557
12732
  console.log();
12558
- const target = await select7({
12733
+ const target = await select9({
12559
12734
  message: "Configure model for:",
12560
12735
  choices: [
12561
12736
  { name: "Spec generation (used for spec writing & refinement)", value: "spec" },
@@ -12564,7 +12739,7 @@ function registerModel(program2) {
12564
12739
  ]
12565
12740
  });
12566
12741
  async function pickProviderAndModel(label) {
12567
- const providerKey = await select7({
12742
+ const providerKey = await select9({
12568
12743
  message: `${label} \u2014 select provider:`,
12569
12744
  choices: Object.entries(PROVIDER_CATALOG).map(([key, meta2]) => ({
12570
12745
  name: `${meta2.displayName.padEnd(22)} ${chalk30.gray(meta2.description)}`,
@@ -12577,12 +12752,12 @@ function registerModel(program2) {
12577
12752
  ...meta.models.map((m) => ({ name: m, value: m })),
12578
12753
  { name: chalk30.italic("\u270E Enter custom model name..."), value: "__custom__" }
12579
12754
  ];
12580
- let chosenModel = await select7({
12755
+ let chosenModel = await select9({
12581
12756
  message: `${label} \u2014 select model (${meta.displayName}):`,
12582
12757
  choices: modelChoices
12583
12758
  });
12584
12759
  if (chosenModel === "__custom__") {
12585
- chosenModel = await input3({
12760
+ chosenModel = await input4({
12586
12761
  message: "Enter model name:",
12587
12762
  validate: (v2) => v2.trim().length > 0 || "Model name cannot be empty"
12588
12763
  });
@@ -12627,14 +12802,14 @@ function registerModel(program2) {
12627
12802
  )
12628
12803
  );
12629
12804
  }
12630
- const ok = await confirm2({ message: "Save to .ai-spec.json?", default: true });
12805
+ const ok = await confirm3({ message: `Save to ${GLOBAL_CONFIG_FILE}?`, default: true });
12631
12806
  if (!ok) {
12632
12807
  console.log(chalk30.gray(" Cancelled."));
12633
12808
  return;
12634
12809
  }
12635
- await fs30.writeJson(configPath, updated, { spaces: 2 });
12810
+ await saveGlobalConfig(updated);
12636
12811
  console.log(chalk30.green(`
12637
- \u2714 Saved to ${configPath}`));
12812
+ \u2714 Saved to ${GLOBAL_CONFIG_FILE}`));
12638
12813
  const providerToCheck = updated.provider ?? "gemini";
12639
12814
  const envKey = ENV_KEY_MAP[providerToCheck];
12640
12815
  if (envKey && !process.env[envKey]) {
@@ -12651,14 +12826,14 @@ function registerModel(program2) {
12651
12826
  import * as path30 from "path";
12652
12827
  import * as fs31 from "fs-extra";
12653
12828
  import chalk31 from "chalk";
12654
- import { input as input4, select as select8, confirm as confirm3 } from "@inquirer/prompts";
12829
+ import { input as input5, select as select10, confirm as confirm4 } from "@inquirer/prompts";
12655
12830
  function registerWorkspace(program2) {
12656
12831
  const workspaceCmd = program2.command("workspace").description("Manage multi-repo workspace configuration");
12657
12832
  workspaceCmd.command("init").description(`Interactive workspace setup \u2014 creates ${WORKSPACE_CONFIG_FILE}`).action(async () => {
12658
12833
  const currentDir = process.cwd();
12659
12834
  const configPath = path30.join(currentDir, WORKSPACE_CONFIG_FILE);
12660
12835
  if (await fs31.pathExists(configPath)) {
12661
- const overwrite = await confirm3({
12836
+ const overwrite = await confirm4({
12662
12837
  message: `${WORKSPACE_CONFIG_FILE} already exists. Overwrite?`,
12663
12838
  default: false
12664
12839
  });
@@ -12668,12 +12843,12 @@ function registerWorkspace(program2) {
12668
12843
  }
12669
12844
  }
12670
12845
  console.log(chalk31.blue("\n\u2500\u2500\u2500 Workspace Setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12671
- const workspaceName = await input4({
12846
+ const workspaceName = await input5({
12672
12847
  message: "Workspace name:",
12673
12848
  validate: (v2) => v2.trim().length > 0 || "Name cannot be empty"
12674
12849
  });
12675
12850
  const repos = [];
12676
- const useAutoScan = await confirm3({
12851
+ const useAutoScan = await confirm4({
12677
12852
  message: "Auto-scan sibling directories for repos?",
12678
12853
  default: true
12679
12854
  });
@@ -12687,7 +12862,7 @@ function registerWorkspace(program2) {
12687
12862
  for (const r of detected) {
12688
12863
  console.log(chalk31.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
12689
12864
  }
12690
- const keepAll = await confirm3({
12865
+ const keepAll = await confirm4({
12691
12866
  message: `Include all ${detected.length} detected repo(s)?`,
12692
12867
  default: true
12693
12868
  });
@@ -12695,7 +12870,7 @@ function registerWorkspace(program2) {
12695
12870
  repos.push(...detected);
12696
12871
  } else {
12697
12872
  for (const r of detected) {
12698
- const keep = await confirm3({
12873
+ const keep = await confirm4({
12699
12874
  message: `Include "${r.name}" (${r.role}, ${r.type})?`,
12700
12875
  default: true
12701
12876
  });
@@ -12719,14 +12894,14 @@ function registerWorkspace(program2) {
12719
12894
  { name: "react-native (React Native mobile)", value: "react-native" },
12720
12895
  { name: "unknown", value: "unknown" }
12721
12896
  ];
12722
- let addMore = await confirm3({
12897
+ let addMore = await confirm4({
12723
12898
  message: repos.length > 0 ? "Manually add more repos?" : "Add repos manually?",
12724
12899
  default: repos.length === 0
12725
12900
  });
12726
12901
  while (addMore) {
12727
12902
  console.log(chalk31.cyan(`
12728
12903
  Adding repo #${repos.length + 1}`));
12729
- const repoName = await input4({
12904
+ const repoName = await input5({
12730
12905
  message: "Repo name (e.g. api, web, app):",
12731
12906
  validate: (v2) => {
12732
12907
  if (!v2.trim()) return "Name cannot be empty";
@@ -12734,7 +12909,7 @@ function registerWorkspace(program2) {
12734
12909
  return true;
12735
12910
  }
12736
12911
  });
12737
- const repoPath = await input4({
12912
+ const repoPath = await input5({
12738
12913
  message: `Relative path to "${repoName}" from here (default: ./${repoName}):`,
12739
12914
  default: `./${repoName}`
12740
12915
  });
@@ -12749,12 +12924,12 @@ function registerWorkspace(program2) {
12749
12924
  } else {
12750
12925
  console.log(chalk31.yellow(` Path "${absPath}" not found \u2014 type/role will be manual.`));
12751
12926
  }
12752
- const repoType = await select8({
12927
+ const repoType = await select10({
12753
12928
  message: `Repo type for "${repoName}":`,
12754
12929
  choices: repoTypeChoices,
12755
12930
  default: detectedType
12756
12931
  });
12757
- const repoRole = await select8({
12932
+ const repoRole = await select10({
12758
12933
  message: `Repo role for "${repoName}":`,
12759
12934
  choices: [
12760
12935
  { name: "backend", value: "backend" },
@@ -12771,7 +12946,7 @@ function registerWorkspace(program2) {
12771
12946
  role: repoRole
12772
12947
  });
12773
12948
  console.log(chalk31.green(` \u2714 Added: ${repoName} (${repoRole}, ${repoType})`));
12774
- addMore = await confirm3({
12949
+ addMore = await confirm4({
12775
12950
  message: "Add another repo?",
12776
12951
  default: false
12777
12952
  });
@@ -12782,7 +12957,7 @@ function registerWorkspace(program2) {
12782
12957
  for (const r of repos) {
12783
12958
  console.log(chalk31.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
12784
12959
  }
12785
- const ok = await confirm3({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
12960
+ const ok = await confirm4({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
12786
12961
  if (!ok) {
12787
12962
  console.log(chalk31.gray(" Cancelled."));
12788
12963
  return;
@@ -12827,7 +13002,7 @@ init_spec_generator();
12827
13002
  import * as path32 from "path";
12828
13003
  import * as fs33 from "fs-extra";
12829
13004
  import chalk33 from "chalk";
12830
- import { input as input5 } from "@inquirer/prompts";
13005
+ import { input as input6 } from "@inquirer/prompts";
12831
13006
 
12832
13007
  // core/spec-updater.ts
12833
13008
  import chalk32 from "chalk";
@@ -13061,7 +13236,7 @@ function registerUpdate(program2) {
13061
13236
  const currentDir = process.cwd();
13062
13237
  const config2 = await loadConfig(currentDir);
13063
13238
  if (!change) {
13064
- change = await input5({
13239
+ change = await input6({
13065
13240
  message: "Describe the change you want to make:",
13066
13241
  validate: (v2) => v2.trim().length > 0 || "Change description cannot be empty"
13067
13242
  });
@@ -13379,7 +13554,7 @@ function buildYamlDoc(obj) {
13379
13554
  return `${k2}: ${valStr}`;
13380
13555
  }).join("\n") + "\n";
13381
13556
  }
13382
- function dslToOpenApi(dsl, serverUrl = "http://localhost:3000") {
13557
+ function dslToOpenApi(dsl, serverUrl = DEFAULT_OPENAPI_SERVER_URL) {
13383
13558
  const info = {
13384
13559
  title: dsl.feature.title,
13385
13560
  description: dsl.feature.description,
@@ -13426,7 +13601,7 @@ function dslToOpenApi(dsl, serverUrl = "http://localhost:3000") {
13426
13601
  }
13427
13602
  async function exportOpenApi(dsl, projectDir, opts = {}) {
13428
13603
  const format = opts.format ?? "yaml";
13429
- const serverUrl = opts.serverUrl ?? "http://localhost:3000";
13604
+ const serverUrl = opts.serverUrl ?? DEFAULT_OPENAPI_SERVER_URL;
13430
13605
  const defaultName = `openapi.${format}`;
13431
13606
  const outputPath = opts.outputPath ? path33.isAbsolute(opts.outputPath) ? opts.outputPath : path33.join(projectDir, opts.outputPath) : path33.join(projectDir, defaultName);
13432
13607
  const doc = dslToOpenApi(dsl, serverUrl);
@@ -13622,12 +13797,12 @@ function registerMock(program2) {
13622
13797
 
13623
13798
  // cli/commands/learn.ts
13624
13799
  import chalk36 from "chalk";
13625
- import { input as input6 } from "@inquirer/prompts";
13800
+ import { input as input7 } from "@inquirer/prompts";
13626
13801
  function registerLearn(program2) {
13627
13802
  program2.command("learn").description("Append a lesson or engineering decision directly to constitution \xA79").argument("[lesson]", "The lesson or decision to record (prompted if omitted)").action(async (lesson) => {
13628
13803
  const currentDir = process.cwd();
13629
13804
  if (!lesson) {
13630
- lesson = await input6({
13805
+ lesson = await input7({
13631
13806
  message: "What lesson or engineering decision should be recorded?",
13632
13807
  validate: (v2) => v2.trim().length > 0 || "Please enter a lesson"
13633
13808
  });
@@ -13669,9 +13844,8 @@ import chalk39 from "chalk";
13669
13844
  import * as fs37 from "fs-extra";
13670
13845
  import * as path36 from "path";
13671
13846
  import chalk38 from "chalk";
13672
- var LOG_DIR2 = ".ai-spec-logs";
13673
13847
  async function loadRunLogs(workingDir) {
13674
- const logDir = path36.join(workingDir, LOG_DIR2);
13848
+ const logDir = path36.join(workingDir, DEFAULT_LOG_DIR);
13675
13849
  if (!await fs37.pathExists(logDir)) return [];
13676
13850
  const files = await fs37.readdir(logDir);
13677
13851
  const jsonFiles = new Set(files.filter((f) => f.endsWith(".json")));
@@ -13816,7 +13990,7 @@ function printTrendReport(report, workingDir) {
13816
13990
  ` ${chalk38.gray(formatDate(e.startedAt))} ${bar}${scoreStr} ${hash} ${dur}${errMark} ${spec}`
13817
13991
  );
13818
13992
  }
13819
- const logRelDir = path36.relative(workingDir, path36.join(workingDir, LOG_DIR2));
13993
+ const logRelDir = path36.relative(workingDir, path36.join(workingDir, DEFAULT_LOG_DIR));
13820
13994
  console.log(chalk38.gray(`
13821
13995
  ${entries.length} run(s) shown \xB7 logs: ${logRelDir}/`));
13822
13996
  console.log(chalk38.cyan("\u2500".repeat(63)));
@@ -14475,7 +14649,233 @@ function registerVcr(program2) {
14475
14649
 
14476
14650
  // cli/commands/scan.ts
14477
14651
  import chalk44 from "chalk";
14652
+ import * as path42 from "path";
14653
+
14654
+ // core/project-index.ts
14655
+ import * as fs42 from "fs-extra";
14478
14656
  import * as path41 from "path";
14657
+ var INDEX_FILE = ".ai-spec-index.json";
14658
+ var KEY_DEPS = [
14659
+ // Frameworks
14660
+ "express",
14661
+ "fastify",
14662
+ "koa",
14663
+ "@nestjs/core",
14664
+ "hapi",
14665
+ "next",
14666
+ "react",
14667
+ "vue",
14668
+ "nuxt",
14669
+ "svelte",
14670
+ "react-native",
14671
+ "expo",
14672
+ // DB / ORM
14673
+ "prisma",
14674
+ "@prisma/client",
14675
+ "mongoose",
14676
+ "typeorm",
14677
+ "sequelize",
14678
+ "drizzle-orm",
14679
+ // Auth
14680
+ "jsonwebtoken",
14681
+ "passport",
14682
+ "next-auth",
14683
+ "@clerk/nextjs",
14684
+ // Build / Lang
14685
+ "typescript",
14686
+ "vite",
14687
+ "webpack",
14688
+ "esbuild",
14689
+ "turbo",
14690
+ // Testing
14691
+ "jest",
14692
+ "vitest",
14693
+ "mocha",
14694
+ "cypress",
14695
+ "playwright",
14696
+ // Infra
14697
+ "redis",
14698
+ "bull",
14699
+ "socket.io",
14700
+ "graphql",
14701
+ "@trpc/server"
14702
+ ];
14703
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
14704
+ "node_modules",
14705
+ ".git",
14706
+ ".svn",
14707
+ "dist",
14708
+ "build",
14709
+ "out",
14710
+ ".next",
14711
+ ".nuxt",
14712
+ "coverage",
14713
+ ".turbo",
14714
+ ".cache",
14715
+ "__pycache__",
14716
+ "vendor",
14717
+ ".ai-spec-vcr",
14718
+ ".ai-spec-logs",
14719
+ "specs"
14720
+ ]);
14721
+ var MANIFEST_FILES = [
14722
+ "package.json",
14723
+ "go.mod",
14724
+ "Cargo.toml",
14725
+ "pom.xml",
14726
+ "build.gradle",
14727
+ "build.gradle.kts",
14728
+ "requirements.txt",
14729
+ "pyproject.toml",
14730
+ "setup.py",
14731
+ "composer.json"
14732
+ ];
14733
+ async function isProjectRoot(absPath) {
14734
+ for (const manifest of MANIFEST_FILES) {
14735
+ if (await fs42.pathExists(path41.join(absPath, manifest))) return true;
14736
+ }
14737
+ return false;
14738
+ }
14739
+ async function extractTechStack(absPath, type) {
14740
+ const stack = [];
14741
+ if (type === "go") stack.push("go");
14742
+ if (type === "rust") stack.push("rust");
14743
+ if (type === "java") stack.push("java");
14744
+ if (type === "python") stack.push("python");
14745
+ if (type === "php") stack.push("php");
14746
+ const pkgPath = path41.join(absPath, "package.json");
14747
+ if (!await fs42.pathExists(pkgPath)) return stack;
14748
+ let pkg = {};
14749
+ try {
14750
+ pkg = await fs42.readJson(pkgPath);
14751
+ } catch {
14752
+ return stack;
14753
+ }
14754
+ const allDeps = {
14755
+ ...pkg.dependencies ?? {},
14756
+ ...pkg.devDependencies ?? {}
14757
+ };
14758
+ const depKeys = new Set(Object.keys(allDeps));
14759
+ for (const dep of KEY_DEPS) {
14760
+ if (depKeys.has(dep)) stack.push(dep);
14761
+ }
14762
+ return stack;
14763
+ }
14764
+ async function discoverProjects(rootDir, maxDepth) {
14765
+ const found = [];
14766
+ async function walk(absDir, depth) {
14767
+ if (depth > maxDepth) return;
14768
+ let entries;
14769
+ try {
14770
+ entries = await fs42.readdir(absDir, { withFileTypes: true });
14771
+ } catch {
14772
+ return;
14773
+ }
14774
+ for (const entry of entries) {
14775
+ if (!entry.isDirectory()) continue;
14776
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
14777
+ const childAbs = path41.join(absDir, entry.name);
14778
+ const gitPath = path41.join(childAbs, ".git");
14779
+ if (await fs42.pathExists(gitPath)) {
14780
+ const gitStat = await fs42.stat(gitPath);
14781
+ if (gitStat.isFile()) continue;
14782
+ }
14783
+ if (await isProjectRoot(childAbs)) {
14784
+ found.push(path41.relative(rootDir, childAbs));
14785
+ } else {
14786
+ await walk(childAbs, depth + 1);
14787
+ }
14788
+ }
14789
+ }
14790
+ await walk(rootDir, 0);
14791
+ return found;
14792
+ }
14793
+ async function loadIndex(scanRoot) {
14794
+ const filePath = path41.join(scanRoot, INDEX_FILE);
14795
+ try {
14796
+ return await fs42.readJson(filePath);
14797
+ } catch {
14798
+ return null;
14799
+ }
14800
+ }
14801
+ async function saveIndex(scanRoot, index) {
14802
+ const filePath = path41.join(scanRoot, INDEX_FILE);
14803
+ await fs42.writeJson(filePath, index, { spaces: 2 });
14804
+ return filePath;
14805
+ }
14806
+ async function runScan(scanRoot, maxDepth = 2) {
14807
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14808
+ const existing = await loadIndex(scanRoot);
14809
+ const existingMap = new Map(
14810
+ (existing?.projects ?? []).map((p) => [p.path, p])
14811
+ );
14812
+ const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
14813
+ const added = [];
14814
+ const updated = [];
14815
+ const unchanged = [];
14816
+ const seenPaths = /* @__PURE__ */ new Set();
14817
+ for (const relPath of discoveredPaths) {
14818
+ const absPath = path41.join(scanRoot, relPath);
14819
+ seenPaths.add(relPath);
14820
+ const { type, role } = await detectRepoType(absPath);
14821
+ const techStack = await extractTechStack(absPath, type);
14822
+ const hasConstitution = await fs42.pathExists(path41.join(absPath, CONSTITUTION_FILE));
14823
+ const hasWorkspace = await fs42.pathExists(path41.join(absPath, WORKSPACE_CONFIG_FILE));
14824
+ const name = path41.basename(relPath);
14825
+ const prev = existingMap.get(relPath);
14826
+ if (!prev) {
14827
+ const entry = {
14828
+ name,
14829
+ path: relPath,
14830
+ type,
14831
+ role,
14832
+ techStack,
14833
+ hasConstitution,
14834
+ hasWorkspace,
14835
+ firstSeen: now,
14836
+ lastSeen: now
14837
+ };
14838
+ added.push(entry);
14839
+ existingMap.set(relPath, entry);
14840
+ } else {
14841
+ const changed = prev.type !== type || prev.role !== role || prev.hasConstitution !== hasConstitution || prev.hasWorkspace !== hasWorkspace || JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
14842
+ const entry = {
14843
+ ...prev,
14844
+ type,
14845
+ role,
14846
+ techStack,
14847
+ hasConstitution,
14848
+ hasWorkspace,
14849
+ lastSeen: now,
14850
+ missing: void 0
14851
+ // clear missing flag if it came back
14852
+ };
14853
+ existingMap.set(relPath, entry);
14854
+ if (changed) {
14855
+ updated.push(entry);
14856
+ } else {
14857
+ unchanged.push(entry);
14858
+ }
14859
+ }
14860
+ }
14861
+ const nowMissing = [];
14862
+ for (const [relPath, entry] of existingMap) {
14863
+ if (!seenPaths.has(relPath) && !entry.missing) {
14864
+ const gone = { ...entry, missing: true };
14865
+ existingMap.set(relPath, gone);
14866
+ nowMissing.push(gone);
14867
+ }
14868
+ }
14869
+ const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
14870
+ const index = {
14871
+ scanRoot,
14872
+ lastScanned: now,
14873
+ projects
14874
+ };
14875
+ return { index, added, updated, unchanged, nowMissing };
14876
+ }
14877
+
14878
+ // cli/commands/scan.ts
14479
14879
  var ROLE_COLOR = {
14480
14880
  backend: chalk44.blue,
14481
14881
  frontend: chalk44.green,
@@ -14549,7 +14949,7 @@ Scanning ${cwd} (depth: ${maxDepth})...`));
14549
14949
  }
14550
14950
  console.log(chalk44.cyan("\n\u2500".repeat(52)));
14551
14951
  console.log(chalk44.gray(" \xA7C = has constitution W = workspace root"));
14552
- console.log(chalk44.gray(` Index saved : ${path41.relative(cwd, path41.join(cwd, INDEX_FILE))}`));
14952
+ console.log(chalk44.gray(` Index saved : ${path42.relative(cwd, path42.join(cwd, INDEX_FILE))}`));
14553
14953
  console.log(chalk44.gray(` Next steps : ai-spec scan --list | ai-spec init [--global]`));
14554
14954
  });
14555
14955
  }
@@ -14557,7 +14957,7 @@ Scanning ${cwd} (depth: ${maxDepth})...`));
14557
14957
  // cli/index.ts
14558
14958
  dotenv.config();
14559
14959
  var program = new Command();
14560
- program.name("ai-spec").description("AI-driven Development Orchestrator \u2014 spec, generate, review").version("0.14.1");
14960
+ program.name("ai-spec").description("AI-driven Development Orchestrator \u2014 spec, generate, review").version(require_package().version);
14561
14961
  registerCreate(program);
14562
14962
  registerReview(program);
14563
14963
  registerInit(program);