ai-spec-dev 0.41.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.
package/dist/cli/index.js CHANGED
@@ -9,6 +9,9 @@ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
9
  var __esm = (fn, res) => function __init() {
10
10
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
11
11
  };
12
+ var __commonJS = (cb, mod) => function __require() {
13
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
14
+ };
12
15
  var __export = (target, all) => {
13
16
  for (var name in all)
14
17
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -147,6 +150,100 @@ CRITICAL \u2014 \u5386\u53F2\u6559\u8BAD\u5E94\u7528\uFF08Accumulated Lessons\uF
147
150
  }
148
151
  });
149
152
 
153
+ // core/cli-ui.ts
154
+ function startSpinner(text) {
155
+ const isTTY = process.stderr.isTTY;
156
+ let frame = 0;
157
+ let currentText = text;
158
+ let stopped = false;
159
+ function render() {
160
+ if (stopped) return;
161
+ const symbol = import_chalk.default.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
162
+ if (isTTY) {
163
+ process.stderr.write(`\r ${symbol} ${currentText}${" ".repeat(10)}`);
164
+ }
165
+ frame++;
166
+ }
167
+ if (!isTTY) {
168
+ process.stderr.write(` \u2026 ${currentText}
169
+ `);
170
+ }
171
+ const timer = setInterval(render, 80);
172
+ render();
173
+ return {
174
+ update(newText) {
175
+ currentText = newText;
176
+ },
177
+ stop(finalText) {
178
+ if (stopped) return;
179
+ stopped = true;
180
+ clearInterval(timer);
181
+ if (isTTY) {
182
+ process.stderr.write(`\r${" ".repeat(currentText.length + 20)}\r`);
183
+ }
184
+ if (finalText) {
185
+ process.stderr.write(` ${finalText}
186
+ `);
187
+ }
188
+ },
189
+ succeed(successText) {
190
+ this.stop(import_chalk.default.green(`\u2714 ${successText}`));
191
+ },
192
+ fail(failText) {
193
+ this.stop(import_chalk.default.red(`\u2718 ${failText}`));
194
+ }
195
+ };
196
+ }
197
+ async function retryCountdown(opts) {
198
+ const { attempt, maxAttempts, waitMs, errorMessage, label } = opts;
199
+ const isTTY = process.stderr.isTTY;
200
+ const shortErr = errorMessage.length > 120 ? errorMessage.slice(0, 117) + "..." : errorMessage;
201
+ process.stderr.write("\n");
202
+ process.stderr.write(import_chalk.default.yellow(` \u250C\u2500 Retry ${attempt}/${maxAttempts} `) + import_chalk.default.gray(`[${label}]`) + import_chalk.default.yellow(` ${"\u2500".repeat(Math.max(1, 40 - label.length))}
203
+ `));
204
+ process.stderr.write(import_chalk.default.yellow(` \u2502 `) + import_chalk.default.white(shortErr) + "\n");
205
+ process.stderr.write(import_chalk.default.yellow(` \u2502 `) + import_chalk.default.gray(`Waiting before retry...`) + "\n");
206
+ const totalSeconds = Math.ceil(waitMs / 1e3);
207
+ for (let s = totalSeconds; s > 0; s--) {
208
+ const bar = import_chalk.default.green("\u2588".repeat(totalSeconds - s)) + import_chalk.default.gray("\u2591".repeat(s));
209
+ const line = import_chalk.default.yellow(` \u2502 `) + `${bar} ${import_chalk.default.bold.white(`${s}s`)}`;
210
+ if (isTTY) {
211
+ process.stderr.write(`\r${line}${" ".repeat(10)}`);
212
+ }
213
+ await new Promise((r) => setTimeout(r, 1e3));
214
+ }
215
+ if (isTTY) {
216
+ process.stderr.write(`\r${" ".repeat(70)}\r`);
217
+ }
218
+ process.stderr.write(import_chalk.default.yellow(` \u2514\u2500 `) + import_chalk.default.cyan(`Retrying now...`) + "\n\n");
219
+ }
220
+ function startStage(stageKey, label) {
221
+ const icon = STAGE_ICONS[stageKey] ?? "\u25B8";
222
+ return startSpinner(`${icon} ${label}`);
223
+ }
224
+ var import_chalk, SPINNER_FRAMES, STAGE_ICONS;
225
+ var init_cli_ui = __esm({
226
+ "core/cli-ui.ts"() {
227
+ "use strict";
228
+ import_chalk = __toESM(require("chalk"));
229
+ SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
230
+ STAGE_ICONS = {
231
+ context_load: "\u{1F4C2}",
232
+ design_dialogue: "\u{1F4AC}",
233
+ spec_gen: "\u{1F4DD}",
234
+ spec_refine: "\u270F\uFE0F ",
235
+ spec_assess: "\u{1F4CA}",
236
+ dsl_extract: "\u{1F517}",
237
+ dsl_gap_feedback: "\u{1F50D}",
238
+ codegen: "\u2699\uFE0F ",
239
+ test_gen: "\u{1F9EA}",
240
+ error_feedback: "\u{1F527}",
241
+ review: "\u{1F50E}",
242
+ self_eval: "\u{1F4C8}"
243
+ };
244
+ }
245
+ });
246
+
150
247
  // core/provider-utils.ts
151
248
  function classifyError(err, label) {
152
249
  const e = err;
@@ -223,21 +320,23 @@ async function withReliability(fn, opts) {
223
320
  throw classifyError(err, label);
224
321
  }
225
322
  const waitMs = attempt === 0 ? 2e3 : 6e3;
226
- console.warn(
227
- import_chalk.default.yellow(` \u26A0 ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${waitMs / 1e3}s`) + import_chalk.default.gray(` \u2014 ${err.message}`)
228
- );
229
323
  onRetry?.(attempt + 1, err);
230
- await sleep(waitMs);
324
+ await retryCountdown({
325
+ attempt: attempt + 1,
326
+ maxAttempts: retries + 1,
327
+ waitMs,
328
+ errorMessage: err.message ?? String(err),
329
+ label
330
+ });
231
331
  }
232
332
  }
233
333
  throw new Error("unreachable");
234
334
  }
235
- var import_chalk, sleep, ProviderError;
335
+ var ProviderError;
236
336
  var init_provider_utils = __esm({
237
337
  "core/provider-utils.ts"() {
238
338
  "use strict";
239
- import_chalk = __toESM(require("chalk"));
240
- sleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
339
+ init_cli_ui();
241
340
  ProviderError = class extends Error {
242
341
  constructor(message, kind, originalError) {
243
342
  super(message);
@@ -295,14 +394,13 @@ function createProvider(providerName, apiKey, modelName) {
295
394
  );
296
395
  }
297
396
  }
298
- var import_generative_ai, import_sdk, import_openai, import_axios, import_undici, PROVIDER_CATALOG, SUPPORTED_PROVIDERS, DEFAULT_MODELS, ENV_KEY_MAP, GeminiProvider, ClaudeProvider, OpenAICompatibleProvider, MiMoProvider, SpecGenerator;
397
+ var import_generative_ai, import_sdk, import_openai, import_undici, PROVIDER_CATALOG, SUPPORTED_PROVIDERS, DEFAULT_MODELS, ENV_KEY_MAP, GeminiProvider, ClaudeProvider, OpenAICompatibleProvider, MiMoProvider, SpecGenerator;
299
398
  var init_spec_generator = __esm({
300
399
  "core/spec-generator.ts"() {
301
400
  "use strict";
302
401
  import_generative_ai = require("@google/generative-ai");
303
402
  import_sdk = __toESM(require("@anthropic-ai/sdk"));
304
403
  import_openai = __toESM(require("openai"));
305
- import_axios = __toESM(require("axios"));
306
404
  import_undici = require("undici");
307
405
  init_spec_prompt();
308
406
  init_provider_utils();
@@ -312,7 +410,9 @@ var init_spec_generator = __esm({
312
410
  displayName: "MiMo (Xiaomi)",
313
411
  description: "\u5C0F\u7C73 MiMo \u2014 mimo-v2-pro (Anthropic-compatible API)",
314
412
  models: ["mimo-v2-pro"],
315
- envKey: "MIMO_API_KEY"
413
+ envKey: "MIMO_API_KEY",
414
+ // Fallback env var — MiMo's token plan uses ANTHROPIC_AUTH_TOKEN
415
+ fallbackEnvKeys: ["ANTHROPIC_AUTH_TOKEN"]
316
416
  // baseURL not used — MiMo has a dedicated provider class
317
417
  },
318
418
  gemini: {
@@ -481,8 +581,8 @@ var init_spec_generator = __esm({
481
581
  ...systemInstruction ? { system: systemInstruction } : {},
482
582
  messages: [{ role: "user", content: prompt }]
483
583
  });
484
- const block = message.content[0];
485
- if (block.type === "text") return block.text;
584
+ const textBlock = message.content.find((b) => b.type === "text");
585
+ if (textBlock) return textBlock.text;
486
586
  throw new Error("Unexpected response type from Claude API");
487
587
  },
488
588
  { label: `${this.providerName}/${this.modelName}` }
@@ -527,43 +627,29 @@ var init_spec_generator = __esm({
527
627
  }
528
628
  };
529
629
  MiMoProvider = class {
630
+ client;
530
631
  providerName = "mimo";
531
632
  modelName;
532
- apiKey;
533
- baseUrl = "https://api.xiaomimimo.com/anthropic/v1/messages";
534
633
  constructor(apiKey, modelName = PROVIDER_CATALOG.mimo.models[0]) {
535
- this.apiKey = apiKey;
634
+ const baseURL = process.env["MIMO_BASE_URL"] || process.env["ANTHROPIC_BASE_URL"] || "https://token-plan-cn.xiaomimimo.com/anthropic";
635
+ this.client = new import_sdk.default({ apiKey, baseURL });
536
636
  this.modelName = modelName;
537
637
  }
538
638
  async generate(prompt, systemInstruction) {
539
639
  return withReliability(
540
640
  async () => {
541
- const body = {
641
+ const stream = this.client.messages.stream({
542
642
  model: this.modelName,
543
- max_tokens: 16384,
544
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
545
- top_p: 0.95,
546
- stream: false,
547
- temperature: 1,
548
- stop_sequences: null
549
- };
550
- if (systemInstruction) {
551
- body.system = systemInstruction;
552
- }
553
- const response = await import_axios.default.post(this.baseUrl, body, {
554
- headers: {
555
- "api-key": this.apiKey,
556
- "Content-Type": "application/json"
557
- }
643
+ max_tokens: 65536,
644
+ ...systemInstruction ? { system: systemInstruction } : {},
645
+ messages: [{ role: "user", content: prompt }]
558
646
  });
559
- const data = response.data;
560
- const blocks = data?.content ?? [];
561
- const textBlock = blocks.find((b) => b.type === "text");
562
- if (textBlock?.text) return textBlock.text;
563
- if (data?.stop_reason === "max_tokens") {
564
- 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.`);
565
- }
566
- throw new Error(`Unexpected MiMo response: ${JSON.stringify(response.data).slice(0, 200)}`);
647
+ const message = await stream.finalMessage();
648
+ const textBlock = message.content.find((b) => b.type === "text");
649
+ if (textBlock) return textBlock.text;
650
+ const thinkBlock = message.content.find((b) => b.type === "thinking");
651
+ if (thinkBlock) return thinkBlock.thinking;
652
+ return message.content.map((b) => b.text ?? "").join("");
567
653
  },
568
654
  { label: `${this.providerName}/${this.modelName}` }
569
655
  );
@@ -624,11 +710,68 @@ ${context.schema.slice(0, 3e3)}`);
624
710
  }
625
711
  });
626
712
 
713
+ // package.json
714
+ var require_package = __commonJS({
715
+ "package.json"(exports2, module2) {
716
+ module2.exports = {
717
+ name: "ai-spec-dev",
718
+ version: "0.46.0",
719
+ description: "AI-driven Development Orchestrator SDK & CLI",
720
+ main: "dist/index.js",
721
+ types: "dist/index.d.ts",
722
+ bin: {
723
+ "ai-spec": "dist/cli/index.js"
724
+ },
725
+ scripts: {
726
+ build: "tsup",
727
+ dev: "tsup --watch",
728
+ test: "vitest run",
729
+ "test:watch": "vitest"
730
+ },
731
+ keywords: [
732
+ "ai",
733
+ "spec",
734
+ "codegen",
735
+ "gemini",
736
+ "claude"
737
+ ],
738
+ author: "",
739
+ license: "MIT",
740
+ dependencies: {
741
+ "@anthropic-ai/sdk": "^0.38.0",
742
+ "@google/generative-ai": "^0.21.0",
743
+ "@inquirer/editor": "^5.0.10",
744
+ "@inquirer/prompts": "^8.3.2",
745
+ "@rollup/rollup-darwin-arm64": "^4.60.1",
746
+ axios: "^1.13.6",
747
+ chalk: "^4.1.2",
748
+ commander: "^13.1.0",
749
+ dotenv: "^16.4.7",
750
+ "fs-extra": "^11.3.0",
751
+ openai: "^6.31.0",
752
+ undici: "^7.24.4"
753
+ },
754
+ devDependencies: {
755
+ "@types/fs-extra": "^11.0.4",
756
+ "@types/glob": "^8.1.0",
757
+ "@types/node": "^22.13.5",
758
+ glob: "^13.0.6",
759
+ "ts-node": "^10.9.2",
760
+ tsup: "^8.4.0",
761
+ typescript: "^5.7.3",
762
+ vitest: "^2.1.0"
763
+ }
764
+ };
765
+ }
766
+ });
767
+
627
768
  // cli/index.ts
628
769
  var import_commander = require("commander");
629
770
  var dotenv = __toESM(require("dotenv"));
630
771
 
631
772
  // cli/commands/create.ts
773
+ var path26 = __toESM(require("path"));
774
+ var fs27 = __toESM(require("fs-extra"));
632
775
  var import_chalk26 = __toESM(require("chalk"));
633
776
  var import_prompts7 = require("@inquirer/prompts");
634
777
  init_spec_generator();
@@ -731,8 +874,8 @@ var WorkspaceLoader = class {
731
874
  const repos = [];
732
875
  for (const entry of entries) {
733
876
  const absPath = path.join(this.workspaceRoot, entry);
734
- const stat4 = await fs.stat(absPath).catch(() => null);
735
- if (!stat4 || !stat4.isDirectory()) continue;
877
+ const stat5 = await fs.stat(absPath).catch(() => null);
878
+ if (!stat5 || !stat5.isDirectory()) continue;
736
879
  if (entry.startsWith(".") || entry === "node_modules") continue;
737
880
  if (names && !names.includes(entry)) continue;
738
881
  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"));
@@ -795,6 +938,7 @@ var WorkspaceLoader = class {
795
938
  // cli/utils.ts
796
939
  var path3 = __toESM(require("path"));
797
940
  var fs3 = __toESM(require("fs-extra"));
941
+ var os2 = __toESM(require("os"));
798
942
  var import_chalk2 = __toESM(require("chalk"));
799
943
  var import_prompts = require("@inquirer/prompts");
800
944
  init_spec_generator();
@@ -841,17 +985,42 @@ async function clearKey(provider) {
841
985
 
842
986
  // cli/utils.ts
843
987
  var CONFIG_FILE = ".ai-spec.json";
988
+ var GLOBAL_CONFIG_FILE = path3.join(os2.homedir(), ".ai-spec-config.json");
989
+ async function loadGlobalConfig() {
990
+ try {
991
+ if (await fs3.pathExists(GLOBAL_CONFIG_FILE)) {
992
+ return await fs3.readJson(GLOBAL_CONFIG_FILE);
993
+ }
994
+ } catch {
995
+ }
996
+ return {};
997
+ }
998
+ async function saveGlobalConfig(config2) {
999
+ await fs3.ensureFile(GLOBAL_CONFIG_FILE);
1000
+ await fs3.writeJson(GLOBAL_CONFIG_FILE, config2, { spaces: 2 });
1001
+ }
844
1002
  async function loadConfig(dir) {
1003
+ const globalConfig = await loadGlobalConfig();
1004
+ let localConfig = {};
845
1005
  const p = path3.join(dir, CONFIG_FILE);
846
1006
  if (await fs3.pathExists(p)) {
847
- return fs3.readJson(p);
1007
+ try {
1008
+ localConfig = await fs3.readJson(p);
1009
+ } catch {
1010
+ }
848
1011
  }
849
- return {};
1012
+ return { ...globalConfig, ...localConfig };
850
1013
  }
851
1014
  async function resolveApiKey(providerName, cliKey) {
852
1015
  if (cliKey) return cliKey;
853
1016
  const envVar = ENV_KEY_MAP[providerName];
854
1017
  if (envVar && process.env[envVar]) return process.env[envVar];
1018
+ const meta = PROVIDER_CATALOG[providerName];
1019
+ if (meta?.fallbackEnvKeys) {
1020
+ for (const key of meta.fallbackEnvKeys) {
1021
+ if (process.env[key]) return process.env[key];
1022
+ }
1023
+ }
855
1024
  const savedKey = await getSavedKey(providerName);
856
1025
  if (savedKey) {
857
1026
  const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);
@@ -925,7 +1094,7 @@ var pe = "\0PERIOD" + Math.random() + "\0";
925
1094
  var is = new RegExp(fe, "g");
926
1095
  var rs = new RegExp(ue, "g");
927
1096
  var ns = new RegExp(qt, "g");
928
- var os2 = new RegExp(de, "g");
1097
+ var os3 = new RegExp(de, "g");
929
1098
  var hs = new RegExp(pe, "g");
930
1099
  var as = /\\\\/g;
931
1100
  var ls = /\\{/g;
@@ -940,7 +1109,7 @@ function ps(n7) {
940
1109
  return n7.replace(as, fe).replace(ls, ue).replace(cs, qt).replace(fs4, de).replace(us, pe);
941
1110
  }
942
1111
  function ms(n7) {
943
- return n7.replace(is, "\\").replace(rs, "{").replace(ns, "}").replace(os2, ",").replace(hs, ".");
1112
+ return n7.replace(is, "\\").replace(rs, "{").replace(ns, "}").replace(os3, ",").replace(hs, ".");
944
1113
  }
945
1114
  function me(n7) {
946
1115
  if (!n7) return [""];
@@ -3870,11 +4039,11 @@ Ze.glob = Ze;
3870
4039
  // core/global-constitution.ts
3871
4040
  var fs5 = __toESM(require("fs-extra"));
3872
4041
  var path4 = __toESM(require("path"));
3873
- var os3 = __toESM(require("os"));
4042
+ var os4 = __toESM(require("os"));
3874
4043
  var GLOBAL_CONSTITUTION_FILE = ".ai-spec-global-constitution.md";
3875
4044
  var SEARCH_ROOTS = [
3876
4045
  // Workspace root is injected at runtime — see loadGlobalConstitution()
3877
- os3.homedir()
4046
+ os4.homedir()
3878
4047
  ];
3879
4048
  async function loadGlobalConstitution(extraRoots = []) {
3880
4049
  const roots = [...extraRoots, ...SEARCH_ROOTS];
@@ -3903,7 +4072,7 @@ function mergeConstitutions(globalContent, projectContent) {
3903
4072
  }
3904
4073
  return parts.join("\n");
3905
4074
  }
3906
- async function saveGlobalConstitution(content, targetDir = os3.homedir()) {
4075
+ async function saveGlobalConstitution(content, targetDir = os4.homedir()) {
3907
4076
  const filePath = path4.join(targetDir, GLOBAL_CONSTITUTION_FILE);
3908
4077
  await fs5.writeFile(filePath, content, "utf-8");
3909
4078
  return filePath;
@@ -4936,32 +5105,32 @@ function validateDsl(raw) {
4936
5105
  }
4937
5106
  return { valid: true, dsl: raw };
4938
5107
  }
4939
- function validateFeature(raw, path42, errors) {
5108
+ function validateFeature(raw, path43, errors) {
4940
5109
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4941
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5110
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
4942
5111
  return;
4943
5112
  }
4944
5113
  const f = raw;
4945
- requireNonEmptyString(f["id"], `${path42}.id`, errors);
4946
- requireNonEmptyString(f["title"], `${path42}.title`, errors);
4947
- requireNonEmptyString(f["description"], `${path42}.description`, errors);
5114
+ requireNonEmptyString(f["id"], `${path43}.id`, errors);
5115
+ requireNonEmptyString(f["title"], `${path43}.title`, errors);
5116
+ requireNonEmptyString(f["description"], `${path43}.description`, errors);
4948
5117
  }
4949
- function validateModel(raw, path42, errors) {
5118
+ function validateModel(raw, path43, errors) {
4950
5119
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4951
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5120
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
4952
5121
  return;
4953
5122
  }
4954
5123
  const m = raw;
4955
- requireNonEmptyString(m["name"], `${path42}.name`, errors);
5124
+ requireNonEmptyString(m["name"], `${path43}.name`, errors);
4956
5125
  if (!Array.isArray(m["fields"])) {
4957
- errors.push({ path: `${path42}.fields`, message: `Must be an array, got: ${typeLabel(m["fields"])}` });
5126
+ errors.push({ path: `${path43}.fields`, message: `Must be an array, got: ${typeLabel(m["fields"])}` });
4958
5127
  } else {
4959
5128
  const fields = m["fields"];
4960
5129
  if (fields.length > MAX_FIELDS_PER_MODEL) {
4961
- errors.push({ path: `${path42}.fields`, message: `Too many fields (${fields.length} > ${MAX_FIELDS_PER_MODEL})` });
5130
+ errors.push({ path: `${path43}.fields`, message: `Too many fields (${fields.length} > ${MAX_FIELDS_PER_MODEL})` });
4962
5131
  }
4963
5132
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
4964
- validateModelField(fields[j2], `${path42}.fields[${j2}]`, errors);
5133
+ validateModelField(fields[j2], `${path43}.fields[${j2}]`, errors);
4965
5134
  }
4966
5135
  const seenFieldNames = /* @__PURE__ */ new Set();
4967
5136
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
@@ -4970,7 +5139,7 @@ function validateModel(raw, path42, errors) {
4970
5139
  const name = f["name"];
4971
5140
  if (seenFieldNames.has(name)) {
4972
5141
  errors.push({
4973
- path: `${path42}.fields[${j2}].name`,
5142
+ path: `${path43}.fields[${j2}].name`,
4974
5143
  message: `Duplicate field name "${name}" \u2014 each field within a model must have a unique name`
4975
5144
  });
4976
5145
  } else {
@@ -4981,184 +5150,184 @@ function validateModel(raw, path42, errors) {
4981
5150
  }
4982
5151
  if (m["relations"] !== void 0) {
4983
5152
  if (!Array.isArray(m["relations"])) {
4984
- errors.push({ path: `${path42}.relations`, message: "Must be an array of strings if present" });
5153
+ errors.push({ path: `${path43}.relations`, message: "Must be an array of strings if present" });
4985
5154
  } else {
4986
5155
  const rels = m["relations"];
4987
5156
  for (let j2 = 0; j2 < rels.length; j2++) {
4988
5157
  if (typeof rels[j2] !== "string") {
4989
- errors.push({ path: `${path42}.relations[${j2}]`, message: "Must be a string" });
5158
+ errors.push({ path: `${path43}.relations[${j2}]`, message: "Must be a string" });
4990
5159
  }
4991
5160
  }
4992
5161
  }
4993
5162
  }
4994
5163
  }
4995
- function validateModelField(raw, path42, errors) {
5164
+ function validateModelField(raw, path43, errors) {
4996
5165
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4997
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5166
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
4998
5167
  return;
4999
5168
  }
5000
5169
  const f = raw;
5001
- requireNonEmptyString(f["name"], `${path42}.name`, errors);
5002
- requireNonEmptyString(f["type"], `${path42}.type`, errors);
5170
+ requireNonEmptyString(f["name"], `${path43}.name`, errors);
5171
+ requireNonEmptyString(f["type"], `${path43}.type`, errors);
5003
5172
  if (typeof f["required"] !== "boolean") {
5004
- errors.push({ path: `${path42}.required`, message: `Must be boolean, got: ${typeLabel(f["required"])}` });
5173
+ errors.push({ path: `${path43}.required`, message: `Must be boolean, got: ${typeLabel(f["required"])}` });
5005
5174
  }
5006
5175
  }
5007
- function validateEndpoint(raw, path42, errors) {
5176
+ function validateEndpoint(raw, path43, errors) {
5008
5177
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5009
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5178
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5010
5179
  return;
5011
5180
  }
5012
5181
  const e = raw;
5013
- requireNonEmptyString(e["id"], `${path42}.id`, errors);
5014
- requireNonEmptyString(e["description"], `${path42}.description`, errors);
5182
+ requireNonEmptyString(e["id"], `${path43}.id`, errors);
5183
+ requireNonEmptyString(e["description"], `${path43}.description`, errors);
5015
5184
  if (!VALID_METHODS.includes(e["method"])) {
5016
5185
  errors.push({
5017
- path: `${path42}.method`,
5186
+ path: `${path43}.method`,
5018
5187
  message: `Must be one of ${VALID_METHODS.join("|")}, got: ${JSON.stringify(e["method"])}`
5019
5188
  });
5020
5189
  }
5021
5190
  if (typeof e["path"] !== "string" || !e["path"].startsWith("/")) {
5022
5191
  errors.push({
5023
- path: `${path42}.path`,
5192
+ path: `${path43}.path`,
5024
5193
  message: `Must be a string starting with "/", got: ${JSON.stringify(e["path"])}`
5025
5194
  });
5026
5195
  }
5027
5196
  if (typeof e["auth"] !== "boolean") {
5028
- errors.push({ path: `${path42}.auth`, message: `Must be boolean, got: ${typeLabel(e["auth"])}` });
5197
+ errors.push({ path: `${path43}.auth`, message: `Must be boolean, got: ${typeLabel(e["auth"])}` });
5029
5198
  }
5030
5199
  if (typeof e["successStatus"] !== "number" || e["successStatus"] < 100 || e["successStatus"] > 599) {
5031
5200
  errors.push({
5032
- path: `${path42}.successStatus`,
5201
+ path: `${path43}.successStatus`,
5033
5202
  message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["successStatus"])}`
5034
5203
  });
5035
5204
  }
5036
- requireNonEmptyString(e["successDescription"], `${path42}.successDescription`, errors);
5205
+ requireNonEmptyString(e["successDescription"], `${path43}.successDescription`, errors);
5037
5206
  if (e["request"] !== void 0) {
5038
- validateRequestSchema(e["request"], `${path42}.request`, errors);
5207
+ validateRequestSchema(e["request"], `${path43}.request`, errors);
5039
5208
  }
5040
5209
  if (e["errors"] !== void 0) {
5041
5210
  if (!Array.isArray(e["errors"])) {
5042
- errors.push({ path: `${path42}.errors`, message: "Must be an array if present" });
5211
+ errors.push({ path: `${path43}.errors`, message: "Must be an array if present" });
5043
5212
  } else {
5044
5213
  const errs = e["errors"];
5045
5214
  if (errs.length > MAX_ERRORS_PER_ENDPOINT) {
5046
- errors.push({ path: `${path42}.errors`, message: `Too many error entries (${errs.length} > ${MAX_ERRORS_PER_ENDPOINT})` });
5215
+ errors.push({ path: `${path43}.errors`, message: `Too many error entries (${errs.length} > ${MAX_ERRORS_PER_ENDPOINT})` });
5047
5216
  }
5048
5217
  for (let j2 = 0; j2 < Math.min(errs.length, MAX_ERRORS_PER_ENDPOINT); j2++) {
5049
- validateResponseError(errs[j2], `${path42}.errors[${j2}]`, errors);
5218
+ validateResponseError(errs[j2], `${path43}.errors[${j2}]`, errors);
5050
5219
  }
5051
5220
  }
5052
5221
  }
5053
5222
  }
5054
- function validateRequestSchema(raw, path42, errors) {
5223
+ function validateRequestSchema(raw, path43, errors) {
5055
5224
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5056
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5225
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5057
5226
  return;
5058
5227
  }
5059
5228
  const r = raw;
5060
5229
  for (const key of ["body", "query", "params"]) {
5061
5230
  if (r[key] !== void 0) {
5062
- validateFieldMap(r[key], `${path42}.${key}`, errors);
5231
+ validateFieldMap(r[key], `${path43}.${key}`, errors);
5063
5232
  }
5064
5233
  }
5065
5234
  }
5066
- function validateFieldMap(raw, path42, errors) {
5235
+ function validateFieldMap(raw, path43, errors) {
5067
5236
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5068
- errors.push({ path: path42, message: `Must be a flat object (FieldMap), got: ${typeLabel(raw)}` });
5237
+ errors.push({ path: path43, message: `Must be a flat object (FieldMap), got: ${typeLabel(raw)}` });
5069
5238
  return;
5070
5239
  }
5071
5240
  const map = raw;
5072
5241
  for (const [k2, v2] of Object.entries(map)) {
5073
5242
  if (typeof v2 !== "string") {
5074
- errors.push({ path: `${path42}.${k2}`, message: `Value must be a type-description string, got: ${typeLabel(v2)}` });
5243
+ errors.push({ path: `${path43}.${k2}`, message: `Value must be a type-description string, got: ${typeLabel(v2)}` });
5075
5244
  }
5076
5245
  }
5077
5246
  }
5078
- function validateResponseError(raw, path42, errors) {
5247
+ function validateResponseError(raw, path43, errors) {
5079
5248
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5080
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5249
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5081
5250
  return;
5082
5251
  }
5083
5252
  const e = raw;
5084
5253
  if (typeof e["status"] !== "number" || e["status"] < 100 || e["status"] > 599) {
5085
- errors.push({ path: `${path42}.status`, message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["status"])}` });
5254
+ errors.push({ path: `${path43}.status`, message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["status"])}` });
5086
5255
  }
5087
- requireNonEmptyString(e["code"], `${path42}.code`, errors);
5088
- requireNonEmptyString(e["description"], `${path42}.description`, errors);
5256
+ requireNonEmptyString(e["code"], `${path43}.code`, errors);
5257
+ requireNonEmptyString(e["description"], `${path43}.description`, errors);
5089
5258
  }
5090
- function validateBehavior(raw, path42, errors) {
5259
+ function validateBehavior(raw, path43, errors) {
5091
5260
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5092
- 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)}` });
5093
5262
  return;
5094
5263
  }
5095
5264
  const b = raw;
5096
- requireNonEmptyString(b["id"], `${path42}.id`, errors);
5097
- requireNonEmptyString(b["description"], `${path42}.description`, errors);
5265
+ requireNonEmptyString(b["id"], `${path43}.id`, errors);
5266
+ requireNonEmptyString(b["description"], `${path43}.description`, errors);
5098
5267
  if (b["constraints"] !== void 0) {
5099
5268
  if (!Array.isArray(b["constraints"])) {
5100
- errors.push({ path: `${path42}.constraints`, message: "Must be an array of strings if present" });
5269
+ errors.push({ path: `${path43}.constraints`, message: "Must be an array of strings if present" });
5101
5270
  } else {
5102
5271
  const cs2 = b["constraints"];
5103
5272
  for (let j2 = 0; j2 < cs2.length; j2++) {
5104
5273
  if (typeof cs2[j2] !== "string") {
5105
- errors.push({ path: `${path42}.constraints[${j2}]`, message: "Must be a string" });
5274
+ errors.push({ path: `${path43}.constraints[${j2}]`, message: "Must be a string" });
5106
5275
  }
5107
5276
  }
5108
5277
  }
5109
5278
  }
5110
5279
  }
5111
- function validateComponent(raw, path42, errors) {
5280
+ function validateComponent(raw, path43, errors) {
5112
5281
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5113
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5282
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5114
5283
  return;
5115
5284
  }
5116
5285
  const c = raw;
5117
- requireNonEmptyString(c["id"], `${path42}.id`, errors);
5118
- requireNonEmptyString(c["name"], `${path42}.name`, errors);
5119
- requireNonEmptyString(c["description"], `${path42}.description`, errors);
5286
+ requireNonEmptyString(c["id"], `${path43}.id`, errors);
5287
+ requireNonEmptyString(c["name"], `${path43}.name`, errors);
5288
+ requireNonEmptyString(c["description"], `${path43}.description`, errors);
5120
5289
  if (c["props"] !== void 0) {
5121
5290
  if (!Array.isArray(c["props"])) {
5122
- errors.push({ path: `${path42}.props`, message: "Must be an array if present" });
5291
+ errors.push({ path: `${path43}.props`, message: "Must be an array if present" });
5123
5292
  } else {
5124
5293
  const props = c["props"];
5125
5294
  for (let j2 = 0; j2 < props.length; j2++) {
5126
5295
  const p = props[j2];
5127
5296
  if (typeof p !== "object" || p === null) {
5128
- errors.push({ path: `${path42}.props[${j2}]`, message: "Must be an object" });
5297
+ errors.push({ path: `${path43}.props[${j2}]`, message: "Must be an object" });
5129
5298
  continue;
5130
5299
  }
5131
- requireNonEmptyString(p["name"], `${path42}.props[${j2}].name`, errors);
5132
- requireNonEmptyString(p["type"], `${path42}.props[${j2}].type`, errors);
5300
+ requireNonEmptyString(p["name"], `${path43}.props[${j2}].name`, errors);
5301
+ requireNonEmptyString(p["type"], `${path43}.props[${j2}].type`, errors);
5133
5302
  if (typeof p["required"] !== "boolean") {
5134
- errors.push({ path: `${path42}.props[${j2}].required`, message: "Must be boolean" });
5303
+ errors.push({ path: `${path43}.props[${j2}].required`, message: "Must be boolean" });
5135
5304
  }
5136
5305
  }
5137
5306
  }
5138
5307
  }
5139
5308
  if (c["events"] !== void 0) {
5140
5309
  if (!Array.isArray(c["events"])) {
5141
- errors.push({ path: `${path42}.events`, message: "Must be an array if present" });
5310
+ errors.push({ path: `${path43}.events`, message: "Must be an array if present" });
5142
5311
  } else {
5143
5312
  const events = c["events"];
5144
5313
  for (let j2 = 0; j2 < events.length; j2++) {
5145
5314
  const e = events[j2];
5146
5315
  if (typeof e !== "object" || e === null) {
5147
- errors.push({ path: `${path42}.events[${j2}]`, message: "Must be an object" });
5316
+ errors.push({ path: `${path43}.events[${j2}]`, message: "Must be an object" });
5148
5317
  continue;
5149
5318
  }
5150
- requireNonEmptyString(e["name"], `${path42}.events[${j2}].name`, errors);
5319
+ requireNonEmptyString(e["name"], `${path43}.events[${j2}].name`, errors);
5151
5320
  }
5152
5321
  }
5153
5322
  }
5154
5323
  if (c["state"] !== void 0) {
5155
5324
  if (typeof c["state"] !== "object" || Array.isArray(c["state"]) || c["state"] === null) {
5156
- errors.push({ path: `${path42}.state`, message: "Must be a flat object (Record<string, string>) if present" });
5325
+ errors.push({ path: `${path43}.state`, message: "Must be a flat object (Record<string, string>) if present" });
5157
5326
  }
5158
5327
  }
5159
5328
  if (c["apiCalls"] !== void 0) {
5160
5329
  if (!Array.isArray(c["apiCalls"])) {
5161
- errors.push({ path: `${path42}.apiCalls`, message: "Must be an array of strings if present" });
5330
+ errors.push({ path: `${path43}.apiCalls`, message: "Must be an array of strings if present" });
5162
5331
  }
5163
5332
  }
5164
5333
  }
@@ -5220,10 +5389,10 @@ function crossReferenceChecks(obj, errors) {
5220
5389
  }
5221
5390
  }
5222
5391
  }
5223
- function requireNonEmptyString(v2, path42, errors) {
5392
+ function requireNonEmptyString(v2, path43, errors) {
5224
5393
  if (typeof v2 !== "string" || v2.trim().length === 0) {
5225
5394
  errors.push({
5226
- path: path42,
5395
+ path: path43,
5227
5396
  message: `Must be a non-empty string, got: ${typeLabel(v2)}`
5228
5397
  });
5229
5398
  }
@@ -5434,13 +5603,18 @@ Output ONLY the corrected JSON. No explanation.`;
5434
5603
 
5435
5604
  // core/token-budget.ts
5436
5605
  var import_chalk5 = __toESM(require("chalk"));
5437
- var CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g;
5438
- function estimateTokens(text) {
5439
- if (!text) return 0;
5440
- const cjkCount = (text.match(CJK_RANGE) ?? []).length;
5441
- const nonCjkLength = text.length - cjkCount;
5442
- return Math.ceil(cjkCount + nonCjkLength / 4);
5443
- }
5606
+
5607
+ // core/config-defaults.ts
5608
+ var DEFAULT_LOG_DIR = ".ai-spec-logs";
5609
+ var DEFAULT_VCR_DIR = ".ai-spec-vcr";
5610
+ var DEFAULT_BACKUP_DIR = ".ai-spec-backup";
5611
+ var DEFAULT_REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
5612
+ var DEFAULT_OPENAPI_SERVER_URL = "http://localhost:3000";
5613
+ var DEFAULT_MAX_COMMAND_OUTPUT_CHARS = 3e4;
5614
+ var DEFAULT_MAX_FIX_FILE_CHARS = 6e4;
5615
+ var DEFAULT_DSL_MAX_RETRIES = 2;
5616
+ var DEFAULT_MAX_CONSTITUTION_CHARS = 4e3;
5617
+ var DEFAULT_MAX_REVIEW_FILE_CHARS = 3e3;
5444
5618
  var DEFAULT_TOKEN_BUDGETS = {
5445
5619
  gemini: 9e5,
5446
5620
  claude: 18e4,
@@ -5448,8 +5622,18 @@ var DEFAULT_TOKEN_BUDGETS = {
5448
5622
  deepseek: 6e4,
5449
5623
  default: 1e5
5450
5624
  };
5625
+
5626
+ // core/token-budget.ts
5627
+ var CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g;
5628
+ function estimateTokens(text) {
5629
+ if (!text) return 0;
5630
+ const cjkCount = (text.match(CJK_RANGE) ?? []).length;
5631
+ const nonCjkLength = text.length - cjkCount;
5632
+ return Math.ceil(cjkCount + nonCjkLength / 4);
5633
+ }
5634
+ var DEFAULT_TOKEN_BUDGETS2 = DEFAULT_TOKEN_BUDGETS;
5451
5635
  function getDefaultBudget(providerName) {
5452
- return DEFAULT_TOKEN_BUDGETS[providerName] ?? DEFAULT_TOKEN_BUDGETS.default;
5636
+ return DEFAULT_TOKEN_BUDGETS2[providerName] ?? DEFAULT_TOKEN_BUDGETS2.default;
5453
5637
  }
5454
5638
 
5455
5639
  // core/safe-json.ts
@@ -5520,7 +5704,7 @@ function sanitizeDsl(raw) {
5520
5704
  }
5521
5705
  return dsl;
5522
5706
  }
5523
- var MAX_RETRIES = 2;
5707
+ var MAX_RETRIES = DEFAULT_DSL_MAX_RETRIES;
5524
5708
  var DEFAULT_MAX_SPEC_CHARS = 12e3;
5525
5709
  function dslFilePath(specFilePath) {
5526
5710
  const dir = path7.dirname(specFilePath);
@@ -6244,12 +6428,11 @@ Shared component structure patterns:`);
6244
6428
  // core/run-snapshot.ts
6245
6429
  var fs10 = __toESM(require("fs-extra"));
6246
6430
  var path9 = __toESM(require("path"));
6247
- var BACKUP_DIR = ".ai-spec-backup";
6248
6431
  var RunSnapshot = class {
6249
6432
  constructor(workingDir, runId) {
6250
6433
  this.workingDir = workingDir;
6251
6434
  this.runId = runId;
6252
- this.backupRoot = path9.join(workingDir, BACKUP_DIR, runId);
6435
+ this.backupRoot = path9.join(workingDir, DEFAULT_BACKUP_DIR, runId);
6253
6436
  }
6254
6437
  backupRoot;
6255
6438
  snapshotted = /* @__PURE__ */ new Set();
@@ -6305,7 +6488,6 @@ function getActiveSnapshot() {
6305
6488
  var fs11 = __toESM(require("fs-extra"));
6306
6489
  var path10 = __toESM(require("path"));
6307
6490
  var import_chalk7 = __toESM(require("chalk"));
6308
- var LOG_DIR = ".ai-spec-logs";
6309
6491
  function appendJsonlLine(filePath, record) {
6310
6492
  try {
6311
6493
  fs11.appendFileSync(filePath, JSON.stringify(record) + "\n");
@@ -6376,8 +6558,8 @@ var RunLogger = class {
6376
6558
  this.workingDir = workingDir;
6377
6559
  this.runId = runId;
6378
6560
  this.startMs = Date.now();
6379
- this.logPath = path10.join(workingDir, LOG_DIR, `${runId}.json`);
6380
- this.jsonlPath = path10.join(workingDir, LOG_DIR, `${runId}.jsonl`);
6561
+ this.logPath = path10.join(workingDir, DEFAULT_LOG_DIR, `${runId}.json`);
6562
+ this.jsonlPath = path10.join(workingDir, DEFAULT_LOG_DIR, `${runId}.jsonl`);
6381
6563
  this.log = {
6382
6564
  runId,
6383
6565
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6711,6 +6893,7 @@ function printTaskProgress(completed, total, task, mode) {
6711
6893
  }
6712
6894
 
6713
6895
  // core/code-generator.ts
6896
+ init_cli_ui();
6714
6897
  var CodeGenerator = class {
6715
6898
  constructor(provider, mode = "claude-code") {
6716
6899
  this.provider = provider;
@@ -7159,6 +7342,7 @@ ${spec}
7159
7342
  ${constitutionSection}
7160
7343
  === ${existingContent ? "Existing content (modify and return the complete file)" : "Create this file from scratch"} ===
7161
7344
  ${existingContent || "Output only the complete file content."}`;
7345
+ const fileSpinner = startSpinner(`${prefix}Generating ${import_chalk9.default.bold(item.file)}...`);
7162
7346
  try {
7163
7347
  const raw = await this.provider.generate(codePrompt, systemPrompt);
7164
7348
  const fileContent = stripCodeFences(raw);
@@ -7166,11 +7350,11 @@ ${existingContent || "Output only the complete file content."}`;
7166
7350
  await fs12.ensureDir(path11.dirname(fullPath));
7167
7351
  await fs12.writeFile(fullPath, fileContent, "utf-8");
7168
7352
  getActiveLogger()?.fileWritten(item.file);
7169
- console.log(`${prefix}${existingContent ? import_chalk9.default.yellow("~") : import_chalk9.default.green("+")} ${import_chalk9.default.bold(item.file)} ${import_chalk9.default.green("\u2714")}`);
7353
+ fileSpinner.succeed(`${existingContent ? import_chalk9.default.yellow("~") : import_chalk9.default.green("+")} ${import_chalk9.default.bold(item.file)}`);
7170
7354
  successCount++;
7171
7355
  writtenFiles.push(item.file);
7172
7356
  } catch (err) {
7173
- console.log(`${prefix}${import_chalk9.default.red("\u2718")} ${import_chalk9.default.bold(item.file)} \u2014 ${import_chalk9.default.red(err.message)}`);
7357
+ fileSpinner.fail(`${import_chalk9.default.bold(item.file)} \u2014 ${err.message}`);
7174
7358
  }
7175
7359
  }
7176
7360
  if (!taskLabel) {
@@ -7317,7 +7501,7 @@ ${context.routeSummary}
7317
7501
  }
7318
7502
  if (context.schema) {
7319
7503
  parts.push(`=== Prisma Schema ===
7320
- ${context.schema.slice(0, 4e3)}
7504
+ ${context.schema.slice(0, DEFAULT_MAX_CONSTITUTION_CHARS)}
7321
7505
  `);
7322
7506
  }
7323
7507
  if (context.errorPatterns) {
@@ -7365,7 +7549,7 @@ async function loadAccumulatedLessons(projectRoot) {
7365
7549
  const nextSection = section.slice(marker.length).match(/\n## \d/);
7366
7550
  return nextSection ? section.slice(0, marker.length + nextSection.index) : section;
7367
7551
  }
7368
- var REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
7552
+ var REVIEW_HISTORY_FILE = DEFAULT_REVIEW_HISTORY_FILE;
7369
7553
  async function loadReviewHistory(projectRoot) {
7370
7554
  const historyPath = path13.join(projectRoot, REVIEW_HISTORY_FILE);
7371
7555
  try {
@@ -7496,15 +7680,13 @@ ${specContent || "(No spec \u2014 review for general code quality)"}
7496
7680
  ${codeContext}`;
7497
7681
  const archReview = await this.provider.generate(archPrompt, reviewArchitectureSystemPrompt);
7498
7682
  console.log(import_chalk11.default.gray(" Pass 2/3: Implementation review..."));
7683
+ const specDigest = specContent && specContent.length > 600 ? specContent.slice(0, 600) + "\n... [spec truncated \u2014 see Pass 0/1 for full text]" : specContent || "(No spec)";
7499
7684
  const history = await loadReviewHistory(this.projectRoot);
7500
7685
  const historyContext = buildHistoryContext(history);
7501
7686
  const implPrompt = `Review the implementation details of this change.
7502
7687
 
7503
- === Feature Spec ===
7504
- ${specContent || "(No spec \u2014 review for general code quality)"}
7505
-
7506
- === Code ===
7507
- ${codeContext}
7688
+ === Feature Spec (digest \u2014 full spec was provided in Pass 0/1) ===
7689
+ ${specDigest}
7508
7690
 
7509
7691
  === Architecture Review (Pass 1 \u2014 do NOT repeat these findings) ===
7510
7692
  ${archReview}
@@ -7513,11 +7695,8 @@ ${historyContext}`;
7513
7695
  console.log(import_chalk11.default.gray(" Pass 3/3: Impact & complexity assessment..."));
7514
7696
  const impactPrompt = `Assess the impact and complexity of this change.
7515
7697
 
7516
- === Feature Spec ===
7517
- ${specContent || "(No spec \u2014 review for general code quality)"}
7518
-
7519
- === Code ===
7520
- ${codeContext}
7698
+ === Feature Spec (digest) ===
7699
+ ${specDigest}
7521
7700
 
7522
7701
  === Architecture Review (Pass 1 \u2014 do NOT repeat) ===
7523
7702
  ${archReview}
@@ -7591,8 +7770,8 @@ ${sep}
7591
7770
  filesSection += `
7592
7771
 
7593
7772
  === ${filePath} ===
7594
- ${content.slice(0, 3e3)}`;
7595
- if (content.length > 3e3) filesSection += `
7773
+ ${content.slice(0, DEFAULT_MAX_REVIEW_FILE_CHARS)}`;
7774
+ if (content.length > DEFAULT_MAX_REVIEW_FILE_CHARS) filesSection += `
7596
7775
  ... (truncated, ${content.length} chars total)`;
7597
7776
  } catch {
7598
7777
  filesSection += `
@@ -8196,8 +8375,9 @@ var import_chalk15 = __toESM(require("chalk"));
8196
8375
  var import_child_process5 = require("child_process");
8197
8376
  var fs18 = __toESM(require("fs-extra"));
8198
8377
  var path17 = __toESM(require("path"));
8199
- var MAX_COMMAND_OUTPUT_CHARS = 5e4;
8200
- var MAX_FIX_FILE_CHARS = 6e4;
8378
+ init_cli_ui();
8379
+ var MAX_COMMAND_OUTPUT_CHARS = DEFAULT_MAX_COMMAND_OUTPUT_CHARS;
8380
+ var MAX_FIX_FILE_CHARS = DEFAULT_MAX_FIX_FILE_CHARS;
8201
8381
  function runCommand(cmd, cwd) {
8202
8382
  try {
8203
8383
  const output = (0, import_child_process5.execSync)(cmd, { cwd, encoding: "utf-8", timeout: 6e4 });
@@ -8387,16 +8567,17 @@ ${errorSummary}
8387
8567
  ${fileContent}
8388
8568
 
8389
8569
  Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
8570
+ const fixSpinner = startSpinner(`Fixing ${import_chalk15.default.bold(file)} (${fileErrors.length} error(s))...`);
8390
8571
  try {
8391
8572
  const raw = await provider.generate(prompt, getCodeGenSystemPrompt());
8392
8573
  const fixed = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
8393
8574
  await getActiveSnapshot()?.snapshotFile(fullPath);
8394
8575
  await fs18.writeFile(fullPath, fixed, "utf-8");
8395
8576
  results.push({ fixed: true, file, explanation: `Fixed ${fileErrors.length} error(s)` });
8396
- console.log(import_chalk15.default.green(` \u2714 Auto-fixed: ${file}`));
8577
+ fixSpinner.succeed(`Auto-fixed: ${file}`);
8397
8578
  } catch (err) {
8398
8579
  results.push({ fixed: false, file, explanation: `AI fix failed: ${err.message}` });
8399
- console.log(import_chalk15.default.yellow(` \u26A0 Could not auto-fix: ${file}`));
8580
+ fixSpinner.fail(`Could not auto-fix: ${file}`);
8400
8581
  }
8401
8582
  }
8402
8583
  return results;
@@ -9405,8 +9586,8 @@ async function findLatestDslFile(projectDir) {
9405
9586
  const entries = await fs22.readdir(dir);
9406
9587
  for (const entry of entries) {
9407
9588
  const abs = path21.join(dir, entry);
9408
- const stat4 = await fs22.stat(abs);
9409
- if (stat4.isDirectory()) {
9589
+ const stat5 = await fs22.stat(abs);
9590
+ if (stat5.isDirectory()) {
9410
9591
  await scan(abs);
9411
9592
  } else if (entry.endsWith(".dsl.json")) {
9412
9593
  allFiles.push(abs);
@@ -11144,7 +11325,7 @@ ${optionsText}`;
11144
11325
  var import_crypto2 = require("crypto");
11145
11326
  var fs24 = __toESM(require("fs-extra"));
11146
11327
  var path23 = __toESM(require("path"));
11147
- var VCR_DIR = ".ai-spec-vcr";
11328
+ var VCR_DIR = DEFAULT_VCR_DIR;
11148
11329
  var VcrRecordingProvider = class {
11149
11330
  constructor(inner) {
11150
11331
  this.inner = inner;
@@ -11272,6 +11453,7 @@ async function listVcrRecordings(workingDir) {
11272
11453
  }
11273
11454
 
11274
11455
  // cli/pipeline/single-repo.ts
11456
+ init_cli_ui();
11275
11457
  async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11276
11458
  const specProviderName = opts.provider || config2.provider || "gemini";
11277
11459
  const specModelName = opts.model || config2.model || DEFAULT_MODELS[specProviderName];
@@ -11334,7 +11516,7 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11334
11516
  console.log(import_chalk25.default.yellow(` \u26A0 Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
11335
11517
  }
11336
11518
  } else {
11337
- console.log(import_chalk25.default.yellow(" Constitution : not found \u2014 auto-generating..."));
11519
+ const constitutionSpinner = startSpinner("Constitution not found \u2014 auto-generating...");
11338
11520
  try {
11339
11521
  const constitutionGen = new ConstitutionGenerator(
11340
11522
  createProvider(specProviderName, specApiKey, specModelName)
@@ -11342,9 +11524,9 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11342
11524
  const constitutionContent = await constitutionGen.generate(currentDir);
11343
11525
  await constitutionGen.saveConstitution(currentDir, constitutionContent);
11344
11526
  context.constitution = constitutionContent;
11345
- console.log(import_chalk25.default.green(` Constitution : \u2714 generated and saved (.ai-spec-constitution.md)`));
11527
+ constitutionSpinner.succeed("Constitution generated and saved (.ai-spec-constitution.md)");
11346
11528
  } catch (err) {
11347
- console.log(import_chalk25.default.yellow(` Constitution : \u26A0 auto-generation failed (${err.message}), continuing without it.`));
11529
+ constitutionSpinner.fail(`Constitution auto-generation failed (${err.message}), continuing without it.`);
11348
11530
  }
11349
11531
  }
11350
11532
  let architectureDecision;
@@ -11375,17 +11557,18 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11375
11557
  let initialSpec;
11376
11558
  let initialTasks = [];
11377
11559
  runLogger.stageStart("spec_gen", { provider: specProviderName, model: specModelName });
11560
+ const specSpinner = startStage("spec_gen", `Generating spec with ${specProviderName}/${specModelName}...`);
11378
11561
  try {
11379
11562
  if (opts.skipTasks) {
11380
11563
  const { SpecGenerator: SpecGenerator2 } = await Promise.resolve().then(() => (init_spec_generator(), spec_generator_exports));
11381
11564
  const generator = new SpecGenerator2(specProvider);
11382
11565
  initialSpec = await generator.generateSpec(idea, context, architectureDecision);
11383
- console.log(import_chalk25.default.green(" \u2714 Spec generated."));
11566
+ specSpinner.succeed("Spec generated.");
11384
11567
  } else {
11385
11568
  const result = await generateSpecWithTasks(specProvider, idea, context, architectureDecision);
11386
11569
  initialSpec = result.spec;
11387
11570
  initialTasks = result.tasks;
11388
- console.log(import_chalk25.default.green(` \u2714 Spec generated.`));
11571
+ specSpinner.succeed("Spec generated.");
11389
11572
  if (initialTasks.length > 0) {
11390
11573
  console.log(import_chalk25.default.green(` \u2714 ${initialTasks.length} tasks generated (combined call).`));
11391
11574
  } else {
@@ -11394,8 +11577,8 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11394
11577
  }
11395
11578
  runLogger.stageEnd("spec_gen", { taskCount: initialTasks.length });
11396
11579
  } catch (err) {
11580
+ specSpinner.fail(`Spec generation failed: ${err.message}`);
11397
11581
  runLogger.stageFail("spec_gen", err.message);
11398
- console.error(import_chalk25.default.red(" \u2718 Spec generation failed:"), err);
11399
11582
  process.exit(1);
11400
11583
  }
11401
11584
  let finalSpec;
@@ -11417,7 +11600,9 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11417
11600
  console.log(import_chalk25.default.blue("\n[3.4/6] Spec quality assessment..."));
11418
11601
  }
11419
11602
  runLogger.stageStart("spec_assess");
11603
+ const assessSpinner = startStage("spec_assess", "Evaluating spec quality...");
11420
11604
  const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? void 0);
11605
+ assessSpinner.stop();
11421
11606
  if (assessment) {
11422
11607
  runLogger.stageEnd("spec_assess", { overallScore: assessment.overallScore });
11423
11608
  if (!opts.auto) printSpecAssessment(assessment);
@@ -11509,21 +11694,24 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11509
11694
  console.log(import_chalk25.default.blue("\n[DSL] Extracting structured DSL from spec..."));
11510
11695
  console.log(import_chalk25.default.gray(` Provider: ${specProviderName}/${specModelName}`));
11511
11696
  runLogger.stageStart("dsl_extract");
11697
+ const dslSpinner = startStage("dsl_extract", "Extracting DSL from spec...");
11512
11698
  try {
11513
11699
  const isFrontend = isFrontendDeps(context.dependencies);
11514
- if (isFrontend) console.log(import_chalk25.default.gray(" Frontend project detected \u2014 using ComponentSpec extractor"));
11700
+ if (isFrontend) {
11701
+ dslSpinner.update("\u{1F517} Extracting DSL (frontend ComponentSpec mode)...");
11702
+ }
11515
11703
  const dslExtractor = new DslExtractor(specProvider);
11516
11704
  extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
11517
11705
  if (extractedDsl) {
11518
11706
  runLogger.stageEnd("dsl_extract", { endpoints: extractedDsl.endpoints?.length ?? 0, models: extractedDsl.models?.length ?? 0 });
11519
- console.log(import_chalk25.default.green(" \u2714 DSL extracted and validated."));
11707
+ dslSpinner.succeed("DSL extracted and validated.");
11520
11708
  } else {
11521
11709
  runLogger.stageEnd("dsl_extract", { skipped: true });
11522
- console.log(import_chalk25.default.yellow(" \u26A0 DSL skipped \u2014 codegen will use Spec + Tasks only."));
11710
+ dslSpinner.fail("DSL skipped \u2014 codegen will use Spec + Tasks only.");
11523
11711
  }
11524
11712
  } catch (err) {
11525
11713
  runLogger.stageFail("dsl_extract", err.message);
11526
- console.log(import_chalk25.default.yellow(` \u26A0 DSL extraction error: ${err.message} \u2014 continuing without DSL.`));
11714
+ dslSpinner.fail(`DSL extraction error: ${err.message} \u2014 continuing without DSL.`);
11527
11715
  }
11528
11716
  }
11529
11717
  if (extractedDsl && !opts.auto && !opts.fast && !opts.skipDsl) {
@@ -11698,6 +11886,7 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11698
11886
  if (!opts.skipReview) {
11699
11887
  console.log(import_chalk25.default.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
11700
11888
  runLogger.stageStart("review");
11889
+ const reviewSpinner = startStage("review", "Running 3-pass code review...");
11701
11890
  const reviewer = new CodeReviewer(specProvider, workingDir);
11702
11891
  const savedSpec = await fs25.readFile(specFile, "utf-8");
11703
11892
  if (codegenMode === "api" && generatedFiles.length > 0) {
@@ -11705,6 +11894,7 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11705
11894
  } else {
11706
11895
  reviewResult = await reviewer.reviewCode(savedSpec, specFile);
11707
11896
  }
11897
+ reviewSpinner.succeed("Code review complete.");
11708
11898
  runLogger.stageEnd("review");
11709
11899
  const complianceScore = extractComplianceScore(reviewResult);
11710
11900
  const missingCount = extractMissingCount(reviewResult);
@@ -11833,7 +12023,147 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11833
12023
  }
11834
12024
  }
11835
12025
 
12026
+ // core/repo-store.ts
12027
+ var fs26 = __toESM(require("fs-extra"));
12028
+ var path25 = __toESM(require("path"));
12029
+ var os5 = __toESM(require("os"));
12030
+ var REPO_STORE_FILE = path25.join(os5.homedir(), ".ai-spec-repos.json");
12031
+ async function readStore2() {
12032
+ try {
12033
+ if (await fs26.pathExists(REPO_STORE_FILE)) {
12034
+ return await fs26.readJson(REPO_STORE_FILE);
12035
+ }
12036
+ } catch (err) {
12037
+ console.warn(`Warning: Could not read repo store at ${REPO_STORE_FILE}: ${err.message}.`);
12038
+ }
12039
+ return { repos: [] };
12040
+ }
12041
+ async function writeStore2(store) {
12042
+ await fs26.ensureFile(REPO_STORE_FILE);
12043
+ await fs26.writeJson(REPO_STORE_FILE, store, { spaces: 2 });
12044
+ }
12045
+ async function getRegisteredRepos() {
12046
+ const store = await readStore2();
12047
+ return store.repos;
12048
+ }
12049
+ async function registerRepo(repo) {
12050
+ const store = await readStore2();
12051
+ const idx = store.repos.findIndex((r) => r.path === repo.path);
12052
+ if (idx >= 0) {
12053
+ store.repos[idx] = repo;
12054
+ } else {
12055
+ store.repos.push(repo);
12056
+ }
12057
+ await writeStore2(store);
12058
+ }
12059
+
11836
12060
  // cli/commands/create.ts
12061
+ init_spec_generator();
12062
+ async function promptRepoRole() {
12063
+ return (0, import_prompts7.select)({
12064
+ message: "What type of repo is this?",
12065
+ choices: [
12066
+ { name: "Frontend", value: "frontend" },
12067
+ { name: "Backend", value: "backend" },
12068
+ { name: "Mobile", value: "mobile" },
12069
+ { name: "Shared / Other", value: "shared" }
12070
+ ]
12071
+ });
12072
+ }
12073
+ async function quickRegisterRepo(provider) {
12074
+ const roleOverride = await promptRepoRole();
12075
+ const roleLabels = {
12076
+ frontend: "frontend",
12077
+ backend: "backend",
12078
+ mobile: "mobile",
12079
+ shared: "shared"
12080
+ };
12081
+ const raw = await (0, import_prompts7.input)({
12082
+ message: `Enter your ${roleLabels[roleOverride]} repo path (absolute path):`,
12083
+ validate: (v2) => {
12084
+ const trimmed = v2.trim();
12085
+ if (trimmed.length === 0) return "Path cannot be empty";
12086
+ if (!path26.isAbsolute(trimmed)) return "Please provide an absolute path";
12087
+ return true;
12088
+ }
12089
+ });
12090
+ const cleaned = raw.trim().replace(/\\ /g, " ");
12091
+ const resolved = path26.resolve(cleaned);
12092
+ if (!await fs27.pathExists(resolved)) {
12093
+ console.log(import_chalk26.default.red(` Path does not exist: ${resolved}`));
12094
+ return quickRegisterRepo(provider);
12095
+ }
12096
+ const { type, role: detectedRole } = await detectRepoType(resolved);
12097
+ const role = roleOverride ?? detectedRole;
12098
+ const repoName = path26.basename(resolved);
12099
+ console.log(import_chalk26.default.gray(` Detected: ${repoName} \u2192 ${type} (${role})`));
12100
+ const constitutionPath = path26.join(resolved, CONSTITUTION_FILE);
12101
+ let hasConstitution = await fs27.pathExists(constitutionPath);
12102
+ if (!hasConstitution) {
12103
+ console.log(import_chalk26.default.blue(` Generating constitution for ${repoName}...`));
12104
+ try {
12105
+ const gen = new ConstitutionGenerator(provider);
12106
+ const content = await gen.generate(resolved);
12107
+ await gen.saveConstitution(resolved, content);
12108
+ hasConstitution = true;
12109
+ console.log(import_chalk26.default.green(` \u2714 Constitution saved`));
12110
+ } catch (err) {
12111
+ console.log(import_chalk26.default.yellow(` \u26A0 Constitution failed: ${err.message}`));
12112
+ }
12113
+ }
12114
+ const entry = {
12115
+ name: repoName,
12116
+ path: resolved,
12117
+ type,
12118
+ role,
12119
+ hasConstitution,
12120
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
12121
+ };
12122
+ await registerRepo(entry);
12123
+ console.log(import_chalk26.default.green(` \u2714 Repo registered: ${repoName}`));
12124
+ return entry;
12125
+ }
12126
+ async function selectRepos(registeredRepos, provider) {
12127
+ const ADD_NEW = "__add_new__";
12128
+ const choices = [
12129
+ ...registeredRepos.map((r) => ({
12130
+ name: `${r.name} (${r.type} / ${r.role}) \u2192 ${r.path}`,
12131
+ value: r.path
12132
+ })),
12133
+ { name: import_chalk26.default.cyan("+ Add new repo"), value: ADD_NEW }
12134
+ ];
12135
+ const selected = await (0, import_prompts7.checkbox)({
12136
+ message: "Select repo(s) for this feature (space to toggle, enter to confirm):",
12137
+ choices,
12138
+ required: true
12139
+ });
12140
+ const result = [];
12141
+ for (const val of selected) {
12142
+ if (val === ADD_NEW) {
12143
+ const newRepo = await quickRegisterRepo(provider);
12144
+ result.push(newRepo);
12145
+ } else {
12146
+ const repo = registeredRepos.find((r) => r.path === val);
12147
+ if (repo) result.push(repo);
12148
+ }
12149
+ }
12150
+ if (result.length === 0) {
12151
+ console.log(import_chalk26.default.yellow(" No repos selected. Please select at least one."));
12152
+ return selectRepos(registeredRepos, provider);
12153
+ }
12154
+ return result;
12155
+ }
12156
+ function buildWorkspaceConfig(repos) {
12157
+ return {
12158
+ name: "ai-spec-workspace",
12159
+ repos: repos.map((r) => ({
12160
+ name: r.name,
12161
+ path: r.path,
12162
+ type: r.type,
12163
+ role: r.role
12164
+ }))
12165
+ };
12166
+ }
11837
12167
  function registerCreate(program2) {
11838
12168
  program2.command("create").description("Generate a feature spec and kick off code generation").argument("[idea]", "Feature idea in natural language (prompted if omitted)").option(
11839
12169
  "--provider <name>",
@@ -11856,24 +12186,75 @@ function registerCreate(program2) {
11856
12186
  });
11857
12187
  }
11858
12188
  const workspaceLoader = new WorkspaceLoader(currentDir);
11859
- const workspaceConfig = await workspaceLoader.load();
11860
- if (workspaceConfig) {
12189
+ const existingWorkspaceConfig = await workspaceLoader.load();
12190
+ if (existingWorkspaceConfig) {
11861
12191
  console.log(import_chalk26.default.cyan(`
11862
- [Workspace] Detected workspace: ${workspaceConfig.name}`));
11863
- console.log(import_chalk26.default.gray(` Repos: ${workspaceConfig.repos.map((r) => r.name).join(", ")}`));
11864
- const pipelineResults = await runMultiRepoPipeline(idea, workspaceConfig, opts, currentDir, config2);
12192
+ [Workspace] Detected workspace: ${existingWorkspaceConfig.name}`));
12193
+ console.log(import_chalk26.default.gray(` Repos: ${existingWorkspaceConfig.repos.map((r) => r.name).join(", ")}`));
12194
+ const pipelineResults = await runMultiRepoPipeline(idea, existingWorkspaceConfig, opts, currentDir, config2);
11865
12195
  if (opts.serve) {
11866
12196
  await handleAutoServe(pipelineResults);
11867
12197
  }
11868
12198
  return;
11869
12199
  }
11870
- await runSingleRepoPipeline(idea, opts, currentDir, config2);
12200
+ const providerName = opts.provider || config2.provider || "gemini";
12201
+ const modelName = opts.model || config2.model || DEFAULT_MODELS[providerName];
12202
+ const apiKey = await resolveApiKey(providerName, opts.key);
12203
+ const specProvider = createProvider(providerName, apiKey, modelName);
12204
+ const registeredRepos = await getRegisteredRepos();
12205
+ if (registeredRepos.length === 0) {
12206
+ console.log(import_chalk26.default.yellow("\n No repos registered. Please register repos first."));
12207
+ console.log(import_chalk26.default.gray(" Run: ai-spec init"));
12208
+ console.log(import_chalk26.default.gray(" Or add a repo now:\n"));
12209
+ const addNow = await (0, import_prompts7.select)({
12210
+ message: "Add a repo now?",
12211
+ choices: [
12212
+ { name: "Yes \u2014 register a repo and continue", value: "yes" },
12213
+ { name: "No \u2014 exit", value: "no" }
12214
+ ]
12215
+ });
12216
+ if (addNow === "no") {
12217
+ process.exit(0);
12218
+ }
12219
+ const newRepo = await quickRegisterRepo(specProvider);
12220
+ registeredRepos.push(newRepo);
12221
+ }
12222
+ const validRepos = [];
12223
+ for (const r of registeredRepos) {
12224
+ if (await fs27.pathExists(r.path)) {
12225
+ validRepos.push(r);
12226
+ } else {
12227
+ console.log(import_chalk26.default.yellow(` \u26A0 Skipping ${r.name}: path not found (${r.path})`));
12228
+ }
12229
+ }
12230
+ if (validRepos.length === 0) {
12231
+ console.log(import_chalk26.default.red(" No valid repos available. Run: ai-spec init"));
12232
+ process.exit(1);
12233
+ }
12234
+ const selectedRepos = await selectRepos(validRepos, specProvider);
12235
+ if (selectedRepos.length === 1) {
12236
+ const repo = selectedRepos[0];
12237
+ console.log(import_chalk26.default.cyan(`
12238
+ [Repo] ${repo.name} (${repo.type}/${repo.role}) \u2192 ${repo.path}`));
12239
+ await runSingleRepoPipeline(idea, opts, repo.path, config2);
12240
+ } else {
12241
+ const workspaceConfig = buildWorkspaceConfig(selectedRepos);
12242
+ console.log(import_chalk26.default.cyan(`
12243
+ [Workspace] ${selectedRepos.length} repo(s) selected:`));
12244
+ for (const repo of selectedRepos) {
12245
+ console.log(import_chalk26.default.gray(` ${repo.name} (${repo.type}/${repo.role}) \u2192 ${repo.path}`));
12246
+ }
12247
+ const pipelineResults = await runMultiRepoPipeline(idea, workspaceConfig, opts, currentDir, config2);
12248
+ if (opts.serve) {
12249
+ await handleAutoServe(pipelineResults);
12250
+ }
12251
+ }
11871
12252
  });
11872
12253
  }
11873
12254
 
11874
12255
  // cli/commands/review.ts
11875
- var path25 = __toESM(require("path"));
11876
- var fs26 = __toESM(require("fs-extra"));
12256
+ var path27 = __toESM(require("path"));
12257
+ var fs28 = __toESM(require("fs-extra"));
11877
12258
  var import_chalk27 = __toESM(require("chalk"));
11878
12259
  init_spec_generator();
11879
12260
  function registerReview(program2) {
@@ -11891,17 +12272,17 @@ function registerReview(program2) {
11891
12272
  const reviewer = new CodeReviewer(provider, currentDir);
11892
12273
  let specContent = "";
11893
12274
  let resolvedSpecFile;
11894
- if (specFile && await fs26.pathExists(specFile)) {
11895
- specContent = await fs26.readFile(specFile, "utf-8");
12275
+ if (specFile && await fs28.pathExists(specFile)) {
12276
+ specContent = await fs28.readFile(specFile, "utf-8");
11896
12277
  resolvedSpecFile = specFile;
11897
12278
  console.log(import_chalk27.default.gray(`Using spec: ${specFile}`));
11898
12279
  } else {
11899
- const specsDir = path25.join(currentDir, "specs");
11900
- if (await fs26.pathExists(specsDir)) {
11901
- const files = (await fs26.readdir(specsDir)).filter((f) => f.endsWith(".md")).sort().reverse();
12280
+ const specsDir = path27.join(currentDir, "specs");
12281
+ if (await fs28.pathExists(specsDir)) {
12282
+ const files = (await fs28.readdir(specsDir)).filter((f) => f.endsWith(".md")).sort().reverse();
11902
12283
  if (files.length > 0) {
11903
- const latest = path25.join(specsDir, files[0]);
11904
- specContent = await fs26.readFile(latest, "utf-8");
12284
+ const latest = path27.join(specsDir, files[0]);
12285
+ specContent = await fs28.readFile(latest, "utf-8");
11905
12286
  resolvedSpecFile = latest;
11906
12287
  console.log(import_chalk27.default.gray(`Auto-detected spec: specs/${files[0]}`));
11907
12288
  }
@@ -11916,9 +12297,10 @@ function registerReview(program2) {
11916
12297
  }
11917
12298
 
11918
12299
  // cli/commands/init.ts
11919
- var path27 = __toESM(require("path"));
11920
- var fs28 = __toESM(require("fs-extra"));
12300
+ var path28 = __toESM(require("path"));
12301
+ var fs29 = __toESM(require("fs-extra"));
11921
12302
  var import_chalk28 = __toESM(require("chalk"));
12303
+ var import_prompts8 = require("@inquirer/prompts");
11922
12304
  init_spec_generator();
11923
12305
 
11924
12306
  // prompts/global-constitution.prompt.ts
@@ -11980,240 +12362,139 @@ ${summary}
11980
12362
  return parts.join("\n");
11981
12363
  }
11982
12364
 
11983
- // core/project-index.ts
11984
- var fs27 = __toESM(require("fs-extra"));
11985
- var path26 = __toESM(require("path"));
11986
- var INDEX_FILE = ".ai-spec-index.json";
11987
- var KEY_DEPS = [
11988
- // Frameworks
11989
- "express",
11990
- "fastify",
11991
- "koa",
11992
- "@nestjs/core",
11993
- "hapi",
11994
- "next",
11995
- "react",
11996
- "vue",
11997
- "nuxt",
11998
- "svelte",
11999
- "react-native",
12000
- "expo",
12001
- // DB / ORM
12002
- "prisma",
12003
- "@prisma/client",
12004
- "mongoose",
12005
- "typeorm",
12006
- "sequelize",
12007
- "drizzle-orm",
12008
- // Auth
12009
- "jsonwebtoken",
12010
- "passport",
12011
- "next-auth",
12012
- "@clerk/nextjs",
12013
- // Build / Lang
12014
- "typescript",
12015
- "vite",
12016
- "webpack",
12017
- "esbuild",
12018
- "turbo",
12019
- // Testing
12020
- "jest",
12021
- "vitest",
12022
- "mocha",
12023
- "cypress",
12024
- "playwright",
12025
- // Infra
12026
- "redis",
12027
- "bull",
12028
- "socket.io",
12029
- "graphql",
12030
- "@trpc/server"
12031
- ];
12032
- var SKIP_DIRS = /* @__PURE__ */ new Set([
12033
- "node_modules",
12034
- ".git",
12035
- ".svn",
12036
- "dist",
12037
- "build",
12038
- "out",
12039
- ".next",
12040
- ".nuxt",
12041
- "coverage",
12042
- ".turbo",
12043
- ".cache",
12044
- "__pycache__",
12045
- "vendor",
12046
- ".ai-spec-vcr",
12047
- ".ai-spec-logs",
12048
- "specs"
12049
- ]);
12050
- var MANIFEST_FILES = [
12051
- "package.json",
12052
- "go.mod",
12053
- "Cargo.toml",
12054
- "pom.xml",
12055
- "build.gradle",
12056
- "build.gradle.kts",
12057
- "requirements.txt",
12058
- "pyproject.toml",
12059
- "setup.py",
12060
- "composer.json"
12061
- ];
12062
- async function isProjectRoot(absPath) {
12063
- for (const manifest of MANIFEST_FILES) {
12064
- if (await fs27.pathExists(path26.join(absPath, manifest))) return true;
12065
- }
12066
- return false;
12365
+ // cli/commands/init.ts
12366
+ var ROLE_LABELS = {
12367
+ frontend: "frontend",
12368
+ backend: "backend",
12369
+ mobile: "mobile",
12370
+ shared: "shared"
12371
+ };
12372
+ async function promptRepoRole2() {
12373
+ return (0, import_prompts8.select)({
12374
+ message: "What type of repo is this?",
12375
+ choices: [
12376
+ { name: "Frontend", value: "frontend" },
12377
+ { name: "Backend", value: "backend" },
12378
+ { name: "Mobile", value: "mobile" },
12379
+ { name: "Shared / Other", value: "shared" }
12380
+ ]
12381
+ });
12067
12382
  }
12068
- async function extractTechStack(absPath, type) {
12069
- const stack = [];
12070
- if (type === "go") stack.push("go");
12071
- if (type === "rust") stack.push("rust");
12072
- if (type === "java") stack.push("java");
12073
- if (type === "python") stack.push("python");
12074
- if (type === "php") stack.push("php");
12075
- const pkgPath = path26.join(absPath, "package.json");
12076
- if (!await fs27.pathExists(pkgPath)) return stack;
12077
- let pkg = {};
12078
- try {
12079
- pkg = await fs27.readJson(pkgPath);
12080
- } catch {
12081
- return stack;
12082
- }
12083
- const allDeps = {
12084
- ...pkg.dependencies ?? {},
12085
- ...pkg.devDependencies ?? {}
12383
+ async function promptRepoPath(role) {
12384
+ const label = ROLE_LABELS[role];
12385
+ const raw = await (0, import_prompts8.input)({
12386
+ message: `Enter your ${label} repo path (absolute path):`,
12387
+ validate: (v2) => {
12388
+ const trimmed = v2.trim();
12389
+ if (trimmed.length === 0) return "Path cannot be empty";
12390
+ if (!path28.isAbsolute(trimmed)) return "Please provide an absolute path";
12391
+ return true;
12392
+ }
12393
+ });
12394
+ const cleaned = raw.trim().replace(/\\ /g, " ");
12395
+ const resolved = path28.resolve(cleaned);
12396
+ if (!await fs29.pathExists(resolved)) {
12397
+ console.log(import_chalk28.default.red(` Path does not exist: ${resolved}`));
12398
+ return promptRepoPath(role);
12399
+ }
12400
+ const stat5 = await fs29.stat(resolved);
12401
+ if (!stat5.isDirectory()) {
12402
+ console.log(import_chalk28.default.red(` Not a directory: ${resolved}`));
12403
+ return promptRepoPath(role);
12404
+ }
12405
+ return resolved;
12406
+ }
12407
+ async function registerSingleRepo(repoPath, provider, roleOverride) {
12408
+ const { type, role: detectedRole } = await detectRepoType(repoPath);
12409
+ const role = roleOverride ?? detectedRole;
12410
+ const repoName = path28.basename(repoPath);
12411
+ console.log(import_chalk28.default.gray(` Detected: ${repoName} \u2192 ${type} (${role})`));
12412
+ const constitutionPath = path28.join(repoPath, CONSTITUTION_FILE);
12413
+ let hasConstitution = await fs29.pathExists(constitutionPath);
12414
+ if (!hasConstitution) {
12415
+ console.log(import_chalk28.default.blue(` Generating project constitution for ${repoName}...`));
12416
+ try {
12417
+ const gen = new ConstitutionGenerator(provider);
12418
+ const content = await gen.generate(repoPath);
12419
+ await gen.saveConstitution(repoPath, content);
12420
+ hasConstitution = true;
12421
+ console.log(import_chalk28.default.green(` \u2714 Constitution saved: ${constitutionPath}`));
12422
+ } catch (err) {
12423
+ console.log(import_chalk28.default.yellow(` \u26A0 Constitution generation failed: ${err.message}`));
12424
+ }
12425
+ } else {
12426
+ console.log(import_chalk28.default.green(` \u2714 Constitution already exists: ${constitutionPath}`));
12427
+ }
12428
+ const entry = {
12429
+ name: repoName,
12430
+ path: repoPath,
12431
+ type,
12432
+ role,
12433
+ hasConstitution,
12434
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
12086
12435
  };
12087
- const depKeys = new Set(Object.keys(allDeps));
12088
- for (const dep of KEY_DEPS) {
12089
- if (depKeys.has(dep)) stack.push(dep);
12090
- }
12091
- return stack;
12092
- }
12093
- async function discoverProjects(rootDir, maxDepth) {
12094
- const found = [];
12095
- async function walk(absDir, depth) {
12096
- if (depth > maxDepth) return;
12097
- let entries;
12436
+ await registerRepo(entry);
12437
+ return entry;
12438
+ }
12439
+ async function buildProjectSummaries(repos) {
12440
+ const summaries = [];
12441
+ for (const repo of repos) {
12442
+ if (!await fs29.pathExists(repo.path)) continue;
12443
+ const lines = [
12444
+ `Type: ${repo.type} (${repo.role})`
12445
+ ];
12098
12446
  try {
12099
- entries = await fs27.readdir(absDir, { withFileTypes: true });
12447
+ const loader = new ContextLoader(repo.path);
12448
+ const ctx = await loader.loadProjectContext();
12449
+ lines.push(`Tech stack: ${ctx.techStack.join(", ") || "unknown"}`);
12450
+ lines.push(`Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`);
12100
12451
  } catch {
12101
- return;
12452
+ lines.push(`Tech stack: ${repo.type}`);
12102
12453
  }
12103
- for (const entry of entries) {
12104
- if (!entry.isDirectory()) continue;
12105
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
12106
- const childAbs = path26.join(absDir, entry.name);
12107
- const gitPath = path26.join(childAbs, ".git");
12108
- if (await fs27.pathExists(gitPath)) {
12109
- const gitStat = await fs27.stat(gitPath);
12110
- if (gitStat.isFile()) continue;
12111
- }
12112
- if (await isProjectRoot(childAbs)) {
12113
- found.push(path26.relative(rootDir, childAbs));
12114
- } else {
12115
- await walk(childAbs, depth + 1);
12454
+ if (repo.hasConstitution) {
12455
+ try {
12456
+ const constitutionPath = path28.join(repo.path, CONSTITUTION_FILE);
12457
+ const raw = await fs29.readFile(constitutionPath, "utf-8");
12458
+ lines.push("", "Constitution excerpt:", raw.slice(0, 2e3));
12459
+ } catch {
12116
12460
  }
12117
12461
  }
12462
+ summaries.push({ name: repo.name, summary: lines.join("\n") });
12118
12463
  }
12119
- await walk(rootDir, 0);
12120
- return found;
12464
+ return summaries;
12121
12465
  }
12122
- async function loadIndex(scanRoot) {
12123
- const filePath = path26.join(scanRoot, INDEX_FILE);
12124
- try {
12125
- return await fs27.readJson(filePath);
12126
- } catch {
12127
- return null;
12466
+ async function generateGlobalConstitution(provider, repos, currentDir) {
12467
+ console.log(import_chalk28.default.blue("\n\u2500\u2500\u2500 Generating Global Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12468
+ console.log(import_chalk28.default.gray(` Based on ${repos.length} registered repo(s)`));
12469
+ const summaries = await buildProjectSummaries(repos);
12470
+ if (summaries.length === 0) {
12471
+ console.log(import_chalk28.default.yellow(" No valid repos found \u2014 skipping global constitution."));
12472
+ return;
12128
12473
  }
12129
- }
12130
- async function saveIndex(scanRoot, index) {
12131
- const filePath = path26.join(scanRoot, INDEX_FILE);
12132
- await fs27.writeJson(filePath, index, { spaces: 2 });
12133
- return filePath;
12134
- }
12135
- async function runScan(scanRoot, maxDepth = 2) {
12136
- const now = (/* @__PURE__ */ new Date()).toISOString();
12137
- const existing = await loadIndex(scanRoot);
12138
- const existingMap = new Map(
12139
- (existing?.projects ?? []).map((p) => [p.path, p])
12140
- );
12141
- const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
12142
- const added = [];
12143
- const updated = [];
12144
- const unchanged = [];
12145
- const seenPaths = /* @__PURE__ */ new Set();
12146
- for (const relPath of discoveredPaths) {
12147
- const absPath = path26.join(scanRoot, relPath);
12148
- seenPaths.add(relPath);
12149
- const { type, role } = await detectRepoType(absPath);
12150
- const techStack = await extractTechStack(absPath, type);
12151
- const hasConstitution = await fs27.pathExists(path26.join(absPath, CONSTITUTION_FILE));
12152
- const hasWorkspace = await fs27.pathExists(path26.join(absPath, WORKSPACE_CONFIG_FILE));
12153
- const name = path26.basename(relPath);
12154
- const prev = existingMap.get(relPath);
12155
- if (!prev) {
12156
- const entry = {
12157
- name,
12158
- path: relPath,
12159
- type,
12160
- role,
12161
- techStack,
12162
- hasConstitution,
12163
- hasWorkspace,
12164
- firstSeen: now,
12165
- lastSeen: now
12166
- };
12167
- added.push(entry);
12168
- existingMap.set(relPath, entry);
12169
- } else {
12170
- const changed = prev.type !== type || prev.role !== role || prev.hasConstitution !== hasConstitution || prev.hasWorkspace !== hasWorkspace || JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
12171
- const entry = {
12172
- ...prev,
12173
- type,
12174
- role,
12175
- techStack,
12176
- hasConstitution,
12177
- hasWorkspace,
12178
- lastSeen: now,
12179
- missing: void 0
12180
- // clear missing flag if it came back
12181
- };
12182
- existingMap.set(relPath, entry);
12183
- if (changed) {
12184
- updated.push(entry);
12185
- } else {
12186
- unchanged.push(entry);
12187
- }
12188
- }
12474
+ const prompt = buildGlobalConstitutionPrompt(summaries);
12475
+ let globalConstitution;
12476
+ try {
12477
+ globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
12478
+ } catch (err) {
12479
+ console.error(import_chalk28.default.red(` \u2718 Failed to generate global constitution: ${err.message}`));
12480
+ return;
12189
12481
  }
12190
- const nowMissing = [];
12191
- for (const [relPath, entry] of existingMap) {
12192
- if (!seenPaths.has(relPath) && !entry.missing) {
12193
- const gone = { ...entry, missing: true };
12194
- existingMap.set(relPath, gone);
12195
- nowMissing.push(gone);
12196
- }
12482
+ const saved = await saveGlobalConstitution(globalConstitution, currentDir);
12483
+ console.log(import_chalk28.default.green(` \u2714 Global constitution saved: ${saved}`));
12484
+ console.log(import_chalk28.default.gray(" Project constitutions will be merged with this at runtime."));
12485
+ const lines = globalConstitution.split("\n");
12486
+ console.log(import_chalk28.default.bold("\n Preview:"));
12487
+ console.log(import_chalk28.default.gray(lines.slice(0, 10).join("\n")));
12488
+ if (lines.length > 10) {
12489
+ console.log(import_chalk28.default.gray(` ... (${lines.length} lines total)`));
12197
12490
  }
12198
- const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
12199
- const index = {
12200
- scanRoot,
12201
- lastScanned: now,
12202
- projects
12203
- };
12204
- return { index, added, updated, unchanged, nowMissing };
12205
12491
  }
12206
-
12207
- // cli/commands/init.ts
12208
12492
  function registerInit(program2) {
12209
- program2.command("init").description(`Analyze codebase and generate Project Constitution (${CONSTITUTION_FILE})`).option(
12493
+ program2.command("init").description("Setup workspace: register repos, generate constitutions").option(
12210
12494
  "--provider <name>",
12211
12495
  `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
12212
12496
  void 0
12213
- ).option("--model <name>", "Model name").option("-k, --key <apiKey>", "API key").option("--force", "Overwrite existing constitution").option(
12214
- "--global",
12215
- `Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
12216
- ).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) => {
12497
+ ).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) => {
12217
12498
  const currentDir = process.cwd();
12218
12499
  const config2 = await loadConfig(currentDir);
12219
12500
  const providerName = opts.provider || config2.provider || "gemini";
@@ -12232,7 +12513,7 @@ function registerInit(program2) {
12232
12513
  console.log(import_chalk28.default.gray(` Lines : ${result.before.totalLines} \u2192 ${result.after.totalLines} (${result.before.totalLines - result.after.totalLines > 0 ? "-" : "+"}${Math.abs(result.before.totalLines - result.after.totalLines)})`));
12233
12514
  console.log(import_chalk28.default.gray(` \xA79 : ${result.before.lessonCount} \u2192 ${result.after.lessonCount} lessons remaining`));
12234
12515
  if (result.backupPath) {
12235
- console.log(import_chalk28.default.gray(` Backup: ${path27.basename(result.backupPath)}`));
12516
+ console.log(import_chalk28.default.gray(` Backup: ${path28.basename(result.backupPath)}`));
12236
12517
  }
12237
12518
  }
12238
12519
  } catch (err) {
@@ -12241,119 +12522,125 @@ function registerInit(program2) {
12241
12522
  }
12242
12523
  return;
12243
12524
  }
12244
- if (opts.global) {
12245
- const existing = await loadGlobalConstitution([currentDir]);
12246
- if (existing && !opts.force) {
12247
- console.log(import_chalk28.default.yellow(`
12248
- Global constitution already exists at: ${existing.source}`));
12249
- console.log(import_chalk28.default.gray(" Use --force to overwrite it."));
12250
- return;
12525
+ if (opts.addRepo) {
12526
+ console.log(import_chalk28.default.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"));
12527
+ const role = await promptRepoRole2();
12528
+ const repoPath = await promptRepoPath(role);
12529
+ const entry = await registerSingleRepo(repoPath, provider, role);
12530
+ console.log(import_chalk28.default.green(`
12531
+ \u2714 Repo registered: ${entry.name} (${entry.type} / ${entry.role})`));
12532
+ console.log(import_chalk28.default.gray(` Saved to: ${REPO_STORE_FILE}`));
12533
+ const updateGlobal = await (0, import_prompts8.confirm)({
12534
+ message: "Update global constitution with this repo's context?",
12535
+ default: true
12536
+ });
12537
+ if (updateGlobal) {
12538
+ const allRepos2 = await getRegisteredRepos();
12539
+ await generateGlobalConstitution(provider, allRepos2, currentDir);
12251
12540
  }
12252
- console.log(import_chalk28.default.blue("\n\u2500\u2500\u2500 Generating Global Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12253
- console.log(import_chalk28.default.gray(` Provider: ${providerName}/${modelName}`));
12254
- const projectSummaries = [];
12255
- const index = await loadIndex(currentDir);
12256
- if (index && index.projects.length > 0) {
12257
- const active = index.projects.filter((p) => !p.missing);
12258
- console.log(import_chalk28.default.gray(` Found project index: ${active.length} project(s) \u2014 reading constitutions...`));
12259
- for (const entry of active) {
12260
- const absPath = path27.join(currentDir, entry.path);
12261
- const lines = [
12262
- `Type: ${entry.type} (${entry.role})`,
12263
- `Tech stack: ${entry.techStack.join(", ") || "unknown"}`
12264
- ];
12265
- if (entry.hasConstitution) {
12266
- try {
12267
- const constitutionPath2 = path27.join(absPath, CONSTITUTION_FILE);
12268
- const raw = await fs28.readFile(constitutionPath2, "utf-8");
12269
- const excerpt = raw.slice(0, 2e3);
12270
- lines.push("", "Constitution excerpt:", excerpt);
12271
- } catch {
12272
- }
12273
- }
12274
- projectSummaries.push({ name: entry.name, summary: lines.join("\n") });
12541
+ return;
12542
+ }
12543
+ console.log(import_chalk28.default.blue("\n" + "\u2500".repeat(52)));
12544
+ console.log(import_chalk28.default.bold(" ai-spec init \u2014 Workspace Setup"));
12545
+ console.log(import_chalk28.default.blue("\u2500".repeat(52)));
12546
+ console.log(import_chalk28.default.gray(` Provider: ${providerName}/${modelName}
12547
+ `));
12548
+ const existingRepos = await getRegisteredRepos();
12549
+ if (existingRepos.length > 0) {
12550
+ console.log(import_chalk28.default.cyan(" Registered repos:"));
12551
+ for (const r of existingRepos) {
12552
+ const constitutionIcon = r.hasConstitution ? import_chalk28.default.green("\u2714") : import_chalk28.default.gray("\u25CB");
12553
+ console.log(import_chalk28.default.gray(` ${constitutionIcon} ${r.name} (${r.type} / ${r.role}) \u2192 ${r.path}`));
12554
+ }
12555
+ console.log();
12556
+ }
12557
+ const action = existingRepos.length > 0 ? await (0, import_prompts8.select)({
12558
+ message: "What would you like to do?",
12559
+ choices: [
12560
+ { name: "Add new repo(s)", value: "add" },
12561
+ { name: "Re-generate constitutions for existing repos", value: "regen" },
12562
+ { name: "Skip \u2014 proceed to global constitution", value: "skip" }
12563
+ ]
12564
+ }) : "add";
12565
+ const newRepos = [];
12566
+ if (action === "add") {
12567
+ let addMore = true;
12568
+ while (addMore) {
12569
+ console.log(import_chalk28.default.blue(`
12570
+ \u2500\u2500 Register Repo #${existingRepos.length + newRepos.length + 1} \u2500\u2500`));
12571
+ const role = await promptRepoRole2();
12572
+ const repoPath = await promptRepoPath(role);
12573
+ const alreadyRegistered = [...existingRepos, ...newRepos].find((r) => r.path === repoPath);
12574
+ if (alreadyRegistered) {
12575
+ console.log(import_chalk28.default.yellow(` Already registered: ${alreadyRegistered.name}`));
12576
+ } else {
12577
+ const entry = await registerSingleRepo(repoPath, provider, role);
12578
+ newRepos.push(entry);
12579
+ console.log(import_chalk28.default.green(` \u2714 Registered: ${entry.name}`));
12275
12580
  }
12276
- } else {
12277
- console.log(import_chalk28.default.yellow(" No project index found. Run `ai-spec scan` first for better results."));
12278
- console.log(import_chalk28.default.gray(" Falling back: scanning current directory only..."));
12279
- const loader = new ContextLoader(currentDir);
12280
- const ctx = await loader.loadProjectContext();
12281
- projectSummaries.push({
12282
- name: path27.basename(currentDir),
12283
- summary: [
12284
- `Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
12285
- `Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`
12286
- ].join("\n")
12581
+ addMore = await (0, import_prompts8.confirm)({
12582
+ message: "Add another repo?",
12583
+ default: false
12287
12584
  });
12288
12585
  }
12289
- console.log(import_chalk28.default.gray(` Generating from ${projectSummaries.length} project(s)...`));
12290
- const prompt = buildGlobalConstitutionPrompt(projectSummaries);
12291
- let globalConstitution;
12292
- try {
12293
- globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
12294
- } catch (err) {
12295
- console.error(import_chalk28.default.red(" \u2718 Failed to generate global constitution:"), err);
12296
- process.exit(1);
12297
- }
12298
- const saved2 = await saveGlobalConstitution(globalConstitution, currentDir);
12299
- console.log(import_chalk28.default.green(`
12300
- \u2714 Global constitution saved: ${saved2}`));
12301
- console.log(import_chalk28.default.gray(" This will be automatically merged into all project constitutions in this workspace."));
12302
- console.log(import_chalk28.default.gray(" Project-level rules always override global rules.\n"));
12303
- console.log(import_chalk28.default.bold(" Preview:"));
12304
- console.log(import_chalk28.default.gray(globalConstitution.split("\n").slice(0, 12).join("\n")));
12305
- if (globalConstitution.split("\n").length > 12) {
12306
- console.log(import_chalk28.default.gray(` ... (${globalConstitution.split("\n").length} lines total)`));
12586
+ }
12587
+ if (action === "regen") {
12588
+ console.log(import_chalk28.default.blue("\n Re-generating project constitutions..."));
12589
+ for (const repo of existingRepos) {
12590
+ if (!await fs29.pathExists(repo.path)) {
12591
+ console.log(import_chalk28.default.yellow(` \u26A0 ${repo.name}: path not found \u2014 skipping`));
12592
+ continue;
12593
+ }
12594
+ const constitutionPath = path28.join(repo.path, CONSTITUTION_FILE);
12595
+ if (await fs29.pathExists(constitutionPath) && !opts.force) {
12596
+ console.log(import_chalk28.default.gray(` ${repo.name}: constitution exists (use --force to overwrite)`));
12597
+ continue;
12598
+ }
12599
+ console.log(import_chalk28.default.blue(` ${repo.name}: generating constitution...`));
12600
+ try {
12601
+ const gen = new ConstitutionGenerator(provider);
12602
+ const content = await gen.generate(repo.path);
12603
+ await gen.saveConstitution(repo.path, content);
12604
+ console.log(import_chalk28.default.green(` \u2714 ${repo.name}: constitution saved`));
12605
+ } catch (err) {
12606
+ console.log(import_chalk28.default.yellow(` \u26A0 ${repo.name}: failed \u2014 ${err.message}`));
12607
+ }
12307
12608
  }
12308
- return;
12309
12609
  }
12310
- const constitutionPath = path27.join(currentDir, CONSTITUTION_FILE);
12311
- if (!opts.force && await fs28.pathExists(constitutionPath)) {
12312
- console.log(import_chalk28.default.yellow(`
12313
- ${CONSTITUTION_FILE} already exists.`));
12314
- console.log(import_chalk28.default.gray(" Use --force to overwrite it."));
12315
- console.log(import_chalk28.default.gray(` Or edit it directly: ${constitutionPath}`));
12610
+ const allRepos = await getRegisteredRepos();
12611
+ if (allRepos.length === 0) {
12612
+ console.log(import_chalk28.default.yellow("\n No repos registered. Run `ai-spec init` again to add repos."));
12316
12613
  return;
12317
12614
  }
12318
- console.log(import_chalk28.default.blue("\n\u2500\u2500\u2500 Generating Project Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12319
- console.log(import_chalk28.default.gray(` Provider: ${providerName}/${modelName}`));
12320
- console.log(import_chalk28.default.gray(" Analyzing codebase..."));
12321
- const generator = new ConstitutionGenerator(provider);
12322
- let constitution;
12323
- try {
12324
- constitution = await generator.generate(currentDir);
12325
- } catch (err) {
12326
- console.error(import_chalk28.default.red(" \u2718 Failed to generate constitution:"), err);
12327
- process.exit(1);
12328
- }
12329
- const saved = await generator.saveConstitution(currentDir, constitution);
12330
- const globalResult = await loadGlobalConstitution([path27.dirname(currentDir)]);
12331
- if (globalResult) {
12332
- console.log(import_chalk28.default.cyan(`
12333
- \u2139 Global constitution detected: ${globalResult.source}`));
12334
- console.log(import_chalk28.default.gray(" It will be merged with this project constitution at runtime."));
12335
- console.log(import_chalk28.default.gray(" Project rules take priority over global rules."));
12336
- }
12337
- console.log(import_chalk28.default.green(`
12338
- \u2714 Constitution saved: ${saved}`));
12339
- console.log(import_chalk28.default.gray(" This file will be automatically used in all future `ai-spec create` runs."));
12340
- console.log(import_chalk28.default.gray(" Edit it to add custom rules or red lines for your project.\n"));
12341
- console.log(import_chalk28.default.bold(" Preview:"));
12342
- console.log(import_chalk28.default.gray(constitution.split("\n").slice(0, 15).join("\n")));
12343
- if (constitution.split("\n").length > 15) {
12344
- console.log(import_chalk28.default.gray(` ... (${constitution.split("\n").length} lines total)`));
12345
- }
12615
+ const existingGlobal = await loadGlobalConstitution([currentDir]);
12616
+ const shouldGenerateGlobal = !existingGlobal || opts.force || newRepos.length > 0 ? true : await (0, import_prompts8.confirm)({
12617
+ message: "Global constitution exists. Re-generate it?",
12618
+ default: false
12619
+ });
12620
+ if (shouldGenerateGlobal) {
12621
+ await generateGlobalConstitution(provider, allRepos, currentDir);
12622
+ }
12623
+ console.log(import_chalk28.default.bold.green("\n\u2714 Init complete!"));
12624
+ console.log(import_chalk28.default.gray(` Repos registered: ${allRepos.length}`));
12625
+ for (const r of allRepos) {
12626
+ const icon = r.hasConstitution ? import_chalk28.default.green("\u2714") : import_chalk28.default.gray("\u25CB");
12627
+ console.log(import_chalk28.default.gray(` ${icon} ${r.name} (${r.type}/${r.role})`));
12628
+ }
12629
+ console.log(import_chalk28.default.gray(`
12630
+ Repo store: ${REPO_STORE_FILE}`));
12631
+ console.log(import_chalk28.default.gray(` Next step: ai-spec create "your feature idea"`));
12632
+ process.exit(0);
12346
12633
  });
12347
12634
  }
12348
12635
 
12349
12636
  // cli/commands/config.ts
12350
- var path28 = __toESM(require("path"));
12351
- var fs29 = __toESM(require("fs-extra"));
12637
+ var path29 = __toESM(require("path"));
12638
+ var fs30 = __toESM(require("fs-extra"));
12352
12639
  var import_chalk29 = __toESM(require("chalk"));
12353
12640
  function registerConfig(program2) {
12354
12641
  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) => {
12355
12642
  const currentDir = process.cwd();
12356
- const configPath = path28.join(currentDir, CONFIG_FILE);
12643
+ const configPath = path29.join(currentDir, CONFIG_FILE);
12357
12644
  if (opts.clearKeys) {
12358
12645
  await clearAllKeys();
12359
12646
  console.log(import_chalk29.default.green(`\u2714 All saved API keys cleared.`));
@@ -12365,7 +12652,7 @@ function registerConfig(program2) {
12365
12652
  return;
12366
12653
  }
12367
12654
  if (opts.listKeys) {
12368
- const store = await fs29.readJson(KEY_STORE_FILE).catch(() => ({}));
12655
+ const store = await fs30.readJson(KEY_STORE_FILE).catch(() => ({}));
12369
12656
  const providers = Object.keys(store);
12370
12657
  if (providers.length === 0) {
12371
12658
  console.log(import_chalk29.default.gray("No saved API keys."));
@@ -12381,7 +12668,7 @@ File: ${KEY_STORE_FILE}`));
12381
12668
  return;
12382
12669
  }
12383
12670
  if (opts.reset) {
12384
- await fs29.writeJson(configPath, {}, { spaces: 2 });
12671
+ await fs30.writeJson(configPath, {}, { spaces: 2 });
12385
12672
  console.log(import_chalk29.default.green(`\u2714 Config reset: ${configPath}`));
12386
12673
  return;
12387
12674
  }
@@ -12425,22 +12712,18 @@ File: ${KEY_STORE_FILE}`));
12425
12712
  }
12426
12713
  updated.maxErrorCycles = cycles;
12427
12714
  }
12428
- await fs29.writeJson(configPath, updated, { spaces: 2 });
12715
+ await fs30.writeJson(configPath, updated, { spaces: 2 });
12429
12716
  console.log(import_chalk29.default.green(`\u2714 Config saved to ${configPath}`));
12430
12717
  console.log(JSON.stringify(updated, null, 2));
12431
12718
  });
12432
12719
  }
12433
12720
 
12434
12721
  // cli/commands/model.ts
12435
- var path29 = __toESM(require("path"));
12436
- var fs30 = __toESM(require("fs-extra"));
12437
12722
  var import_chalk30 = __toESM(require("chalk"));
12438
- var import_prompts8 = require("@inquirer/prompts");
12723
+ var import_prompts9 = require("@inquirer/prompts");
12439
12724
  init_spec_generator();
12440
12725
  function registerModel(program2) {
12441
12726
  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) => {
12442
- const currentDir = process.cwd();
12443
- const configPath = path29.join(currentDir, CONFIG_FILE);
12444
12727
  if (opts.list) {
12445
12728
  console.log(import_chalk30.default.bold("\nAvailable providers & models:\n"));
12446
12729
  for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
@@ -12457,8 +12740,9 @@ function registerModel(program2) {
12457
12740
  }
12458
12741
  return;
12459
12742
  }
12460
- const existing = await loadConfig(currentDir);
12743
+ const existing = await loadGlobalConfig();
12461
12744
  console.log(import_chalk30.default.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"));
12745
+ console.log(import_chalk30.default.gray(` Config: ${GLOBAL_CONFIG_FILE}`));
12462
12746
  if (Object.keys(existing).length > 0) {
12463
12747
  console.log(
12464
12748
  import_chalk30.default.gray(
@@ -12467,7 +12751,7 @@ function registerModel(program2) {
12467
12751
  );
12468
12752
  }
12469
12753
  console.log();
12470
- const target = await (0, import_prompts8.select)({
12754
+ const target = await (0, import_prompts9.select)({
12471
12755
  message: "Configure model for:",
12472
12756
  choices: [
12473
12757
  { name: "Spec generation (used for spec writing & refinement)", value: "spec" },
@@ -12476,7 +12760,7 @@ function registerModel(program2) {
12476
12760
  ]
12477
12761
  });
12478
12762
  async function pickProviderAndModel(label) {
12479
- const providerKey = await (0, import_prompts8.select)({
12763
+ const providerKey = await (0, import_prompts9.select)({
12480
12764
  message: `${label} \u2014 select provider:`,
12481
12765
  choices: Object.entries(PROVIDER_CATALOG).map(([key, meta2]) => ({
12482
12766
  name: `${meta2.displayName.padEnd(22)} ${import_chalk30.default.gray(meta2.description)}`,
@@ -12489,12 +12773,12 @@ function registerModel(program2) {
12489
12773
  ...meta.models.map((m) => ({ name: m, value: m })),
12490
12774
  { name: import_chalk30.default.italic("\u270E Enter custom model name..."), value: "__custom__" }
12491
12775
  ];
12492
- let chosenModel = await (0, import_prompts8.select)({
12776
+ let chosenModel = await (0, import_prompts9.select)({
12493
12777
  message: `${label} \u2014 select model (${meta.displayName}):`,
12494
12778
  choices: modelChoices
12495
12779
  });
12496
12780
  if (chosenModel === "__custom__") {
12497
- chosenModel = await (0, import_prompts8.input)({
12781
+ chosenModel = await (0, import_prompts9.input)({
12498
12782
  message: "Enter model name:",
12499
12783
  validate: (v2) => v2.trim().length > 0 || "Model name cannot be empty"
12500
12784
  });
@@ -12539,14 +12823,14 @@ function registerModel(program2) {
12539
12823
  )
12540
12824
  );
12541
12825
  }
12542
- const ok = await (0, import_prompts8.confirm)({ message: "Save to .ai-spec.json?", default: true });
12826
+ const ok = await (0, import_prompts9.confirm)({ message: `Save to ${GLOBAL_CONFIG_FILE}?`, default: true });
12543
12827
  if (!ok) {
12544
12828
  console.log(import_chalk30.default.gray(" Cancelled."));
12545
12829
  return;
12546
12830
  }
12547
- await fs30.writeJson(configPath, updated, { spaces: 2 });
12831
+ await saveGlobalConfig(updated);
12548
12832
  console.log(import_chalk30.default.green(`
12549
- \u2714 Saved to ${configPath}`));
12833
+ \u2714 Saved to ${GLOBAL_CONFIG_FILE}`));
12550
12834
  const providerToCheck = updated.provider ?? "gemini";
12551
12835
  const envKey = ENV_KEY_MAP[providerToCheck];
12552
12836
  if (envKey && !process.env[envKey]) {
@@ -12563,14 +12847,14 @@ function registerModel(program2) {
12563
12847
  var path30 = __toESM(require("path"));
12564
12848
  var fs31 = __toESM(require("fs-extra"));
12565
12849
  var import_chalk31 = __toESM(require("chalk"));
12566
- var import_prompts9 = require("@inquirer/prompts");
12850
+ var import_prompts10 = require("@inquirer/prompts");
12567
12851
  function registerWorkspace(program2) {
12568
12852
  const workspaceCmd = program2.command("workspace").description("Manage multi-repo workspace configuration");
12569
12853
  workspaceCmd.command("init").description(`Interactive workspace setup \u2014 creates ${WORKSPACE_CONFIG_FILE}`).action(async () => {
12570
12854
  const currentDir = process.cwd();
12571
12855
  const configPath = path30.join(currentDir, WORKSPACE_CONFIG_FILE);
12572
12856
  if (await fs31.pathExists(configPath)) {
12573
- const overwrite = await (0, import_prompts9.confirm)({
12857
+ const overwrite = await (0, import_prompts10.confirm)({
12574
12858
  message: `${WORKSPACE_CONFIG_FILE} already exists. Overwrite?`,
12575
12859
  default: false
12576
12860
  });
@@ -12580,12 +12864,12 @@ function registerWorkspace(program2) {
12580
12864
  }
12581
12865
  }
12582
12866
  console.log(import_chalk31.default.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"));
12583
- const workspaceName = await (0, import_prompts9.input)({
12867
+ const workspaceName = await (0, import_prompts10.input)({
12584
12868
  message: "Workspace name:",
12585
12869
  validate: (v2) => v2.trim().length > 0 || "Name cannot be empty"
12586
12870
  });
12587
12871
  const repos = [];
12588
- const useAutoScan = await (0, import_prompts9.confirm)({
12872
+ const useAutoScan = await (0, import_prompts10.confirm)({
12589
12873
  message: "Auto-scan sibling directories for repos?",
12590
12874
  default: true
12591
12875
  });
@@ -12599,7 +12883,7 @@ function registerWorkspace(program2) {
12599
12883
  for (const r of detected) {
12600
12884
  console.log(import_chalk31.default.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
12601
12885
  }
12602
- const keepAll = await (0, import_prompts9.confirm)({
12886
+ const keepAll = await (0, import_prompts10.confirm)({
12603
12887
  message: `Include all ${detected.length} detected repo(s)?`,
12604
12888
  default: true
12605
12889
  });
@@ -12607,7 +12891,7 @@ function registerWorkspace(program2) {
12607
12891
  repos.push(...detected);
12608
12892
  } else {
12609
12893
  for (const r of detected) {
12610
- const keep = await (0, import_prompts9.confirm)({
12894
+ const keep = await (0, import_prompts10.confirm)({
12611
12895
  message: `Include "${r.name}" (${r.role}, ${r.type})?`,
12612
12896
  default: true
12613
12897
  });
@@ -12631,14 +12915,14 @@ function registerWorkspace(program2) {
12631
12915
  { name: "react-native (React Native mobile)", value: "react-native" },
12632
12916
  { name: "unknown", value: "unknown" }
12633
12917
  ];
12634
- let addMore = await (0, import_prompts9.confirm)({
12918
+ let addMore = await (0, import_prompts10.confirm)({
12635
12919
  message: repos.length > 0 ? "Manually add more repos?" : "Add repos manually?",
12636
12920
  default: repos.length === 0
12637
12921
  });
12638
12922
  while (addMore) {
12639
12923
  console.log(import_chalk31.default.cyan(`
12640
12924
  Adding repo #${repos.length + 1}`));
12641
- const repoName = await (0, import_prompts9.input)({
12925
+ const repoName = await (0, import_prompts10.input)({
12642
12926
  message: "Repo name (e.g. api, web, app):",
12643
12927
  validate: (v2) => {
12644
12928
  if (!v2.trim()) return "Name cannot be empty";
@@ -12646,7 +12930,7 @@ function registerWorkspace(program2) {
12646
12930
  return true;
12647
12931
  }
12648
12932
  });
12649
- const repoPath = await (0, import_prompts9.input)({
12933
+ const repoPath = await (0, import_prompts10.input)({
12650
12934
  message: `Relative path to "${repoName}" from here (default: ./${repoName}):`,
12651
12935
  default: `./${repoName}`
12652
12936
  });
@@ -12661,12 +12945,12 @@ function registerWorkspace(program2) {
12661
12945
  } else {
12662
12946
  console.log(import_chalk31.default.yellow(` Path "${absPath}" not found \u2014 type/role will be manual.`));
12663
12947
  }
12664
- const repoType = await (0, import_prompts9.select)({
12948
+ const repoType = await (0, import_prompts10.select)({
12665
12949
  message: `Repo type for "${repoName}":`,
12666
12950
  choices: repoTypeChoices,
12667
12951
  default: detectedType
12668
12952
  });
12669
- const repoRole = await (0, import_prompts9.select)({
12953
+ const repoRole = await (0, import_prompts10.select)({
12670
12954
  message: `Repo role for "${repoName}":`,
12671
12955
  choices: [
12672
12956
  { name: "backend", value: "backend" },
@@ -12683,7 +12967,7 @@ function registerWorkspace(program2) {
12683
12967
  role: repoRole
12684
12968
  });
12685
12969
  console.log(import_chalk31.default.green(` \u2714 Added: ${repoName} (${repoRole}, ${repoType})`));
12686
- addMore = await (0, import_prompts9.confirm)({
12970
+ addMore = await (0, import_prompts10.confirm)({
12687
12971
  message: "Add another repo?",
12688
12972
  default: false
12689
12973
  });
@@ -12694,7 +12978,7 @@ function registerWorkspace(program2) {
12694
12978
  for (const r of repos) {
12695
12979
  console.log(import_chalk31.default.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
12696
12980
  }
12697
- const ok = await (0, import_prompts9.confirm)({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
12981
+ const ok = await (0, import_prompts10.confirm)({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
12698
12982
  if (!ok) {
12699
12983
  console.log(import_chalk31.default.gray(" Cancelled."));
12700
12984
  return;
@@ -12738,7 +13022,7 @@ Workspace: ${config2.name}`));
12738
13022
  var path32 = __toESM(require("path"));
12739
13023
  var fs33 = __toESM(require("fs-extra"));
12740
13024
  var import_chalk33 = __toESM(require("chalk"));
12741
- var import_prompts10 = require("@inquirer/prompts");
13025
+ var import_prompts11 = require("@inquirer/prompts");
12742
13026
  init_spec_generator();
12743
13027
 
12744
13028
  // core/spec-updater.ts
@@ -12973,7 +13257,7 @@ function registerUpdate(program2) {
12973
13257
  const currentDir = process.cwd();
12974
13258
  const config2 = await loadConfig(currentDir);
12975
13259
  if (!change) {
12976
- change = await (0, import_prompts10.input)({
13260
+ change = await (0, import_prompts11.input)({
12977
13261
  message: "Describe the change you want to make:",
12978
13262
  validate: (v2) => v2.trim().length > 0 || "Change description cannot be empty"
12979
13263
  });
@@ -13291,7 +13575,7 @@ function buildYamlDoc(obj) {
13291
13575
  return `${k2}: ${valStr}`;
13292
13576
  }).join("\n") + "\n";
13293
13577
  }
13294
- function dslToOpenApi(dsl, serverUrl = "http://localhost:3000") {
13578
+ function dslToOpenApi(dsl, serverUrl = DEFAULT_OPENAPI_SERVER_URL) {
13295
13579
  const info = {
13296
13580
  title: dsl.feature.title,
13297
13581
  description: dsl.feature.description,
@@ -13338,7 +13622,7 @@ function dslToOpenApi(dsl, serverUrl = "http://localhost:3000") {
13338
13622
  }
13339
13623
  async function exportOpenApi(dsl, projectDir, opts = {}) {
13340
13624
  const format = opts.format ?? "yaml";
13341
- const serverUrl = opts.serverUrl ?? "http://localhost:3000";
13625
+ const serverUrl = opts.serverUrl ?? DEFAULT_OPENAPI_SERVER_URL;
13342
13626
  const defaultName = `openapi.${format}`;
13343
13627
  const outputPath = opts.outputPath ? path33.isAbsolute(opts.outputPath) ? opts.outputPath : path33.join(projectDir, opts.outputPath) : path33.join(projectDir, defaultName);
13344
13628
  const doc = dslToOpenApi(dsl, serverUrl);
@@ -13534,12 +13818,12 @@ function registerMock(program2) {
13534
13818
 
13535
13819
  // cli/commands/learn.ts
13536
13820
  var import_chalk36 = __toESM(require("chalk"));
13537
- var import_prompts11 = require("@inquirer/prompts");
13821
+ var import_prompts12 = require("@inquirer/prompts");
13538
13822
  function registerLearn(program2) {
13539
13823
  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) => {
13540
13824
  const currentDir = process.cwd();
13541
13825
  if (!lesson) {
13542
- lesson = await (0, import_prompts11.input)({
13826
+ lesson = await (0, import_prompts12.input)({
13543
13827
  message: "What lesson or engineering decision should be recorded?",
13544
13828
  validate: (v2) => v2.trim().length > 0 || "Please enter a lesson"
13545
13829
  });
@@ -13581,9 +13865,8 @@ var import_chalk39 = __toESM(require("chalk"));
13581
13865
  var fs37 = __toESM(require("fs-extra"));
13582
13866
  var path36 = __toESM(require("path"));
13583
13867
  var import_chalk38 = __toESM(require("chalk"));
13584
- var LOG_DIR2 = ".ai-spec-logs";
13585
13868
  async function loadRunLogs(workingDir) {
13586
- const logDir = path36.join(workingDir, LOG_DIR2);
13869
+ const logDir = path36.join(workingDir, DEFAULT_LOG_DIR);
13587
13870
  if (!await fs37.pathExists(logDir)) return [];
13588
13871
  const files = await fs37.readdir(logDir);
13589
13872
  const jsonFiles = new Set(files.filter((f) => f.endsWith(".json")));
@@ -13728,7 +14011,7 @@ function printTrendReport(report, workingDir) {
13728
14011
  ` ${import_chalk38.default.gray(formatDate(e.startedAt))} ${bar}${scoreStr} ${hash} ${dur}${errMark} ${spec}`
13729
14012
  );
13730
14013
  }
13731
- const logRelDir = path36.relative(workingDir, path36.join(workingDir, LOG_DIR2));
14014
+ const logRelDir = path36.relative(workingDir, path36.join(workingDir, DEFAULT_LOG_DIR));
13732
14015
  console.log(import_chalk38.default.gray(`
13733
14016
  ${entries.length} run(s) shown \xB7 logs: ${logRelDir}/`));
13734
14017
  console.log(import_chalk38.default.cyan("\u2500".repeat(63)));
@@ -14387,7 +14670,233 @@ function registerVcr(program2) {
14387
14670
 
14388
14671
  // cli/commands/scan.ts
14389
14672
  var import_chalk44 = __toESM(require("chalk"));
14673
+ var path42 = __toESM(require("path"));
14674
+
14675
+ // core/project-index.ts
14676
+ var fs42 = __toESM(require("fs-extra"));
14390
14677
  var path41 = __toESM(require("path"));
14678
+ var INDEX_FILE = ".ai-spec-index.json";
14679
+ var KEY_DEPS = [
14680
+ // Frameworks
14681
+ "express",
14682
+ "fastify",
14683
+ "koa",
14684
+ "@nestjs/core",
14685
+ "hapi",
14686
+ "next",
14687
+ "react",
14688
+ "vue",
14689
+ "nuxt",
14690
+ "svelte",
14691
+ "react-native",
14692
+ "expo",
14693
+ // DB / ORM
14694
+ "prisma",
14695
+ "@prisma/client",
14696
+ "mongoose",
14697
+ "typeorm",
14698
+ "sequelize",
14699
+ "drizzle-orm",
14700
+ // Auth
14701
+ "jsonwebtoken",
14702
+ "passport",
14703
+ "next-auth",
14704
+ "@clerk/nextjs",
14705
+ // Build / Lang
14706
+ "typescript",
14707
+ "vite",
14708
+ "webpack",
14709
+ "esbuild",
14710
+ "turbo",
14711
+ // Testing
14712
+ "jest",
14713
+ "vitest",
14714
+ "mocha",
14715
+ "cypress",
14716
+ "playwright",
14717
+ // Infra
14718
+ "redis",
14719
+ "bull",
14720
+ "socket.io",
14721
+ "graphql",
14722
+ "@trpc/server"
14723
+ ];
14724
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
14725
+ "node_modules",
14726
+ ".git",
14727
+ ".svn",
14728
+ "dist",
14729
+ "build",
14730
+ "out",
14731
+ ".next",
14732
+ ".nuxt",
14733
+ "coverage",
14734
+ ".turbo",
14735
+ ".cache",
14736
+ "__pycache__",
14737
+ "vendor",
14738
+ ".ai-spec-vcr",
14739
+ ".ai-spec-logs",
14740
+ "specs"
14741
+ ]);
14742
+ var MANIFEST_FILES = [
14743
+ "package.json",
14744
+ "go.mod",
14745
+ "Cargo.toml",
14746
+ "pom.xml",
14747
+ "build.gradle",
14748
+ "build.gradle.kts",
14749
+ "requirements.txt",
14750
+ "pyproject.toml",
14751
+ "setup.py",
14752
+ "composer.json"
14753
+ ];
14754
+ async function isProjectRoot(absPath) {
14755
+ for (const manifest of MANIFEST_FILES) {
14756
+ if (await fs42.pathExists(path41.join(absPath, manifest))) return true;
14757
+ }
14758
+ return false;
14759
+ }
14760
+ async function extractTechStack(absPath, type) {
14761
+ const stack = [];
14762
+ if (type === "go") stack.push("go");
14763
+ if (type === "rust") stack.push("rust");
14764
+ if (type === "java") stack.push("java");
14765
+ if (type === "python") stack.push("python");
14766
+ if (type === "php") stack.push("php");
14767
+ const pkgPath = path41.join(absPath, "package.json");
14768
+ if (!await fs42.pathExists(pkgPath)) return stack;
14769
+ let pkg = {};
14770
+ try {
14771
+ pkg = await fs42.readJson(pkgPath);
14772
+ } catch {
14773
+ return stack;
14774
+ }
14775
+ const allDeps = {
14776
+ ...pkg.dependencies ?? {},
14777
+ ...pkg.devDependencies ?? {}
14778
+ };
14779
+ const depKeys = new Set(Object.keys(allDeps));
14780
+ for (const dep of KEY_DEPS) {
14781
+ if (depKeys.has(dep)) stack.push(dep);
14782
+ }
14783
+ return stack;
14784
+ }
14785
+ async function discoverProjects(rootDir, maxDepth) {
14786
+ const found = [];
14787
+ async function walk(absDir, depth) {
14788
+ if (depth > maxDepth) return;
14789
+ let entries;
14790
+ try {
14791
+ entries = await fs42.readdir(absDir, { withFileTypes: true });
14792
+ } catch {
14793
+ return;
14794
+ }
14795
+ for (const entry of entries) {
14796
+ if (!entry.isDirectory()) continue;
14797
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
14798
+ const childAbs = path41.join(absDir, entry.name);
14799
+ const gitPath = path41.join(childAbs, ".git");
14800
+ if (await fs42.pathExists(gitPath)) {
14801
+ const gitStat = await fs42.stat(gitPath);
14802
+ if (gitStat.isFile()) continue;
14803
+ }
14804
+ if (await isProjectRoot(childAbs)) {
14805
+ found.push(path41.relative(rootDir, childAbs));
14806
+ } else {
14807
+ await walk(childAbs, depth + 1);
14808
+ }
14809
+ }
14810
+ }
14811
+ await walk(rootDir, 0);
14812
+ return found;
14813
+ }
14814
+ async function loadIndex(scanRoot) {
14815
+ const filePath = path41.join(scanRoot, INDEX_FILE);
14816
+ try {
14817
+ return await fs42.readJson(filePath);
14818
+ } catch {
14819
+ return null;
14820
+ }
14821
+ }
14822
+ async function saveIndex(scanRoot, index) {
14823
+ const filePath = path41.join(scanRoot, INDEX_FILE);
14824
+ await fs42.writeJson(filePath, index, { spaces: 2 });
14825
+ return filePath;
14826
+ }
14827
+ async function runScan(scanRoot, maxDepth = 2) {
14828
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14829
+ const existing = await loadIndex(scanRoot);
14830
+ const existingMap = new Map(
14831
+ (existing?.projects ?? []).map((p) => [p.path, p])
14832
+ );
14833
+ const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
14834
+ const added = [];
14835
+ const updated = [];
14836
+ const unchanged = [];
14837
+ const seenPaths = /* @__PURE__ */ new Set();
14838
+ for (const relPath of discoveredPaths) {
14839
+ const absPath = path41.join(scanRoot, relPath);
14840
+ seenPaths.add(relPath);
14841
+ const { type, role } = await detectRepoType(absPath);
14842
+ const techStack = await extractTechStack(absPath, type);
14843
+ const hasConstitution = await fs42.pathExists(path41.join(absPath, CONSTITUTION_FILE));
14844
+ const hasWorkspace = await fs42.pathExists(path41.join(absPath, WORKSPACE_CONFIG_FILE));
14845
+ const name = path41.basename(relPath);
14846
+ const prev = existingMap.get(relPath);
14847
+ if (!prev) {
14848
+ const entry = {
14849
+ name,
14850
+ path: relPath,
14851
+ type,
14852
+ role,
14853
+ techStack,
14854
+ hasConstitution,
14855
+ hasWorkspace,
14856
+ firstSeen: now,
14857
+ lastSeen: now
14858
+ };
14859
+ added.push(entry);
14860
+ existingMap.set(relPath, entry);
14861
+ } else {
14862
+ const changed = prev.type !== type || prev.role !== role || prev.hasConstitution !== hasConstitution || prev.hasWorkspace !== hasWorkspace || JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
14863
+ const entry = {
14864
+ ...prev,
14865
+ type,
14866
+ role,
14867
+ techStack,
14868
+ hasConstitution,
14869
+ hasWorkspace,
14870
+ lastSeen: now,
14871
+ missing: void 0
14872
+ // clear missing flag if it came back
14873
+ };
14874
+ existingMap.set(relPath, entry);
14875
+ if (changed) {
14876
+ updated.push(entry);
14877
+ } else {
14878
+ unchanged.push(entry);
14879
+ }
14880
+ }
14881
+ }
14882
+ const nowMissing = [];
14883
+ for (const [relPath, entry] of existingMap) {
14884
+ if (!seenPaths.has(relPath) && !entry.missing) {
14885
+ const gone = { ...entry, missing: true };
14886
+ existingMap.set(relPath, gone);
14887
+ nowMissing.push(gone);
14888
+ }
14889
+ }
14890
+ const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
14891
+ const index = {
14892
+ scanRoot,
14893
+ lastScanned: now,
14894
+ projects
14895
+ };
14896
+ return { index, added, updated, unchanged, nowMissing };
14897
+ }
14898
+
14899
+ // cli/commands/scan.ts
14391
14900
  var ROLE_COLOR = {
14392
14901
  backend: import_chalk44.default.blue,
14393
14902
  frontend: import_chalk44.default.green,
@@ -14461,7 +14970,7 @@ Scanning ${cwd} (depth: ${maxDepth})...`));
14461
14970
  }
14462
14971
  console.log(import_chalk44.default.cyan("\n\u2500".repeat(52)));
14463
14972
  console.log(import_chalk44.default.gray(" \xA7C = has constitution W = workspace root"));
14464
- console.log(import_chalk44.default.gray(` Index saved : ${path41.relative(cwd, path41.join(cwd, INDEX_FILE))}`));
14973
+ console.log(import_chalk44.default.gray(` Index saved : ${path42.relative(cwd, path42.join(cwd, INDEX_FILE))}`));
14465
14974
  console.log(import_chalk44.default.gray(` Next steps : ai-spec scan --list | ai-spec init [--global]`));
14466
14975
  });
14467
14976
  }
@@ -14469,7 +14978,7 @@ Scanning ${cwd} (depth: ${maxDepth})...`));
14469
14978
  // cli/index.ts
14470
14979
  dotenv.config();
14471
14980
  var program = new import_commander.Command();
14472
- program.name("ai-spec").description("AI-driven Development Orchestrator \u2014 spec, generate, review").version("0.14.1");
14981
+ program.name("ai-spec").description("AI-driven Development Orchestrator \u2014 spec, generate, review").version(require_package().version);
14473
14982
  registerCreate(program);
14474
14983
  registerReview(program);
14475
14984
  registerInit(program);