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.
@@ -4,6 +4,9 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
4
4
  var __esm = (fn, res) => function __init() {
5
5
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
6
  };
7
+ var __commonJS = (cb, mod) => function __require() {
8
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
+ };
7
10
  var __export = (target, all) => {
8
11
  for (var name in all)
9
12
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -126,8 +129,101 @@ CRITICAL \u2014 \u5386\u53F2\u6559\u8BAD\u5E94\u7528\uFF08Accumulated Lessons\uF
126
129
  }
127
130
  });
128
131
 
129
- // core/provider-utils.ts
132
+ // core/cli-ui.ts
130
133
  import chalk from "chalk";
134
+ function startSpinner(text) {
135
+ const isTTY = process.stderr.isTTY;
136
+ let frame = 0;
137
+ let currentText = text;
138
+ let stopped = false;
139
+ function render() {
140
+ if (stopped) return;
141
+ const symbol = chalk.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
142
+ if (isTTY) {
143
+ process.stderr.write(`\r ${symbol} ${currentText}${" ".repeat(10)}`);
144
+ }
145
+ frame++;
146
+ }
147
+ if (!isTTY) {
148
+ process.stderr.write(` \u2026 ${currentText}
149
+ `);
150
+ }
151
+ const timer = setInterval(render, 80);
152
+ render();
153
+ return {
154
+ update(newText) {
155
+ currentText = newText;
156
+ },
157
+ stop(finalText) {
158
+ if (stopped) return;
159
+ stopped = true;
160
+ clearInterval(timer);
161
+ if (isTTY) {
162
+ process.stderr.write(`\r${" ".repeat(currentText.length + 20)}\r`);
163
+ }
164
+ if (finalText) {
165
+ process.stderr.write(` ${finalText}
166
+ `);
167
+ }
168
+ },
169
+ succeed(successText) {
170
+ this.stop(chalk.green(`\u2714 ${successText}`));
171
+ },
172
+ fail(failText) {
173
+ this.stop(chalk.red(`\u2718 ${failText}`));
174
+ }
175
+ };
176
+ }
177
+ async function retryCountdown(opts) {
178
+ const { attempt, maxAttempts, waitMs, errorMessage, label } = opts;
179
+ const isTTY = process.stderr.isTTY;
180
+ const shortErr = errorMessage.length > 120 ? errorMessage.slice(0, 117) + "..." : errorMessage;
181
+ process.stderr.write("\n");
182
+ process.stderr.write(chalk.yellow(` \u250C\u2500 Retry ${attempt}/${maxAttempts} `) + chalk.gray(`[${label}]`) + chalk.yellow(` ${"\u2500".repeat(Math.max(1, 40 - label.length))}
183
+ `));
184
+ process.stderr.write(chalk.yellow(` \u2502 `) + chalk.white(shortErr) + "\n");
185
+ process.stderr.write(chalk.yellow(` \u2502 `) + chalk.gray(`Waiting before retry...`) + "\n");
186
+ const totalSeconds = Math.ceil(waitMs / 1e3);
187
+ for (let s = totalSeconds; s > 0; s--) {
188
+ const bar = chalk.green("\u2588".repeat(totalSeconds - s)) + chalk.gray("\u2591".repeat(s));
189
+ const line = chalk.yellow(` \u2502 `) + `${bar} ${chalk.bold.white(`${s}s`)}`;
190
+ if (isTTY) {
191
+ process.stderr.write(`\r${line}${" ".repeat(10)}`);
192
+ }
193
+ await new Promise((r) => setTimeout(r, 1e3));
194
+ }
195
+ if (isTTY) {
196
+ process.stderr.write(`\r${" ".repeat(70)}\r`);
197
+ }
198
+ process.stderr.write(chalk.yellow(` \u2514\u2500 `) + chalk.cyan(`Retrying now...`) + "\n\n");
199
+ }
200
+ function startStage(stageKey, label) {
201
+ const icon = STAGE_ICONS[stageKey] ?? "\u25B8";
202
+ return startSpinner(`${icon} ${label}`);
203
+ }
204
+ var SPINNER_FRAMES, STAGE_ICONS;
205
+ var init_cli_ui = __esm({
206
+ "core/cli-ui.ts"() {
207
+ "use strict";
208
+ SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
209
+ STAGE_ICONS = {
210
+ context_load: "\u{1F4C2}",
211
+ design_dialogue: "\u{1F4AC}",
212
+ spec_gen: "\u{1F4DD}",
213
+ spec_refine: "\u270F\uFE0F ",
214
+ spec_assess: "\u{1F4CA}",
215
+ dsl_extract: "\u{1F517}",
216
+ dsl_gap_feedback: "\u{1F50D}",
217
+ codegen: "\u2699\uFE0F ",
218
+ test_gen: "\u{1F9EA}",
219
+ error_feedback: "\u{1F527}",
220
+ review: "\u{1F50E}",
221
+ self_eval: "\u{1F4C8}"
222
+ };
223
+ }
224
+ });
225
+
226
+ // core/provider-utils.ts
131
227
  function classifyError(err, label) {
132
228
  const e = err;
133
229
  const status = e.status ?? e.response?.status;
@@ -203,20 +299,23 @@ async function withReliability(fn, opts) {
203
299
  throw classifyError(err, label);
204
300
  }
205
301
  const waitMs = attempt === 0 ? 2e3 : 6e3;
206
- console.warn(
207
- chalk.yellow(` \u26A0 ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${waitMs / 1e3}s`) + chalk.gray(` \u2014 ${err.message}`)
208
- );
209
302
  onRetry?.(attempt + 1, err);
210
- await sleep(waitMs);
303
+ await retryCountdown({
304
+ attempt: attempt + 1,
305
+ maxAttempts: retries + 1,
306
+ waitMs,
307
+ errorMessage: err.message ?? String(err),
308
+ label
309
+ });
211
310
  }
212
311
  }
213
312
  throw new Error("unreachable");
214
313
  }
215
- var sleep, ProviderError;
314
+ var ProviderError;
216
315
  var init_provider_utils = __esm({
217
316
  "core/provider-utils.ts"() {
218
317
  "use strict";
219
- sleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
318
+ init_cli_ui();
220
319
  ProviderError = class extends Error {
221
320
  constructor(message, kind, originalError) {
222
321
  super(message);
@@ -245,7 +344,6 @@ __export(spec_generator_exports, {
245
344
  import { GoogleGenerativeAI } from "@google/generative-ai";
246
345
  import Anthropic from "@anthropic-ai/sdk";
247
346
  import OpenAI from "openai";
248
- import axios from "axios";
249
347
  import { ProxyAgent } from "undici";
250
348
  function geminiRequestOptions() {
251
349
  const proxyUrl = process.env.GEMINI_PROXY || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
@@ -291,7 +389,9 @@ var init_spec_generator = __esm({
291
389
  displayName: "MiMo (Xiaomi)",
292
390
  description: "\u5C0F\u7C73 MiMo \u2014 mimo-v2-pro (Anthropic-compatible API)",
293
391
  models: ["mimo-v2-pro"],
294
- envKey: "MIMO_API_KEY"
392
+ envKey: "MIMO_API_KEY",
393
+ // Fallback env var — MiMo's token plan uses ANTHROPIC_AUTH_TOKEN
394
+ fallbackEnvKeys: ["ANTHROPIC_AUTH_TOKEN"]
295
395
  // baseURL not used — MiMo has a dedicated provider class
296
396
  },
297
397
  gemini: {
@@ -460,8 +560,8 @@ var init_spec_generator = __esm({
460
560
  ...systemInstruction ? { system: systemInstruction } : {},
461
561
  messages: [{ role: "user", content: prompt }]
462
562
  });
463
- const block = message.content[0];
464
- if (block.type === "text") return block.text;
563
+ const textBlock = message.content.find((b) => b.type === "text");
564
+ if (textBlock) return textBlock.text;
465
565
  throw new Error("Unexpected response type from Claude API");
466
566
  },
467
567
  { label: `${this.providerName}/${this.modelName}` }
@@ -506,43 +606,29 @@ var init_spec_generator = __esm({
506
606
  }
507
607
  };
508
608
  MiMoProvider = class {
609
+ client;
509
610
  providerName = "mimo";
510
611
  modelName;
511
- apiKey;
512
- baseUrl = "https://api.xiaomimimo.com/anthropic/v1/messages";
513
612
  constructor(apiKey, modelName = PROVIDER_CATALOG.mimo.models[0]) {
514
- this.apiKey = apiKey;
613
+ const baseURL = process.env["MIMO_BASE_URL"] || process.env["ANTHROPIC_BASE_URL"] || "https://token-plan-cn.xiaomimimo.com/anthropic";
614
+ this.client = new Anthropic({ apiKey, baseURL });
515
615
  this.modelName = modelName;
516
616
  }
517
617
  async generate(prompt, systemInstruction) {
518
618
  return withReliability(
519
619
  async () => {
520
- const body = {
620
+ const stream = this.client.messages.stream({
521
621
  model: this.modelName,
522
- max_tokens: 16384,
523
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
524
- top_p: 0.95,
525
- stream: false,
526
- temperature: 1,
527
- stop_sequences: null
528
- };
529
- if (systemInstruction) {
530
- body.system = systemInstruction;
531
- }
532
- const response = await axios.post(this.baseUrl, body, {
533
- headers: {
534
- "api-key": this.apiKey,
535
- "Content-Type": "application/json"
536
- }
622
+ max_tokens: 65536,
623
+ ...systemInstruction ? { system: systemInstruction } : {},
624
+ messages: [{ role: "user", content: prompt }]
537
625
  });
538
- const data = response.data;
539
- const blocks = data?.content ?? [];
540
- const textBlock = blocks.find((b) => b.type === "text");
541
- if (textBlock?.text) return textBlock.text;
542
- if (data?.stop_reason === "max_tokens") {
543
- 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.`);
544
- }
545
- throw new Error(`Unexpected MiMo response: ${JSON.stringify(response.data).slice(0, 200)}`);
626
+ const message = await stream.finalMessage();
627
+ const textBlock = message.content.find((b) => b.type === "text");
628
+ if (textBlock) return textBlock.text;
629
+ const thinkBlock = message.content.find((b) => b.type === "thinking");
630
+ if (thinkBlock) return thinkBlock.thinking;
631
+ return message.content.map((b) => b.text ?? "").join("");
546
632
  },
547
633
  { label: `${this.providerName}/${this.modelName}` }
548
634
  );
@@ -603,14 +689,71 @@ ${context.schema.slice(0, 3e3)}`);
603
689
  }
604
690
  });
605
691
 
692
+ // package.json
693
+ var require_package = __commonJS({
694
+ "package.json"(exports, module) {
695
+ module.exports = {
696
+ name: "ai-spec-dev",
697
+ version: "0.46.0",
698
+ description: "AI-driven Development Orchestrator SDK & CLI",
699
+ main: "dist/index.js",
700
+ types: "dist/index.d.ts",
701
+ bin: {
702
+ "ai-spec": "dist/cli/index.js"
703
+ },
704
+ scripts: {
705
+ build: "tsup",
706
+ dev: "tsup --watch",
707
+ test: "vitest run",
708
+ "test:watch": "vitest"
709
+ },
710
+ keywords: [
711
+ "ai",
712
+ "spec",
713
+ "codegen",
714
+ "gemini",
715
+ "claude"
716
+ ],
717
+ author: "",
718
+ license: "MIT",
719
+ dependencies: {
720
+ "@anthropic-ai/sdk": "^0.38.0",
721
+ "@google/generative-ai": "^0.21.0",
722
+ "@inquirer/editor": "^5.0.10",
723
+ "@inquirer/prompts": "^8.3.2",
724
+ "@rollup/rollup-darwin-arm64": "^4.60.1",
725
+ axios: "^1.13.6",
726
+ chalk: "^4.1.2",
727
+ commander: "^13.1.0",
728
+ dotenv: "^16.4.7",
729
+ "fs-extra": "^11.3.0",
730
+ openai: "^6.31.0",
731
+ undici: "^7.24.4"
732
+ },
733
+ devDependencies: {
734
+ "@types/fs-extra": "^11.0.4",
735
+ "@types/glob": "^8.1.0",
736
+ "@types/node": "^22.13.5",
737
+ glob: "^13.0.6",
738
+ "ts-node": "^10.9.2",
739
+ tsup: "^8.4.0",
740
+ typescript: "^5.7.3",
741
+ vitest: "^2.1.0"
742
+ }
743
+ };
744
+ }
745
+ });
746
+
606
747
  // cli/index.ts
607
748
  import { Command } from "commander";
608
749
  import * as dotenv from "dotenv";
609
750
 
610
751
  // cli/commands/create.ts
611
752
  init_spec_generator();
753
+ import * as path26 from "path";
754
+ import * as fs27 from "fs-extra";
612
755
  import chalk26 from "chalk";
613
- import { input as input2 } from "@inquirer/prompts";
756
+ import { input as input2, select as select7, checkbox } from "@inquirer/prompts";
614
757
 
615
758
  // core/workspace-loader.ts
616
759
  import * as fs from "fs-extra";
@@ -710,8 +853,8 @@ var WorkspaceLoader = class {
710
853
  const repos = [];
711
854
  for (const entry of entries) {
712
855
  const absPath = path.join(this.workspaceRoot, entry);
713
- const stat4 = await fs.stat(absPath).catch(() => null);
714
- if (!stat4 || !stat4.isDirectory()) continue;
856
+ const stat5 = await fs.stat(absPath).catch(() => null);
857
+ if (!stat5 || !stat5.isDirectory()) continue;
715
858
  if (entry.startsWith(".") || entry === "node_modules") continue;
716
859
  if (names && !names.includes(entry)) continue;
717
860
  const hasManifest = await fs.pathExists(path.join(absPath, "package.json")) || await fs.pathExists(path.join(absPath, "go.mod")) || await fs.pathExists(path.join(absPath, "Cargo.toml")) || await fs.pathExists(path.join(absPath, "pom.xml")) || await fs.pathExists(path.join(absPath, "build.gradle")) || await fs.pathExists(path.join(absPath, "requirements.txt")) || await fs.pathExists(path.join(absPath, "pyproject.toml")) || await fs.pathExists(path.join(absPath, "composer.json"));
@@ -775,6 +918,7 @@ var WorkspaceLoader = class {
775
918
  init_spec_generator();
776
919
  import * as path3 from "path";
777
920
  import * as fs3 from "fs-extra";
921
+ import * as os2 from "os";
778
922
  import chalk2 from "chalk";
779
923
  import { input, select } from "@inquirer/prompts";
780
924
 
@@ -820,17 +964,42 @@ async function clearKey(provider) {
820
964
 
821
965
  // cli/utils.ts
822
966
  var CONFIG_FILE = ".ai-spec.json";
967
+ var GLOBAL_CONFIG_FILE = path3.join(os2.homedir(), ".ai-spec-config.json");
968
+ async function loadGlobalConfig() {
969
+ try {
970
+ if (await fs3.pathExists(GLOBAL_CONFIG_FILE)) {
971
+ return await fs3.readJson(GLOBAL_CONFIG_FILE);
972
+ }
973
+ } catch {
974
+ }
975
+ return {};
976
+ }
977
+ async function saveGlobalConfig(config2) {
978
+ await fs3.ensureFile(GLOBAL_CONFIG_FILE);
979
+ await fs3.writeJson(GLOBAL_CONFIG_FILE, config2, { spaces: 2 });
980
+ }
823
981
  async function loadConfig(dir) {
982
+ const globalConfig = await loadGlobalConfig();
983
+ let localConfig = {};
824
984
  const p = path3.join(dir, CONFIG_FILE);
825
985
  if (await fs3.pathExists(p)) {
826
- return fs3.readJson(p);
986
+ try {
987
+ localConfig = await fs3.readJson(p);
988
+ } catch {
989
+ }
827
990
  }
828
- return {};
991
+ return { ...globalConfig, ...localConfig };
829
992
  }
830
993
  async function resolveApiKey(providerName, cliKey) {
831
994
  if (cliKey) return cliKey;
832
995
  const envVar = ENV_KEY_MAP[providerName];
833
996
  if (envVar && process.env[envVar]) return process.env[envVar];
997
+ const meta = PROVIDER_CATALOG[providerName];
998
+ if (meta?.fallbackEnvKeys) {
999
+ for (const key of meta.fallbackEnvKeys) {
1000
+ if (process.env[key]) return process.env[key];
1001
+ }
1002
+ }
834
1003
  const savedKey = await getSavedKey(providerName);
835
1004
  if (savedKey) {
836
1005
  const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);
@@ -904,7 +1073,7 @@ var pe = "\0PERIOD" + Math.random() + "\0";
904
1073
  var is = new RegExp(fe, "g");
905
1074
  var rs = new RegExp(ue, "g");
906
1075
  var ns = new RegExp(qt, "g");
907
- var os2 = new RegExp(de, "g");
1076
+ var os3 = new RegExp(de, "g");
908
1077
  var hs = new RegExp(pe, "g");
909
1078
  var as = /\\\\/g;
910
1079
  var ls = /\\{/g;
@@ -919,7 +1088,7 @@ function ps(n7) {
919
1088
  return n7.replace(as, fe).replace(ls, ue).replace(cs, qt).replace(fs4, de).replace(us, pe);
920
1089
  }
921
1090
  function ms(n7) {
922
- return n7.replace(is, "\\").replace(rs, "{").replace(ns, "}").replace(os2, ",").replace(hs, ".");
1091
+ return n7.replace(is, "\\").replace(rs, "{").replace(ns, "}").replace(os3, ",").replace(hs, ".");
923
1092
  }
924
1093
  function me(n7) {
925
1094
  if (!n7) return [""];
@@ -3849,11 +4018,11 @@ Ze.glob = Ze;
3849
4018
  // core/global-constitution.ts
3850
4019
  import * as fs5 from "fs-extra";
3851
4020
  import * as path4 from "path";
3852
- import * as os3 from "os";
4021
+ import * as os4 from "os";
3853
4022
  var GLOBAL_CONSTITUTION_FILE = ".ai-spec-global-constitution.md";
3854
4023
  var SEARCH_ROOTS = [
3855
4024
  // Workspace root is injected at runtime — see loadGlobalConstitution()
3856
- os3.homedir()
4025
+ os4.homedir()
3857
4026
  ];
3858
4027
  async function loadGlobalConstitution(extraRoots = []) {
3859
4028
  const roots = [...extraRoots, ...SEARCH_ROOTS];
@@ -3882,7 +4051,7 @@ function mergeConstitutions(globalContent, projectContent) {
3882
4051
  }
3883
4052
  return parts.join("\n");
3884
4053
  }
3885
- async function saveGlobalConstitution(content, targetDir = os3.homedir()) {
4054
+ async function saveGlobalConstitution(content, targetDir = os4.homedir()) {
3886
4055
  const filePath = path4.join(targetDir, GLOBAL_CONSTITUTION_FILE);
3887
4056
  await fs5.writeFile(filePath, content, "utf-8");
3888
4057
  return filePath;
@@ -4915,32 +5084,32 @@ function validateDsl(raw) {
4915
5084
  }
4916
5085
  return { valid: true, dsl: raw };
4917
5086
  }
4918
- function validateFeature(raw, path42, errors) {
5087
+ function validateFeature(raw, path43, errors) {
4919
5088
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4920
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5089
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
4921
5090
  return;
4922
5091
  }
4923
5092
  const f = raw;
4924
- requireNonEmptyString(f["id"], `${path42}.id`, errors);
4925
- requireNonEmptyString(f["title"], `${path42}.title`, errors);
4926
- requireNonEmptyString(f["description"], `${path42}.description`, errors);
5093
+ requireNonEmptyString(f["id"], `${path43}.id`, errors);
5094
+ requireNonEmptyString(f["title"], `${path43}.title`, errors);
5095
+ requireNonEmptyString(f["description"], `${path43}.description`, errors);
4927
5096
  }
4928
- function validateModel(raw, path42, errors) {
5097
+ function validateModel(raw, path43, errors) {
4929
5098
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4930
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5099
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
4931
5100
  return;
4932
5101
  }
4933
5102
  const m = raw;
4934
- requireNonEmptyString(m["name"], `${path42}.name`, errors);
5103
+ requireNonEmptyString(m["name"], `${path43}.name`, errors);
4935
5104
  if (!Array.isArray(m["fields"])) {
4936
- errors.push({ path: `${path42}.fields`, message: `Must be an array, got: ${typeLabel(m["fields"])}` });
5105
+ errors.push({ path: `${path43}.fields`, message: `Must be an array, got: ${typeLabel(m["fields"])}` });
4937
5106
  } else {
4938
5107
  const fields = m["fields"];
4939
5108
  if (fields.length > MAX_FIELDS_PER_MODEL) {
4940
- errors.push({ path: `${path42}.fields`, message: `Too many fields (${fields.length} > ${MAX_FIELDS_PER_MODEL})` });
5109
+ errors.push({ path: `${path43}.fields`, message: `Too many fields (${fields.length} > ${MAX_FIELDS_PER_MODEL})` });
4941
5110
  }
4942
5111
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
4943
- validateModelField(fields[j2], `${path42}.fields[${j2}]`, errors);
5112
+ validateModelField(fields[j2], `${path43}.fields[${j2}]`, errors);
4944
5113
  }
4945
5114
  const seenFieldNames = /* @__PURE__ */ new Set();
4946
5115
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
@@ -4949,7 +5118,7 @@ function validateModel(raw, path42, errors) {
4949
5118
  const name = f["name"];
4950
5119
  if (seenFieldNames.has(name)) {
4951
5120
  errors.push({
4952
- path: `${path42}.fields[${j2}].name`,
5121
+ path: `${path43}.fields[${j2}].name`,
4953
5122
  message: `Duplicate field name "${name}" \u2014 each field within a model must have a unique name`
4954
5123
  });
4955
5124
  } else {
@@ -4960,184 +5129,184 @@ function validateModel(raw, path42, errors) {
4960
5129
  }
4961
5130
  if (m["relations"] !== void 0) {
4962
5131
  if (!Array.isArray(m["relations"])) {
4963
- errors.push({ path: `${path42}.relations`, message: "Must be an array of strings if present" });
5132
+ errors.push({ path: `${path43}.relations`, message: "Must be an array of strings if present" });
4964
5133
  } else {
4965
5134
  const rels = m["relations"];
4966
5135
  for (let j2 = 0; j2 < rels.length; j2++) {
4967
5136
  if (typeof rels[j2] !== "string") {
4968
- errors.push({ path: `${path42}.relations[${j2}]`, message: "Must be a string" });
5137
+ errors.push({ path: `${path43}.relations[${j2}]`, message: "Must be a string" });
4969
5138
  }
4970
5139
  }
4971
5140
  }
4972
5141
  }
4973
5142
  }
4974
- function validateModelField(raw, path42, errors) {
5143
+ function validateModelField(raw, path43, errors) {
4975
5144
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4976
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5145
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
4977
5146
  return;
4978
5147
  }
4979
5148
  const f = raw;
4980
- requireNonEmptyString(f["name"], `${path42}.name`, errors);
4981
- requireNonEmptyString(f["type"], `${path42}.type`, errors);
5149
+ requireNonEmptyString(f["name"], `${path43}.name`, errors);
5150
+ requireNonEmptyString(f["type"], `${path43}.type`, errors);
4982
5151
  if (typeof f["required"] !== "boolean") {
4983
- errors.push({ path: `${path42}.required`, message: `Must be boolean, got: ${typeLabel(f["required"])}` });
5152
+ errors.push({ path: `${path43}.required`, message: `Must be boolean, got: ${typeLabel(f["required"])}` });
4984
5153
  }
4985
5154
  }
4986
- function validateEndpoint(raw, path42, errors) {
5155
+ function validateEndpoint(raw, path43, errors) {
4987
5156
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4988
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5157
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
4989
5158
  return;
4990
5159
  }
4991
5160
  const e = raw;
4992
- requireNonEmptyString(e["id"], `${path42}.id`, errors);
4993
- requireNonEmptyString(e["description"], `${path42}.description`, errors);
5161
+ requireNonEmptyString(e["id"], `${path43}.id`, errors);
5162
+ requireNonEmptyString(e["description"], `${path43}.description`, errors);
4994
5163
  if (!VALID_METHODS.includes(e["method"])) {
4995
5164
  errors.push({
4996
- path: `${path42}.method`,
5165
+ path: `${path43}.method`,
4997
5166
  message: `Must be one of ${VALID_METHODS.join("|")}, got: ${JSON.stringify(e["method"])}`
4998
5167
  });
4999
5168
  }
5000
5169
  if (typeof e["path"] !== "string" || !e["path"].startsWith("/")) {
5001
5170
  errors.push({
5002
- path: `${path42}.path`,
5171
+ path: `${path43}.path`,
5003
5172
  message: `Must be a string starting with "/", got: ${JSON.stringify(e["path"])}`
5004
5173
  });
5005
5174
  }
5006
5175
  if (typeof e["auth"] !== "boolean") {
5007
- errors.push({ path: `${path42}.auth`, message: `Must be boolean, got: ${typeLabel(e["auth"])}` });
5176
+ errors.push({ path: `${path43}.auth`, message: `Must be boolean, got: ${typeLabel(e["auth"])}` });
5008
5177
  }
5009
5178
  if (typeof e["successStatus"] !== "number" || e["successStatus"] < 100 || e["successStatus"] > 599) {
5010
5179
  errors.push({
5011
- path: `${path42}.successStatus`,
5180
+ path: `${path43}.successStatus`,
5012
5181
  message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["successStatus"])}`
5013
5182
  });
5014
5183
  }
5015
- requireNonEmptyString(e["successDescription"], `${path42}.successDescription`, errors);
5184
+ requireNonEmptyString(e["successDescription"], `${path43}.successDescription`, errors);
5016
5185
  if (e["request"] !== void 0) {
5017
- validateRequestSchema(e["request"], `${path42}.request`, errors);
5186
+ validateRequestSchema(e["request"], `${path43}.request`, errors);
5018
5187
  }
5019
5188
  if (e["errors"] !== void 0) {
5020
5189
  if (!Array.isArray(e["errors"])) {
5021
- errors.push({ path: `${path42}.errors`, message: "Must be an array if present" });
5190
+ errors.push({ path: `${path43}.errors`, message: "Must be an array if present" });
5022
5191
  } else {
5023
5192
  const errs = e["errors"];
5024
5193
  if (errs.length > MAX_ERRORS_PER_ENDPOINT) {
5025
- errors.push({ path: `${path42}.errors`, message: `Too many error entries (${errs.length} > ${MAX_ERRORS_PER_ENDPOINT})` });
5194
+ errors.push({ path: `${path43}.errors`, message: `Too many error entries (${errs.length} > ${MAX_ERRORS_PER_ENDPOINT})` });
5026
5195
  }
5027
5196
  for (let j2 = 0; j2 < Math.min(errs.length, MAX_ERRORS_PER_ENDPOINT); j2++) {
5028
- validateResponseError(errs[j2], `${path42}.errors[${j2}]`, errors);
5197
+ validateResponseError(errs[j2], `${path43}.errors[${j2}]`, errors);
5029
5198
  }
5030
5199
  }
5031
5200
  }
5032
5201
  }
5033
- function validateRequestSchema(raw, path42, errors) {
5202
+ function validateRequestSchema(raw, path43, errors) {
5034
5203
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5035
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5204
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5036
5205
  return;
5037
5206
  }
5038
5207
  const r = raw;
5039
5208
  for (const key of ["body", "query", "params"]) {
5040
5209
  if (r[key] !== void 0) {
5041
- validateFieldMap(r[key], `${path42}.${key}`, errors);
5210
+ validateFieldMap(r[key], `${path43}.${key}`, errors);
5042
5211
  }
5043
5212
  }
5044
5213
  }
5045
- function validateFieldMap(raw, path42, errors) {
5214
+ function validateFieldMap(raw, path43, errors) {
5046
5215
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5047
- errors.push({ path: path42, message: `Must be a flat object (FieldMap), got: ${typeLabel(raw)}` });
5216
+ errors.push({ path: path43, message: `Must be a flat object (FieldMap), got: ${typeLabel(raw)}` });
5048
5217
  return;
5049
5218
  }
5050
5219
  const map = raw;
5051
5220
  for (const [k2, v2] of Object.entries(map)) {
5052
5221
  if (typeof v2 !== "string") {
5053
- errors.push({ path: `${path42}.${k2}`, message: `Value must be a type-description string, got: ${typeLabel(v2)}` });
5222
+ errors.push({ path: `${path43}.${k2}`, message: `Value must be a type-description string, got: ${typeLabel(v2)}` });
5054
5223
  }
5055
5224
  }
5056
5225
  }
5057
- function validateResponseError(raw, path42, errors) {
5226
+ function validateResponseError(raw, path43, errors) {
5058
5227
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5059
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5228
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5060
5229
  return;
5061
5230
  }
5062
5231
  const e = raw;
5063
5232
  if (typeof e["status"] !== "number" || e["status"] < 100 || e["status"] > 599) {
5064
- errors.push({ path: `${path42}.status`, message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["status"])}` });
5233
+ errors.push({ path: `${path43}.status`, message: `Must be an HTTP status code (100-599), got: ${JSON.stringify(e["status"])}` });
5065
5234
  }
5066
- requireNonEmptyString(e["code"], `${path42}.code`, errors);
5067
- requireNonEmptyString(e["description"], `${path42}.description`, errors);
5235
+ requireNonEmptyString(e["code"], `${path43}.code`, errors);
5236
+ requireNonEmptyString(e["description"], `${path43}.description`, errors);
5068
5237
  }
5069
- function validateBehavior(raw, path42, errors) {
5238
+ function validateBehavior(raw, path43, errors) {
5070
5239
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
5071
- errors.push({ path: path42, message: `Must be an object, got: ${typeLabel(raw)}` });
5240
+ errors.push({ path: path43, message: `Must be an object, got: ${typeLabel(raw)}` });
5072
5241
  return;
5073
5242
  }
5074
5243
  const b = raw;
5075
- requireNonEmptyString(b["id"], `${path42}.id`, errors);
5076
- requireNonEmptyString(b["description"], `${path42}.description`, errors);
5244
+ requireNonEmptyString(b["id"], `${path43}.id`, errors);
5245
+ requireNonEmptyString(b["description"], `${path43}.description`, errors);
5077
5246
  if (b["constraints"] !== void 0) {
5078
5247
  if (!Array.isArray(b["constraints"])) {
5079
- errors.push({ path: `${path42}.constraints`, message: "Must be an array of strings if present" });
5248
+ errors.push({ path: `${path43}.constraints`, message: "Must be an array of strings if present" });
5080
5249
  } else {
5081
5250
  const cs2 = b["constraints"];
5082
5251
  for (let j2 = 0; j2 < cs2.length; j2++) {
5083
5252
  if (typeof cs2[j2] !== "string") {
5084
- errors.push({ path: `${path42}.constraints[${j2}]`, message: "Must be a string" });
5253
+ errors.push({ path: `${path43}.constraints[${j2}]`, message: "Must be a string" });
5085
5254
  }
5086
5255
  }
5087
5256
  }
5088
5257
  }
5089
5258
  }
5090
- function validateComponent(raw, path42, errors) {
5259
+ function validateComponent(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 c = raw;
5096
- requireNonEmptyString(c["id"], `${path42}.id`, errors);
5097
- requireNonEmptyString(c["name"], `${path42}.name`, errors);
5098
- requireNonEmptyString(c["description"], `${path42}.description`, errors);
5265
+ requireNonEmptyString(c["id"], `${path43}.id`, errors);
5266
+ requireNonEmptyString(c["name"], `${path43}.name`, errors);
5267
+ requireNonEmptyString(c["description"], `${path43}.description`, errors);
5099
5268
  if (c["props"] !== void 0) {
5100
5269
  if (!Array.isArray(c["props"])) {
5101
- errors.push({ path: `${path42}.props`, message: "Must be an array if present" });
5270
+ errors.push({ path: `${path43}.props`, message: "Must be an array if present" });
5102
5271
  } else {
5103
5272
  const props = c["props"];
5104
5273
  for (let j2 = 0; j2 < props.length; j2++) {
5105
5274
  const p = props[j2];
5106
5275
  if (typeof p !== "object" || p === null) {
5107
- errors.push({ path: `${path42}.props[${j2}]`, message: "Must be an object" });
5276
+ errors.push({ path: `${path43}.props[${j2}]`, message: "Must be an object" });
5108
5277
  continue;
5109
5278
  }
5110
- requireNonEmptyString(p["name"], `${path42}.props[${j2}].name`, errors);
5111
- requireNonEmptyString(p["type"], `${path42}.props[${j2}].type`, errors);
5279
+ requireNonEmptyString(p["name"], `${path43}.props[${j2}].name`, errors);
5280
+ requireNonEmptyString(p["type"], `${path43}.props[${j2}].type`, errors);
5112
5281
  if (typeof p["required"] !== "boolean") {
5113
- errors.push({ path: `${path42}.props[${j2}].required`, message: "Must be boolean" });
5282
+ errors.push({ path: `${path43}.props[${j2}].required`, message: "Must be boolean" });
5114
5283
  }
5115
5284
  }
5116
5285
  }
5117
5286
  }
5118
5287
  if (c["events"] !== void 0) {
5119
5288
  if (!Array.isArray(c["events"])) {
5120
- errors.push({ path: `${path42}.events`, message: "Must be an array if present" });
5289
+ errors.push({ path: `${path43}.events`, message: "Must be an array if present" });
5121
5290
  } else {
5122
5291
  const events = c["events"];
5123
5292
  for (let j2 = 0; j2 < events.length; j2++) {
5124
5293
  const e = events[j2];
5125
5294
  if (typeof e !== "object" || e === null) {
5126
- errors.push({ path: `${path42}.events[${j2}]`, message: "Must be an object" });
5295
+ errors.push({ path: `${path43}.events[${j2}]`, message: "Must be an object" });
5127
5296
  continue;
5128
5297
  }
5129
- requireNonEmptyString(e["name"], `${path42}.events[${j2}].name`, errors);
5298
+ requireNonEmptyString(e["name"], `${path43}.events[${j2}].name`, errors);
5130
5299
  }
5131
5300
  }
5132
5301
  }
5133
5302
  if (c["state"] !== void 0) {
5134
5303
  if (typeof c["state"] !== "object" || Array.isArray(c["state"]) || c["state"] === null) {
5135
- errors.push({ path: `${path42}.state`, message: "Must be a flat object (Record<string, string>) if present" });
5304
+ errors.push({ path: `${path43}.state`, message: "Must be a flat object (Record<string, string>) if present" });
5136
5305
  }
5137
5306
  }
5138
5307
  if (c["apiCalls"] !== void 0) {
5139
5308
  if (!Array.isArray(c["apiCalls"])) {
5140
- errors.push({ path: `${path42}.apiCalls`, message: "Must be an array of strings if present" });
5309
+ errors.push({ path: `${path43}.apiCalls`, message: "Must be an array of strings if present" });
5141
5310
  }
5142
5311
  }
5143
5312
  }
@@ -5199,10 +5368,10 @@ function crossReferenceChecks(obj, errors) {
5199
5368
  }
5200
5369
  }
5201
5370
  }
5202
- function requireNonEmptyString(v2, path42, errors) {
5371
+ function requireNonEmptyString(v2, path43, errors) {
5203
5372
  if (typeof v2 !== "string" || v2.trim().length === 0) {
5204
5373
  errors.push({
5205
- path: path42,
5374
+ path: path43,
5206
5375
  message: `Must be a non-empty string, got: ${typeLabel(v2)}`
5207
5376
  });
5208
5377
  }
@@ -5413,13 +5582,18 @@ Output ONLY the corrected JSON. No explanation.`;
5413
5582
 
5414
5583
  // core/token-budget.ts
5415
5584
  import chalk5 from "chalk";
5416
- var CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g;
5417
- function estimateTokens(text) {
5418
- if (!text) return 0;
5419
- const cjkCount = (text.match(CJK_RANGE) ?? []).length;
5420
- const nonCjkLength = text.length - cjkCount;
5421
- return Math.ceil(cjkCount + nonCjkLength / 4);
5422
- }
5585
+
5586
+ // core/config-defaults.ts
5587
+ var DEFAULT_LOG_DIR = ".ai-spec-logs";
5588
+ var DEFAULT_VCR_DIR = ".ai-spec-vcr";
5589
+ var DEFAULT_BACKUP_DIR = ".ai-spec-backup";
5590
+ var DEFAULT_REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
5591
+ var DEFAULT_OPENAPI_SERVER_URL = "http://localhost:3000";
5592
+ var DEFAULT_MAX_COMMAND_OUTPUT_CHARS = 3e4;
5593
+ var DEFAULT_MAX_FIX_FILE_CHARS = 6e4;
5594
+ var DEFAULT_DSL_MAX_RETRIES = 2;
5595
+ var DEFAULT_MAX_CONSTITUTION_CHARS = 4e3;
5596
+ var DEFAULT_MAX_REVIEW_FILE_CHARS = 3e3;
5423
5597
  var DEFAULT_TOKEN_BUDGETS = {
5424
5598
  gemini: 9e5,
5425
5599
  claude: 18e4,
@@ -5427,8 +5601,18 @@ var DEFAULT_TOKEN_BUDGETS = {
5427
5601
  deepseek: 6e4,
5428
5602
  default: 1e5
5429
5603
  };
5604
+
5605
+ // core/token-budget.ts
5606
+ var CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g;
5607
+ function estimateTokens(text) {
5608
+ if (!text) return 0;
5609
+ const cjkCount = (text.match(CJK_RANGE) ?? []).length;
5610
+ const nonCjkLength = text.length - cjkCount;
5611
+ return Math.ceil(cjkCount + nonCjkLength / 4);
5612
+ }
5613
+ var DEFAULT_TOKEN_BUDGETS2 = DEFAULT_TOKEN_BUDGETS;
5430
5614
  function getDefaultBudget(providerName) {
5431
- return DEFAULT_TOKEN_BUDGETS[providerName] ?? DEFAULT_TOKEN_BUDGETS.default;
5615
+ return DEFAULT_TOKEN_BUDGETS2[providerName] ?? DEFAULT_TOKEN_BUDGETS2.default;
5432
5616
  }
5433
5617
 
5434
5618
  // core/safe-json.ts
@@ -5499,7 +5683,7 @@ function sanitizeDsl(raw) {
5499
5683
  }
5500
5684
  return dsl;
5501
5685
  }
5502
- var MAX_RETRIES = 2;
5686
+ var MAX_RETRIES = DEFAULT_DSL_MAX_RETRIES;
5503
5687
  var DEFAULT_MAX_SPEC_CHARS = 12e3;
5504
5688
  function dslFilePath(specFilePath) {
5505
5689
  const dir = path7.dirname(specFilePath);
@@ -6223,12 +6407,11 @@ Shared component structure patterns:`);
6223
6407
  // core/run-snapshot.ts
6224
6408
  import * as fs10 from "fs-extra";
6225
6409
  import * as path9 from "path";
6226
- var BACKUP_DIR = ".ai-spec-backup";
6227
6410
  var RunSnapshot = class {
6228
6411
  constructor(workingDir, runId) {
6229
6412
  this.workingDir = workingDir;
6230
6413
  this.runId = runId;
6231
- this.backupRoot = path9.join(workingDir, BACKUP_DIR, runId);
6414
+ this.backupRoot = path9.join(workingDir, DEFAULT_BACKUP_DIR, runId);
6232
6415
  }
6233
6416
  backupRoot;
6234
6417
  snapshotted = /* @__PURE__ */ new Set();
@@ -6284,7 +6467,6 @@ function getActiveSnapshot() {
6284
6467
  import * as fs11 from "fs-extra";
6285
6468
  import * as path10 from "path";
6286
6469
  import chalk7 from "chalk";
6287
- var LOG_DIR = ".ai-spec-logs";
6288
6470
  function appendJsonlLine(filePath, record) {
6289
6471
  try {
6290
6472
  fs11.appendFileSync(filePath, JSON.stringify(record) + "\n");
@@ -6355,8 +6537,8 @@ var RunLogger = class {
6355
6537
  this.workingDir = workingDir;
6356
6538
  this.runId = runId;
6357
6539
  this.startMs = Date.now();
6358
- this.logPath = path10.join(workingDir, LOG_DIR, `${runId}.json`);
6359
- this.jsonlPath = path10.join(workingDir, LOG_DIR, `${runId}.jsonl`);
6540
+ this.logPath = path10.join(workingDir, DEFAULT_LOG_DIR, `${runId}.json`);
6541
+ this.jsonlPath = path10.join(workingDir, DEFAULT_LOG_DIR, `${runId}.jsonl`);
6360
6542
  this.log = {
6361
6543
  runId,
6362
6544
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6690,6 +6872,7 @@ function printTaskProgress(completed, total, task, mode) {
6690
6872
  }
6691
6873
 
6692
6874
  // core/code-generator.ts
6875
+ init_cli_ui();
6693
6876
  var CodeGenerator = class {
6694
6877
  constructor(provider, mode = "claude-code") {
6695
6878
  this.provider = provider;
@@ -7138,6 +7321,7 @@ ${spec}
7138
7321
  ${constitutionSection}
7139
7322
  === ${existingContent ? "Existing content (modify and return the complete file)" : "Create this file from scratch"} ===
7140
7323
  ${existingContent || "Output only the complete file content."}`;
7324
+ const fileSpinner = startSpinner(`${prefix}Generating ${chalk9.bold(item.file)}...`);
7141
7325
  try {
7142
7326
  const raw = await this.provider.generate(codePrompt, systemPrompt);
7143
7327
  const fileContent = stripCodeFences(raw);
@@ -7145,11 +7329,11 @@ ${existingContent || "Output only the complete file content."}`;
7145
7329
  await fs12.ensureDir(path11.dirname(fullPath));
7146
7330
  await fs12.writeFile(fullPath, fileContent, "utf-8");
7147
7331
  getActiveLogger()?.fileWritten(item.file);
7148
- console.log(`${prefix}${existingContent ? chalk9.yellow("~") : chalk9.green("+")} ${chalk9.bold(item.file)} ${chalk9.green("\u2714")}`);
7332
+ fileSpinner.succeed(`${existingContent ? chalk9.yellow("~") : chalk9.green("+")} ${chalk9.bold(item.file)}`);
7149
7333
  successCount++;
7150
7334
  writtenFiles.push(item.file);
7151
7335
  } catch (err) {
7152
- console.log(`${prefix}${chalk9.red("\u2718")} ${chalk9.bold(item.file)} \u2014 ${chalk9.red(err.message)}`);
7336
+ fileSpinner.fail(`${chalk9.bold(item.file)} \u2014 ${err.message}`);
7153
7337
  }
7154
7338
  }
7155
7339
  if (!taskLabel) {
@@ -7296,7 +7480,7 @@ ${context.routeSummary}
7296
7480
  }
7297
7481
  if (context.schema) {
7298
7482
  parts.push(`=== Prisma Schema ===
7299
- ${context.schema.slice(0, 4e3)}
7483
+ ${context.schema.slice(0, DEFAULT_MAX_CONSTITUTION_CHARS)}
7300
7484
  `);
7301
7485
  }
7302
7486
  if (context.errorPatterns) {
@@ -7344,7 +7528,7 @@ async function loadAccumulatedLessons(projectRoot) {
7344
7528
  const nextSection = section.slice(marker.length).match(/\n## \d/);
7345
7529
  return nextSection ? section.slice(0, marker.length + nextSection.index) : section;
7346
7530
  }
7347
- var REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
7531
+ var REVIEW_HISTORY_FILE = DEFAULT_REVIEW_HISTORY_FILE;
7348
7532
  async function loadReviewHistory(projectRoot) {
7349
7533
  const historyPath = path13.join(projectRoot, REVIEW_HISTORY_FILE);
7350
7534
  try {
@@ -7475,15 +7659,13 @@ ${specContent || "(No spec \u2014 review for general code quality)"}
7475
7659
  ${codeContext}`;
7476
7660
  const archReview = await this.provider.generate(archPrompt, reviewArchitectureSystemPrompt);
7477
7661
  console.log(chalk11.gray(" Pass 2/3: Implementation review..."));
7662
+ const specDigest = specContent && specContent.length > 600 ? specContent.slice(0, 600) + "\n... [spec truncated \u2014 see Pass 0/1 for full text]" : specContent || "(No spec)";
7478
7663
  const history = await loadReviewHistory(this.projectRoot);
7479
7664
  const historyContext = buildHistoryContext(history);
7480
7665
  const implPrompt = `Review the implementation details of this change.
7481
7666
 
7482
- === Feature Spec ===
7483
- ${specContent || "(No spec \u2014 review for general code quality)"}
7484
-
7485
- === Code ===
7486
- ${codeContext}
7667
+ === Feature Spec (digest \u2014 full spec was provided in Pass 0/1) ===
7668
+ ${specDigest}
7487
7669
 
7488
7670
  === Architecture Review (Pass 1 \u2014 do NOT repeat these findings) ===
7489
7671
  ${archReview}
@@ -7492,11 +7674,8 @@ ${historyContext}`;
7492
7674
  console.log(chalk11.gray(" Pass 3/3: Impact & complexity assessment..."));
7493
7675
  const impactPrompt = `Assess the impact and complexity of this change.
7494
7676
 
7495
- === Feature Spec ===
7496
- ${specContent || "(No spec \u2014 review for general code quality)"}
7497
-
7498
- === Code ===
7499
- ${codeContext}
7677
+ === Feature Spec (digest) ===
7678
+ ${specDigest}
7500
7679
 
7501
7680
  === Architecture Review (Pass 1 \u2014 do NOT repeat) ===
7502
7681
  ${archReview}
@@ -7570,8 +7749,8 @@ ${sep}
7570
7749
  filesSection += `
7571
7750
 
7572
7751
  === ${filePath} ===
7573
- ${content.slice(0, 3e3)}`;
7574
- if (content.length > 3e3) filesSection += `
7752
+ ${content.slice(0, DEFAULT_MAX_REVIEW_FILE_CHARS)}`;
7753
+ if (content.length > DEFAULT_MAX_REVIEW_FILE_CHARS) filesSection += `
7575
7754
  ... (truncated, ${content.length} chars total)`;
7576
7755
  } catch {
7577
7756
  filesSection += `
@@ -8175,8 +8354,9 @@ import chalk15 from "chalk";
8175
8354
  import { execSync as execSync5 } from "child_process";
8176
8355
  import * as fs18 from "fs-extra";
8177
8356
  import * as path17 from "path";
8178
- var MAX_COMMAND_OUTPUT_CHARS = 5e4;
8179
- var MAX_FIX_FILE_CHARS = 6e4;
8357
+ init_cli_ui();
8358
+ var MAX_COMMAND_OUTPUT_CHARS = DEFAULT_MAX_COMMAND_OUTPUT_CHARS;
8359
+ var MAX_FIX_FILE_CHARS = DEFAULT_MAX_FIX_FILE_CHARS;
8180
8360
  function runCommand(cmd, cwd) {
8181
8361
  try {
8182
8362
  const output = execSync5(cmd, { cwd, encoding: "utf-8", timeout: 6e4 });
@@ -8366,16 +8546,17 @@ ${errorSummary}
8366
8546
  ${fileContent}
8367
8547
 
8368
8548
  Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
8549
+ const fixSpinner = startSpinner(`Fixing ${chalk15.bold(file)} (${fileErrors.length} error(s))...`);
8369
8550
  try {
8370
8551
  const raw = await provider.generate(prompt, getCodeGenSystemPrompt());
8371
8552
  const fixed = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
8372
8553
  await getActiveSnapshot()?.snapshotFile(fullPath);
8373
8554
  await fs18.writeFile(fullPath, fixed, "utf-8");
8374
8555
  results.push({ fixed: true, file, explanation: `Fixed ${fileErrors.length} error(s)` });
8375
- console.log(chalk15.green(` \u2714 Auto-fixed: ${file}`));
8556
+ fixSpinner.succeed(`Auto-fixed: ${file}`);
8376
8557
  } catch (err) {
8377
8558
  results.push({ fixed: false, file, explanation: `AI fix failed: ${err.message}` });
8378
- console.log(chalk15.yellow(` \u26A0 Could not auto-fix: ${file}`));
8559
+ fixSpinner.fail(`Could not auto-fix: ${file}`);
8379
8560
  }
8380
8561
  }
8381
8562
  return results;
@@ -9384,8 +9565,8 @@ async function findLatestDslFile(projectDir) {
9384
9565
  const entries = await fs22.readdir(dir);
9385
9566
  for (const entry of entries) {
9386
9567
  const abs = path21.join(dir, entry);
9387
- const stat4 = await fs22.stat(abs);
9388
- if (stat4.isDirectory()) {
9568
+ const stat5 = await fs22.stat(abs);
9569
+ if (stat5.isDirectory()) {
9389
9570
  await scan(abs);
9390
9571
  } else if (entry.endsWith(".dsl.json")) {
9391
9572
  allFiles.push(abs);
@@ -11123,7 +11304,7 @@ ${optionsText}`;
11123
11304
  import { createHash as createHash2 } from "crypto";
11124
11305
  import * as fs24 from "fs-extra";
11125
11306
  import * as path23 from "path";
11126
- var VCR_DIR = ".ai-spec-vcr";
11307
+ var VCR_DIR = DEFAULT_VCR_DIR;
11127
11308
  var VcrRecordingProvider = class {
11128
11309
  constructor(inner) {
11129
11310
  this.inner = inner;
@@ -11251,6 +11432,7 @@ async function listVcrRecordings(workingDir) {
11251
11432
  }
11252
11433
 
11253
11434
  // cli/pipeline/single-repo.ts
11435
+ init_cli_ui();
11254
11436
  async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11255
11437
  const specProviderName = opts.provider || config2.provider || "gemini";
11256
11438
  const specModelName = opts.model || config2.model || DEFAULT_MODELS[specProviderName];
@@ -11313,7 +11495,7 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11313
11495
  console.log(chalk25.yellow(` \u26A0 Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
11314
11496
  }
11315
11497
  } else {
11316
- console.log(chalk25.yellow(" Constitution : not found \u2014 auto-generating..."));
11498
+ const constitutionSpinner = startSpinner("Constitution not found \u2014 auto-generating...");
11317
11499
  try {
11318
11500
  const constitutionGen = new ConstitutionGenerator(
11319
11501
  createProvider(specProviderName, specApiKey, specModelName)
@@ -11321,9 +11503,9 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11321
11503
  const constitutionContent = await constitutionGen.generate(currentDir);
11322
11504
  await constitutionGen.saveConstitution(currentDir, constitutionContent);
11323
11505
  context.constitution = constitutionContent;
11324
- console.log(chalk25.green(` Constitution : \u2714 generated and saved (.ai-spec-constitution.md)`));
11506
+ constitutionSpinner.succeed("Constitution generated and saved (.ai-spec-constitution.md)");
11325
11507
  } catch (err) {
11326
- console.log(chalk25.yellow(` Constitution : \u26A0 auto-generation failed (${err.message}), continuing without it.`));
11508
+ constitutionSpinner.fail(`Constitution auto-generation failed (${err.message}), continuing without it.`);
11327
11509
  }
11328
11510
  }
11329
11511
  let architectureDecision;
@@ -11354,17 +11536,18 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11354
11536
  let initialSpec;
11355
11537
  let initialTasks = [];
11356
11538
  runLogger.stageStart("spec_gen", { provider: specProviderName, model: specModelName });
11539
+ const specSpinner = startStage("spec_gen", `Generating spec with ${specProviderName}/${specModelName}...`);
11357
11540
  try {
11358
11541
  if (opts.skipTasks) {
11359
11542
  const { SpecGenerator: SpecGenerator2 } = await Promise.resolve().then(() => (init_spec_generator(), spec_generator_exports));
11360
11543
  const generator = new SpecGenerator2(specProvider);
11361
11544
  initialSpec = await generator.generateSpec(idea, context, architectureDecision);
11362
- console.log(chalk25.green(" \u2714 Spec generated."));
11545
+ specSpinner.succeed("Spec generated.");
11363
11546
  } else {
11364
11547
  const result = await generateSpecWithTasks(specProvider, idea, context, architectureDecision);
11365
11548
  initialSpec = result.spec;
11366
11549
  initialTasks = result.tasks;
11367
- console.log(chalk25.green(` \u2714 Spec generated.`));
11550
+ specSpinner.succeed("Spec generated.");
11368
11551
  if (initialTasks.length > 0) {
11369
11552
  console.log(chalk25.green(` \u2714 ${initialTasks.length} tasks generated (combined call).`));
11370
11553
  } else {
@@ -11373,8 +11556,8 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11373
11556
  }
11374
11557
  runLogger.stageEnd("spec_gen", { taskCount: initialTasks.length });
11375
11558
  } catch (err) {
11559
+ specSpinner.fail(`Spec generation failed: ${err.message}`);
11376
11560
  runLogger.stageFail("spec_gen", err.message);
11377
- console.error(chalk25.red(" \u2718 Spec generation failed:"), err);
11378
11561
  process.exit(1);
11379
11562
  }
11380
11563
  let finalSpec;
@@ -11396,7 +11579,9 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11396
11579
  console.log(chalk25.blue("\n[3.4/6] Spec quality assessment..."));
11397
11580
  }
11398
11581
  runLogger.stageStart("spec_assess");
11582
+ const assessSpinner = startStage("spec_assess", "Evaluating spec quality...");
11399
11583
  const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? void 0);
11584
+ assessSpinner.stop();
11400
11585
  if (assessment) {
11401
11586
  runLogger.stageEnd("spec_assess", { overallScore: assessment.overallScore });
11402
11587
  if (!opts.auto) printSpecAssessment(assessment);
@@ -11488,21 +11673,24 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11488
11673
  console.log(chalk25.blue("\n[DSL] Extracting structured DSL from spec..."));
11489
11674
  console.log(chalk25.gray(` Provider: ${specProviderName}/${specModelName}`));
11490
11675
  runLogger.stageStart("dsl_extract");
11676
+ const dslSpinner = startStage("dsl_extract", "Extracting DSL from spec...");
11491
11677
  try {
11492
11678
  const isFrontend = isFrontendDeps(context.dependencies);
11493
- if (isFrontend) console.log(chalk25.gray(" Frontend project detected \u2014 using ComponentSpec extractor"));
11679
+ if (isFrontend) {
11680
+ dslSpinner.update("\u{1F517} Extracting DSL (frontend ComponentSpec mode)...");
11681
+ }
11494
11682
  const dslExtractor = new DslExtractor(specProvider);
11495
11683
  extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
11496
11684
  if (extractedDsl) {
11497
11685
  runLogger.stageEnd("dsl_extract", { endpoints: extractedDsl.endpoints?.length ?? 0, models: extractedDsl.models?.length ?? 0 });
11498
- console.log(chalk25.green(" \u2714 DSL extracted and validated."));
11686
+ dslSpinner.succeed("DSL extracted and validated.");
11499
11687
  } else {
11500
11688
  runLogger.stageEnd("dsl_extract", { skipped: true });
11501
- console.log(chalk25.yellow(" \u26A0 DSL skipped \u2014 codegen will use Spec + Tasks only."));
11689
+ dslSpinner.fail("DSL skipped \u2014 codegen will use Spec + Tasks only.");
11502
11690
  }
11503
11691
  } catch (err) {
11504
11692
  runLogger.stageFail("dsl_extract", err.message);
11505
- console.log(chalk25.yellow(` \u26A0 DSL extraction error: ${err.message} \u2014 continuing without DSL.`));
11693
+ dslSpinner.fail(`DSL extraction error: ${err.message} \u2014 continuing without DSL.`);
11506
11694
  }
11507
11695
  }
11508
11696
  if (extractedDsl && !opts.auto && !opts.fast && !opts.skipDsl) {
@@ -11677,6 +11865,7 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11677
11865
  if (!opts.skipReview) {
11678
11866
  console.log(chalk25.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
11679
11867
  runLogger.stageStart("review");
11868
+ const reviewSpinner = startStage("review", "Running 3-pass code review...");
11680
11869
  const reviewer = new CodeReviewer(specProvider, workingDir);
11681
11870
  const savedSpec = await fs25.readFile(specFile, "utf-8");
11682
11871
  if (codegenMode === "api" && generatedFiles.length > 0) {
@@ -11684,6 +11873,7 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11684
11873
  } else {
11685
11874
  reviewResult = await reviewer.reviewCode(savedSpec, specFile);
11686
11875
  }
11876
+ reviewSpinner.succeed("Code review complete.");
11687
11877
  runLogger.stageEnd("review");
11688
11878
  const complianceScore = extractComplianceScore(reviewResult);
11689
11879
  const missingCount = extractMissingCount(reviewResult);
@@ -11812,7 +12002,147 @@ async function runSingleRepoPipeline(idea, opts, currentDir, config2) {
11812
12002
  }
11813
12003
  }
11814
12004
 
12005
+ // core/repo-store.ts
12006
+ import * as fs26 from "fs-extra";
12007
+ import * as path25 from "path";
12008
+ import * as os5 from "os";
12009
+ var REPO_STORE_FILE = path25.join(os5.homedir(), ".ai-spec-repos.json");
12010
+ async function readStore2() {
12011
+ try {
12012
+ if (await fs26.pathExists(REPO_STORE_FILE)) {
12013
+ return await fs26.readJson(REPO_STORE_FILE);
12014
+ }
12015
+ } catch (err) {
12016
+ console.warn(`Warning: Could not read repo store at ${REPO_STORE_FILE}: ${err.message}.`);
12017
+ }
12018
+ return { repos: [] };
12019
+ }
12020
+ async function writeStore2(store) {
12021
+ await fs26.ensureFile(REPO_STORE_FILE);
12022
+ await fs26.writeJson(REPO_STORE_FILE, store, { spaces: 2 });
12023
+ }
12024
+ async function getRegisteredRepos() {
12025
+ const store = await readStore2();
12026
+ return store.repos;
12027
+ }
12028
+ async function registerRepo(repo) {
12029
+ const store = await readStore2();
12030
+ const idx = store.repos.findIndex((r) => r.path === repo.path);
12031
+ if (idx >= 0) {
12032
+ store.repos[idx] = repo;
12033
+ } else {
12034
+ store.repos.push(repo);
12035
+ }
12036
+ await writeStore2(store);
12037
+ }
12038
+
11815
12039
  // cli/commands/create.ts
12040
+ init_spec_generator();
12041
+ async function promptRepoRole() {
12042
+ return select7({
12043
+ message: "What type of repo is this?",
12044
+ choices: [
12045
+ { name: "Frontend", value: "frontend" },
12046
+ { name: "Backend", value: "backend" },
12047
+ { name: "Mobile", value: "mobile" },
12048
+ { name: "Shared / Other", value: "shared" }
12049
+ ]
12050
+ });
12051
+ }
12052
+ async function quickRegisterRepo(provider) {
12053
+ const roleOverride = await promptRepoRole();
12054
+ const roleLabels = {
12055
+ frontend: "frontend",
12056
+ backend: "backend",
12057
+ mobile: "mobile",
12058
+ shared: "shared"
12059
+ };
12060
+ const raw = await input2({
12061
+ message: `Enter your ${roleLabels[roleOverride]} repo path (absolute path):`,
12062
+ validate: (v2) => {
12063
+ const trimmed = v2.trim();
12064
+ if (trimmed.length === 0) return "Path cannot be empty";
12065
+ if (!path26.isAbsolute(trimmed)) return "Please provide an absolute path";
12066
+ return true;
12067
+ }
12068
+ });
12069
+ const cleaned = raw.trim().replace(/\\ /g, " ");
12070
+ const resolved = path26.resolve(cleaned);
12071
+ if (!await fs27.pathExists(resolved)) {
12072
+ console.log(chalk26.red(` Path does not exist: ${resolved}`));
12073
+ return quickRegisterRepo(provider);
12074
+ }
12075
+ const { type, role: detectedRole } = await detectRepoType(resolved);
12076
+ const role = roleOverride ?? detectedRole;
12077
+ const repoName = path26.basename(resolved);
12078
+ console.log(chalk26.gray(` Detected: ${repoName} \u2192 ${type} (${role})`));
12079
+ const constitutionPath = path26.join(resolved, CONSTITUTION_FILE);
12080
+ let hasConstitution = await fs27.pathExists(constitutionPath);
12081
+ if (!hasConstitution) {
12082
+ console.log(chalk26.blue(` Generating constitution for ${repoName}...`));
12083
+ try {
12084
+ const gen = new ConstitutionGenerator(provider);
12085
+ const content = await gen.generate(resolved);
12086
+ await gen.saveConstitution(resolved, content);
12087
+ hasConstitution = true;
12088
+ console.log(chalk26.green(` \u2714 Constitution saved`));
12089
+ } catch (err) {
12090
+ console.log(chalk26.yellow(` \u26A0 Constitution failed: ${err.message}`));
12091
+ }
12092
+ }
12093
+ const entry = {
12094
+ name: repoName,
12095
+ path: resolved,
12096
+ type,
12097
+ role,
12098
+ hasConstitution,
12099
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
12100
+ };
12101
+ await registerRepo(entry);
12102
+ console.log(chalk26.green(` \u2714 Repo registered: ${repoName}`));
12103
+ return entry;
12104
+ }
12105
+ async function selectRepos(registeredRepos, provider) {
12106
+ const ADD_NEW = "__add_new__";
12107
+ const choices = [
12108
+ ...registeredRepos.map((r) => ({
12109
+ name: `${r.name} (${r.type} / ${r.role}) \u2192 ${r.path}`,
12110
+ value: r.path
12111
+ })),
12112
+ { name: chalk26.cyan("+ Add new repo"), value: ADD_NEW }
12113
+ ];
12114
+ const selected = await checkbox({
12115
+ message: "Select repo(s) for this feature (space to toggle, enter to confirm):",
12116
+ choices,
12117
+ required: true
12118
+ });
12119
+ const result = [];
12120
+ for (const val of selected) {
12121
+ if (val === ADD_NEW) {
12122
+ const newRepo = await quickRegisterRepo(provider);
12123
+ result.push(newRepo);
12124
+ } else {
12125
+ const repo = registeredRepos.find((r) => r.path === val);
12126
+ if (repo) result.push(repo);
12127
+ }
12128
+ }
12129
+ if (result.length === 0) {
12130
+ console.log(chalk26.yellow(" No repos selected. Please select at least one."));
12131
+ return selectRepos(registeredRepos, provider);
12132
+ }
12133
+ return result;
12134
+ }
12135
+ function buildWorkspaceConfig(repos) {
12136
+ return {
12137
+ name: "ai-spec-workspace",
12138
+ repos: repos.map((r) => ({
12139
+ name: r.name,
12140
+ path: r.path,
12141
+ type: r.type,
12142
+ role: r.role
12143
+ }))
12144
+ };
12145
+ }
11816
12146
  function registerCreate(program2) {
11817
12147
  program2.command("create").description("Generate a feature spec and kick off code generation").argument("[idea]", "Feature idea in natural language (prompted if omitted)").option(
11818
12148
  "--provider <name>",
@@ -11835,25 +12165,76 @@ function registerCreate(program2) {
11835
12165
  });
11836
12166
  }
11837
12167
  const workspaceLoader = new WorkspaceLoader(currentDir);
11838
- const workspaceConfig = await workspaceLoader.load();
11839
- if (workspaceConfig) {
12168
+ const existingWorkspaceConfig = await workspaceLoader.load();
12169
+ if (existingWorkspaceConfig) {
11840
12170
  console.log(chalk26.cyan(`
11841
- [Workspace] Detected workspace: ${workspaceConfig.name}`));
11842
- console.log(chalk26.gray(` Repos: ${workspaceConfig.repos.map((r) => r.name).join(", ")}`));
11843
- const pipelineResults = await runMultiRepoPipeline(idea, workspaceConfig, opts, currentDir, config2);
12171
+ [Workspace] Detected workspace: ${existingWorkspaceConfig.name}`));
12172
+ console.log(chalk26.gray(` Repos: ${existingWorkspaceConfig.repos.map((r) => r.name).join(", ")}`));
12173
+ const pipelineResults = await runMultiRepoPipeline(idea, existingWorkspaceConfig, opts, currentDir, config2);
11844
12174
  if (opts.serve) {
11845
12175
  await handleAutoServe(pipelineResults);
11846
12176
  }
11847
12177
  return;
11848
12178
  }
11849
- await runSingleRepoPipeline(idea, opts, currentDir, config2);
12179
+ const providerName = opts.provider || config2.provider || "gemini";
12180
+ const modelName = opts.model || config2.model || DEFAULT_MODELS[providerName];
12181
+ const apiKey = await resolveApiKey(providerName, opts.key);
12182
+ const specProvider = createProvider(providerName, apiKey, modelName);
12183
+ const registeredRepos = await getRegisteredRepos();
12184
+ if (registeredRepos.length === 0) {
12185
+ console.log(chalk26.yellow("\n No repos registered. Please register repos first."));
12186
+ console.log(chalk26.gray(" Run: ai-spec init"));
12187
+ console.log(chalk26.gray(" Or add a repo now:\n"));
12188
+ const addNow = await select7({
12189
+ message: "Add a repo now?",
12190
+ choices: [
12191
+ { name: "Yes \u2014 register a repo and continue", value: "yes" },
12192
+ { name: "No \u2014 exit", value: "no" }
12193
+ ]
12194
+ });
12195
+ if (addNow === "no") {
12196
+ process.exit(0);
12197
+ }
12198
+ const newRepo = await quickRegisterRepo(specProvider);
12199
+ registeredRepos.push(newRepo);
12200
+ }
12201
+ const validRepos = [];
12202
+ for (const r of registeredRepos) {
12203
+ if (await fs27.pathExists(r.path)) {
12204
+ validRepos.push(r);
12205
+ } else {
12206
+ console.log(chalk26.yellow(` \u26A0 Skipping ${r.name}: path not found (${r.path})`));
12207
+ }
12208
+ }
12209
+ if (validRepos.length === 0) {
12210
+ console.log(chalk26.red(" No valid repos available. Run: ai-spec init"));
12211
+ process.exit(1);
12212
+ }
12213
+ const selectedRepos = await selectRepos(validRepos, specProvider);
12214
+ if (selectedRepos.length === 1) {
12215
+ const repo = selectedRepos[0];
12216
+ console.log(chalk26.cyan(`
12217
+ [Repo] ${repo.name} (${repo.type}/${repo.role}) \u2192 ${repo.path}`));
12218
+ await runSingleRepoPipeline(idea, opts, repo.path, config2);
12219
+ } else {
12220
+ const workspaceConfig = buildWorkspaceConfig(selectedRepos);
12221
+ console.log(chalk26.cyan(`
12222
+ [Workspace] ${selectedRepos.length} repo(s) selected:`));
12223
+ for (const repo of selectedRepos) {
12224
+ console.log(chalk26.gray(` ${repo.name} (${repo.type}/${repo.role}) \u2192 ${repo.path}`));
12225
+ }
12226
+ const pipelineResults = await runMultiRepoPipeline(idea, workspaceConfig, opts, currentDir, config2);
12227
+ if (opts.serve) {
12228
+ await handleAutoServe(pipelineResults);
12229
+ }
12230
+ }
11850
12231
  });
11851
12232
  }
11852
12233
 
11853
12234
  // cli/commands/review.ts
11854
12235
  init_spec_generator();
11855
- import * as path25 from "path";
11856
- import * as fs26 from "fs-extra";
12236
+ import * as path27 from "path";
12237
+ import * as fs28 from "fs-extra";
11857
12238
  import chalk27 from "chalk";
11858
12239
  function registerReview(program2) {
11859
12240
  program2.command("review").description("Run AI code review on current git diff against a spec").argument("[specFile]", "Path to spec file (auto-detects latest in specs/ if omitted)").option(
@@ -11870,17 +12251,17 @@ function registerReview(program2) {
11870
12251
  const reviewer = new CodeReviewer(provider, currentDir);
11871
12252
  let specContent = "";
11872
12253
  let resolvedSpecFile;
11873
- if (specFile && await fs26.pathExists(specFile)) {
11874
- specContent = await fs26.readFile(specFile, "utf-8");
12254
+ if (specFile && await fs28.pathExists(specFile)) {
12255
+ specContent = await fs28.readFile(specFile, "utf-8");
11875
12256
  resolvedSpecFile = specFile;
11876
12257
  console.log(chalk27.gray(`Using spec: ${specFile}`));
11877
12258
  } else {
11878
- const specsDir = path25.join(currentDir, "specs");
11879
- if (await fs26.pathExists(specsDir)) {
11880
- const files = (await fs26.readdir(specsDir)).filter((f) => f.endsWith(".md")).sort().reverse();
12259
+ const specsDir = path27.join(currentDir, "specs");
12260
+ if (await fs28.pathExists(specsDir)) {
12261
+ const files = (await fs28.readdir(specsDir)).filter((f) => f.endsWith(".md")).sort().reverse();
11881
12262
  if (files.length > 0) {
11882
- const latest = path25.join(specsDir, files[0]);
11883
- specContent = await fs26.readFile(latest, "utf-8");
12263
+ const latest = path27.join(specsDir, files[0]);
12264
+ specContent = await fs28.readFile(latest, "utf-8");
11884
12265
  resolvedSpecFile = latest;
11885
12266
  console.log(chalk27.gray(`Auto-detected spec: specs/${files[0]}`));
11886
12267
  }
@@ -11896,9 +12277,10 @@ function registerReview(program2) {
11896
12277
 
11897
12278
  // cli/commands/init.ts
11898
12279
  init_spec_generator();
11899
- import * as path27 from "path";
11900
- import * as fs28 from "fs-extra";
12280
+ import * as path28 from "path";
12281
+ import * as fs29 from "fs-extra";
11901
12282
  import chalk28 from "chalk";
12283
+ import { input as input3, select as select8, confirm as confirm2 } from "@inquirer/prompts";
11902
12284
 
11903
12285
  // prompts/global-constitution.prompt.ts
11904
12286
  var globalConstitutionSystemPrompt = `You are a Senior Software Architect. Analyze the provided multi-project context and generate a "Global Constitution" \u2014 a team-level baseline document that captures cross-project rules, shared conventions, and universal constraints that every repository in this workspace must follow.
@@ -11959,240 +12341,139 @@ ${summary}
11959
12341
  return parts.join("\n");
11960
12342
  }
11961
12343
 
11962
- // core/project-index.ts
11963
- import * as fs27 from "fs-extra";
11964
- import * as path26 from "path";
11965
- var INDEX_FILE = ".ai-spec-index.json";
11966
- var KEY_DEPS = [
11967
- // Frameworks
11968
- "express",
11969
- "fastify",
11970
- "koa",
11971
- "@nestjs/core",
11972
- "hapi",
11973
- "next",
11974
- "react",
11975
- "vue",
11976
- "nuxt",
11977
- "svelte",
11978
- "react-native",
11979
- "expo",
11980
- // DB / ORM
11981
- "prisma",
11982
- "@prisma/client",
11983
- "mongoose",
11984
- "typeorm",
11985
- "sequelize",
11986
- "drizzle-orm",
11987
- // Auth
11988
- "jsonwebtoken",
11989
- "passport",
11990
- "next-auth",
11991
- "@clerk/nextjs",
11992
- // Build / Lang
11993
- "typescript",
11994
- "vite",
11995
- "webpack",
11996
- "esbuild",
11997
- "turbo",
11998
- // Testing
11999
- "jest",
12000
- "vitest",
12001
- "mocha",
12002
- "cypress",
12003
- "playwright",
12004
- // Infra
12005
- "redis",
12006
- "bull",
12007
- "socket.io",
12008
- "graphql",
12009
- "@trpc/server"
12010
- ];
12011
- var SKIP_DIRS = /* @__PURE__ */ new Set([
12012
- "node_modules",
12013
- ".git",
12014
- ".svn",
12015
- "dist",
12016
- "build",
12017
- "out",
12018
- ".next",
12019
- ".nuxt",
12020
- "coverage",
12021
- ".turbo",
12022
- ".cache",
12023
- "__pycache__",
12024
- "vendor",
12025
- ".ai-spec-vcr",
12026
- ".ai-spec-logs",
12027
- "specs"
12028
- ]);
12029
- var MANIFEST_FILES = [
12030
- "package.json",
12031
- "go.mod",
12032
- "Cargo.toml",
12033
- "pom.xml",
12034
- "build.gradle",
12035
- "build.gradle.kts",
12036
- "requirements.txt",
12037
- "pyproject.toml",
12038
- "setup.py",
12039
- "composer.json"
12040
- ];
12041
- async function isProjectRoot(absPath) {
12042
- for (const manifest of MANIFEST_FILES) {
12043
- if (await fs27.pathExists(path26.join(absPath, manifest))) return true;
12044
- }
12045
- return false;
12344
+ // cli/commands/init.ts
12345
+ var ROLE_LABELS = {
12346
+ frontend: "frontend",
12347
+ backend: "backend",
12348
+ mobile: "mobile",
12349
+ shared: "shared"
12350
+ };
12351
+ async function promptRepoRole2() {
12352
+ return select8({
12353
+ message: "What type of repo is this?",
12354
+ choices: [
12355
+ { name: "Frontend", value: "frontend" },
12356
+ { name: "Backend", value: "backend" },
12357
+ { name: "Mobile", value: "mobile" },
12358
+ { name: "Shared / Other", value: "shared" }
12359
+ ]
12360
+ });
12046
12361
  }
12047
- async function extractTechStack(absPath, type) {
12048
- const stack = [];
12049
- if (type === "go") stack.push("go");
12050
- if (type === "rust") stack.push("rust");
12051
- if (type === "java") stack.push("java");
12052
- if (type === "python") stack.push("python");
12053
- if (type === "php") stack.push("php");
12054
- const pkgPath = path26.join(absPath, "package.json");
12055
- if (!await fs27.pathExists(pkgPath)) return stack;
12056
- let pkg = {};
12057
- try {
12058
- pkg = await fs27.readJson(pkgPath);
12059
- } catch {
12060
- return stack;
12061
- }
12062
- const allDeps = {
12063
- ...pkg.dependencies ?? {},
12064
- ...pkg.devDependencies ?? {}
12362
+ async function promptRepoPath(role) {
12363
+ const label = ROLE_LABELS[role];
12364
+ const raw = await input3({
12365
+ message: `Enter your ${label} repo path (absolute path):`,
12366
+ validate: (v2) => {
12367
+ const trimmed = v2.trim();
12368
+ if (trimmed.length === 0) return "Path cannot be empty";
12369
+ if (!path28.isAbsolute(trimmed)) return "Please provide an absolute path";
12370
+ return true;
12371
+ }
12372
+ });
12373
+ const cleaned = raw.trim().replace(/\\ /g, " ");
12374
+ const resolved = path28.resolve(cleaned);
12375
+ if (!await fs29.pathExists(resolved)) {
12376
+ console.log(chalk28.red(` Path does not exist: ${resolved}`));
12377
+ return promptRepoPath(role);
12378
+ }
12379
+ const stat5 = await fs29.stat(resolved);
12380
+ if (!stat5.isDirectory()) {
12381
+ console.log(chalk28.red(` Not a directory: ${resolved}`));
12382
+ return promptRepoPath(role);
12383
+ }
12384
+ return resolved;
12385
+ }
12386
+ async function registerSingleRepo(repoPath, provider, roleOverride) {
12387
+ const { type, role: detectedRole } = await detectRepoType(repoPath);
12388
+ const role = roleOverride ?? detectedRole;
12389
+ const repoName = path28.basename(repoPath);
12390
+ console.log(chalk28.gray(` Detected: ${repoName} \u2192 ${type} (${role})`));
12391
+ const constitutionPath = path28.join(repoPath, CONSTITUTION_FILE);
12392
+ let hasConstitution = await fs29.pathExists(constitutionPath);
12393
+ if (!hasConstitution) {
12394
+ console.log(chalk28.blue(` Generating project constitution for ${repoName}...`));
12395
+ try {
12396
+ const gen = new ConstitutionGenerator(provider);
12397
+ const content = await gen.generate(repoPath);
12398
+ await gen.saveConstitution(repoPath, content);
12399
+ hasConstitution = true;
12400
+ console.log(chalk28.green(` \u2714 Constitution saved: ${constitutionPath}`));
12401
+ } catch (err) {
12402
+ console.log(chalk28.yellow(` \u26A0 Constitution generation failed: ${err.message}`));
12403
+ }
12404
+ } else {
12405
+ console.log(chalk28.green(` \u2714 Constitution already exists: ${constitutionPath}`));
12406
+ }
12407
+ const entry = {
12408
+ name: repoName,
12409
+ path: repoPath,
12410
+ type,
12411
+ role,
12412
+ hasConstitution,
12413
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
12065
12414
  };
12066
- const depKeys = new Set(Object.keys(allDeps));
12067
- for (const dep of KEY_DEPS) {
12068
- if (depKeys.has(dep)) stack.push(dep);
12069
- }
12070
- return stack;
12071
- }
12072
- async function discoverProjects(rootDir, maxDepth) {
12073
- const found = [];
12074
- async function walk(absDir, depth) {
12075
- if (depth > maxDepth) return;
12076
- let entries;
12415
+ await registerRepo(entry);
12416
+ return entry;
12417
+ }
12418
+ async function buildProjectSummaries(repos) {
12419
+ const summaries = [];
12420
+ for (const repo of repos) {
12421
+ if (!await fs29.pathExists(repo.path)) continue;
12422
+ const lines = [
12423
+ `Type: ${repo.type} (${repo.role})`
12424
+ ];
12077
12425
  try {
12078
- entries = await fs27.readdir(absDir, { withFileTypes: true });
12426
+ const loader = new ContextLoader(repo.path);
12427
+ const ctx = await loader.loadProjectContext();
12428
+ lines.push(`Tech stack: ${ctx.techStack.join(", ") || "unknown"}`);
12429
+ lines.push(`Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`);
12079
12430
  } catch {
12080
- return;
12431
+ lines.push(`Tech stack: ${repo.type}`);
12081
12432
  }
12082
- for (const entry of entries) {
12083
- if (!entry.isDirectory()) continue;
12084
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
12085
- const childAbs = path26.join(absDir, entry.name);
12086
- const gitPath = path26.join(childAbs, ".git");
12087
- if (await fs27.pathExists(gitPath)) {
12088
- const gitStat = await fs27.stat(gitPath);
12089
- if (gitStat.isFile()) continue;
12090
- }
12091
- if (await isProjectRoot(childAbs)) {
12092
- found.push(path26.relative(rootDir, childAbs));
12093
- } else {
12094
- await walk(childAbs, depth + 1);
12433
+ if (repo.hasConstitution) {
12434
+ try {
12435
+ const constitutionPath = path28.join(repo.path, CONSTITUTION_FILE);
12436
+ const raw = await fs29.readFile(constitutionPath, "utf-8");
12437
+ lines.push("", "Constitution excerpt:", raw.slice(0, 2e3));
12438
+ } catch {
12095
12439
  }
12096
12440
  }
12441
+ summaries.push({ name: repo.name, summary: lines.join("\n") });
12097
12442
  }
12098
- await walk(rootDir, 0);
12099
- return found;
12443
+ return summaries;
12100
12444
  }
12101
- async function loadIndex(scanRoot) {
12102
- const filePath = path26.join(scanRoot, INDEX_FILE);
12103
- try {
12104
- return await fs27.readJson(filePath);
12105
- } catch {
12106
- return null;
12445
+ async function generateGlobalConstitution(provider, repos, currentDir) {
12446
+ console.log(chalk28.blue("\n\u2500\u2500\u2500 Generating Global Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12447
+ console.log(chalk28.gray(` Based on ${repos.length} registered repo(s)`));
12448
+ const summaries = await buildProjectSummaries(repos);
12449
+ if (summaries.length === 0) {
12450
+ console.log(chalk28.yellow(" No valid repos found \u2014 skipping global constitution."));
12451
+ return;
12107
12452
  }
12108
- }
12109
- async function saveIndex(scanRoot, index) {
12110
- const filePath = path26.join(scanRoot, INDEX_FILE);
12111
- await fs27.writeJson(filePath, index, { spaces: 2 });
12112
- return filePath;
12113
- }
12114
- async function runScan(scanRoot, maxDepth = 2) {
12115
- const now = (/* @__PURE__ */ new Date()).toISOString();
12116
- const existing = await loadIndex(scanRoot);
12117
- const existingMap = new Map(
12118
- (existing?.projects ?? []).map((p) => [p.path, p])
12119
- );
12120
- const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
12121
- const added = [];
12122
- const updated = [];
12123
- const unchanged = [];
12124
- const seenPaths = /* @__PURE__ */ new Set();
12125
- for (const relPath of discoveredPaths) {
12126
- const absPath = path26.join(scanRoot, relPath);
12127
- seenPaths.add(relPath);
12128
- const { type, role } = await detectRepoType(absPath);
12129
- const techStack = await extractTechStack(absPath, type);
12130
- const hasConstitution = await fs27.pathExists(path26.join(absPath, CONSTITUTION_FILE));
12131
- const hasWorkspace = await fs27.pathExists(path26.join(absPath, WORKSPACE_CONFIG_FILE));
12132
- const name = path26.basename(relPath);
12133
- const prev = existingMap.get(relPath);
12134
- if (!prev) {
12135
- const entry = {
12136
- name,
12137
- path: relPath,
12138
- type,
12139
- role,
12140
- techStack,
12141
- hasConstitution,
12142
- hasWorkspace,
12143
- firstSeen: now,
12144
- lastSeen: now
12145
- };
12146
- added.push(entry);
12147
- existingMap.set(relPath, entry);
12148
- } else {
12149
- const changed = prev.type !== type || prev.role !== role || prev.hasConstitution !== hasConstitution || prev.hasWorkspace !== hasWorkspace || JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
12150
- const entry = {
12151
- ...prev,
12152
- type,
12153
- role,
12154
- techStack,
12155
- hasConstitution,
12156
- hasWorkspace,
12157
- lastSeen: now,
12158
- missing: void 0
12159
- // clear missing flag if it came back
12160
- };
12161
- existingMap.set(relPath, entry);
12162
- if (changed) {
12163
- updated.push(entry);
12164
- } else {
12165
- unchanged.push(entry);
12166
- }
12167
- }
12453
+ const prompt = buildGlobalConstitutionPrompt(summaries);
12454
+ let globalConstitution;
12455
+ try {
12456
+ globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
12457
+ } catch (err) {
12458
+ console.error(chalk28.red(` \u2718 Failed to generate global constitution: ${err.message}`));
12459
+ return;
12168
12460
  }
12169
- const nowMissing = [];
12170
- for (const [relPath, entry] of existingMap) {
12171
- if (!seenPaths.has(relPath) && !entry.missing) {
12172
- const gone = { ...entry, missing: true };
12173
- existingMap.set(relPath, gone);
12174
- nowMissing.push(gone);
12175
- }
12461
+ const saved = await saveGlobalConstitution(globalConstitution, currentDir);
12462
+ console.log(chalk28.green(` \u2714 Global constitution saved: ${saved}`));
12463
+ console.log(chalk28.gray(" Project constitutions will be merged with this at runtime."));
12464
+ const lines = globalConstitution.split("\n");
12465
+ console.log(chalk28.bold("\n Preview:"));
12466
+ console.log(chalk28.gray(lines.slice(0, 10).join("\n")));
12467
+ if (lines.length > 10) {
12468
+ console.log(chalk28.gray(` ... (${lines.length} lines total)`));
12176
12469
  }
12177
- const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
12178
- const index = {
12179
- scanRoot,
12180
- lastScanned: now,
12181
- projects
12182
- };
12183
- return { index, added, updated, unchanged, nowMissing };
12184
12470
  }
12185
-
12186
- // cli/commands/init.ts
12187
12471
  function registerInit(program2) {
12188
- program2.command("init").description(`Analyze codebase and generate Project Constitution (${CONSTITUTION_FILE})`).option(
12472
+ program2.command("init").description("Setup workspace: register repos, generate constitutions").option(
12189
12473
  "--provider <name>",
12190
12474
  `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
12191
12475
  void 0
12192
- ).option("--model <name>", "Model name").option("-k, --key <apiKey>", "API key").option("--force", "Overwrite existing constitution").option(
12193
- "--global",
12194
- `Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
12195
- ).option("--consolidate", "Consolidate \xA79 accumulated lessons into \xA71\u2013\xA78 core rules (prune & rebase)").option("--dry-run", "Preview consolidation result without writing (use with --consolidate)").action(async (opts) => {
12476
+ ).option("--model <name>", "Model name").option("-k, --key <apiKey>", "API key").option("--force", "Overwrite existing constitutions").option("--consolidate", "Consolidate \xA79 accumulated lessons into \xA71\u2013\xA78 core rules").option("--dry-run", "Preview consolidation result without writing (use with --consolidate)").option("--add-repo", "Add a new repo to the registered list").action(async (opts) => {
12196
12477
  const currentDir = process.cwd();
12197
12478
  const config2 = await loadConfig(currentDir);
12198
12479
  const providerName = opts.provider || config2.provider || "gemini";
@@ -12211,7 +12492,7 @@ function registerInit(program2) {
12211
12492
  console.log(chalk28.gray(` Lines : ${result.before.totalLines} \u2192 ${result.after.totalLines} (${result.before.totalLines - result.after.totalLines > 0 ? "-" : "+"}${Math.abs(result.before.totalLines - result.after.totalLines)})`));
12212
12493
  console.log(chalk28.gray(` \xA79 : ${result.before.lessonCount} \u2192 ${result.after.lessonCount} lessons remaining`));
12213
12494
  if (result.backupPath) {
12214
- console.log(chalk28.gray(` Backup: ${path27.basename(result.backupPath)}`));
12495
+ console.log(chalk28.gray(` Backup: ${path28.basename(result.backupPath)}`));
12215
12496
  }
12216
12497
  }
12217
12498
  } catch (err) {
@@ -12220,119 +12501,125 @@ function registerInit(program2) {
12220
12501
  }
12221
12502
  return;
12222
12503
  }
12223
- if (opts.global) {
12224
- const existing = await loadGlobalConstitution([currentDir]);
12225
- if (existing && !opts.force) {
12226
- console.log(chalk28.yellow(`
12227
- Global constitution already exists at: ${existing.source}`));
12228
- console.log(chalk28.gray(" Use --force to overwrite it."));
12229
- return;
12504
+ if (opts.addRepo) {
12505
+ console.log(chalk28.blue("\n\u2500\u2500\u2500 Register New Repo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12506
+ const role = await promptRepoRole2();
12507
+ const repoPath = await promptRepoPath(role);
12508
+ const entry = await registerSingleRepo(repoPath, provider, role);
12509
+ console.log(chalk28.green(`
12510
+ \u2714 Repo registered: ${entry.name} (${entry.type} / ${entry.role})`));
12511
+ console.log(chalk28.gray(` Saved to: ${REPO_STORE_FILE}`));
12512
+ const updateGlobal = await confirm2({
12513
+ message: "Update global constitution with this repo's context?",
12514
+ default: true
12515
+ });
12516
+ if (updateGlobal) {
12517
+ const allRepos2 = await getRegisteredRepos();
12518
+ await generateGlobalConstitution(provider, allRepos2, currentDir);
12230
12519
  }
12231
- console.log(chalk28.blue("\n\u2500\u2500\u2500 Generating Global Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12232
- console.log(chalk28.gray(` Provider: ${providerName}/${modelName}`));
12233
- const projectSummaries = [];
12234
- const index = await loadIndex(currentDir);
12235
- if (index && index.projects.length > 0) {
12236
- const active = index.projects.filter((p) => !p.missing);
12237
- console.log(chalk28.gray(` Found project index: ${active.length} project(s) \u2014 reading constitutions...`));
12238
- for (const entry of active) {
12239
- const absPath = path27.join(currentDir, entry.path);
12240
- const lines = [
12241
- `Type: ${entry.type} (${entry.role})`,
12242
- `Tech stack: ${entry.techStack.join(", ") || "unknown"}`
12243
- ];
12244
- if (entry.hasConstitution) {
12245
- try {
12246
- const constitutionPath2 = path27.join(absPath, CONSTITUTION_FILE);
12247
- const raw = await fs28.readFile(constitutionPath2, "utf-8");
12248
- const excerpt = raw.slice(0, 2e3);
12249
- lines.push("", "Constitution excerpt:", excerpt);
12250
- } catch {
12251
- }
12252
- }
12253
- projectSummaries.push({ name: entry.name, summary: lines.join("\n") });
12520
+ return;
12521
+ }
12522
+ console.log(chalk28.blue("\n" + "\u2500".repeat(52)));
12523
+ console.log(chalk28.bold(" ai-spec init \u2014 Workspace Setup"));
12524
+ console.log(chalk28.blue("\u2500".repeat(52)));
12525
+ console.log(chalk28.gray(` Provider: ${providerName}/${modelName}
12526
+ `));
12527
+ const existingRepos = await getRegisteredRepos();
12528
+ if (existingRepos.length > 0) {
12529
+ console.log(chalk28.cyan(" Registered repos:"));
12530
+ for (const r of existingRepos) {
12531
+ const constitutionIcon = r.hasConstitution ? chalk28.green("\u2714") : chalk28.gray("\u25CB");
12532
+ console.log(chalk28.gray(` ${constitutionIcon} ${r.name} (${r.type} / ${r.role}) \u2192 ${r.path}`));
12533
+ }
12534
+ console.log();
12535
+ }
12536
+ const action = existingRepos.length > 0 ? await select8({
12537
+ message: "What would you like to do?",
12538
+ choices: [
12539
+ { name: "Add new repo(s)", value: "add" },
12540
+ { name: "Re-generate constitutions for existing repos", value: "regen" },
12541
+ { name: "Skip \u2014 proceed to global constitution", value: "skip" }
12542
+ ]
12543
+ }) : "add";
12544
+ const newRepos = [];
12545
+ if (action === "add") {
12546
+ let addMore = true;
12547
+ while (addMore) {
12548
+ console.log(chalk28.blue(`
12549
+ \u2500\u2500 Register Repo #${existingRepos.length + newRepos.length + 1} \u2500\u2500`));
12550
+ const role = await promptRepoRole2();
12551
+ const repoPath = await promptRepoPath(role);
12552
+ const alreadyRegistered = [...existingRepos, ...newRepos].find((r) => r.path === repoPath);
12553
+ if (alreadyRegistered) {
12554
+ console.log(chalk28.yellow(` Already registered: ${alreadyRegistered.name}`));
12555
+ } else {
12556
+ const entry = await registerSingleRepo(repoPath, provider, role);
12557
+ newRepos.push(entry);
12558
+ console.log(chalk28.green(` \u2714 Registered: ${entry.name}`));
12254
12559
  }
12255
- } else {
12256
- console.log(chalk28.yellow(" No project index found. Run `ai-spec scan` first for better results."));
12257
- console.log(chalk28.gray(" Falling back: scanning current directory only..."));
12258
- const loader = new ContextLoader(currentDir);
12259
- const ctx = await loader.loadProjectContext();
12260
- projectSummaries.push({
12261
- name: path27.basename(currentDir),
12262
- summary: [
12263
- `Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
12264
- `Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`
12265
- ].join("\n")
12560
+ addMore = await confirm2({
12561
+ message: "Add another repo?",
12562
+ default: false
12266
12563
  });
12267
12564
  }
12268
- console.log(chalk28.gray(` Generating from ${projectSummaries.length} project(s)...`));
12269
- const prompt = buildGlobalConstitutionPrompt(projectSummaries);
12270
- let globalConstitution;
12271
- try {
12272
- globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
12273
- } catch (err) {
12274
- console.error(chalk28.red(" \u2718 Failed to generate global constitution:"), err);
12275
- process.exit(1);
12276
- }
12277
- const saved2 = await saveGlobalConstitution(globalConstitution, currentDir);
12278
- console.log(chalk28.green(`
12279
- \u2714 Global constitution saved: ${saved2}`));
12280
- console.log(chalk28.gray(" This will be automatically merged into all project constitutions in this workspace."));
12281
- console.log(chalk28.gray(" Project-level rules always override global rules.\n"));
12282
- console.log(chalk28.bold(" Preview:"));
12283
- console.log(chalk28.gray(globalConstitution.split("\n").slice(0, 12).join("\n")));
12284
- if (globalConstitution.split("\n").length > 12) {
12285
- console.log(chalk28.gray(` ... (${globalConstitution.split("\n").length} lines total)`));
12565
+ }
12566
+ if (action === "regen") {
12567
+ console.log(chalk28.blue("\n Re-generating project constitutions..."));
12568
+ for (const repo of existingRepos) {
12569
+ if (!await fs29.pathExists(repo.path)) {
12570
+ console.log(chalk28.yellow(` \u26A0 ${repo.name}: path not found \u2014 skipping`));
12571
+ continue;
12572
+ }
12573
+ const constitutionPath = path28.join(repo.path, CONSTITUTION_FILE);
12574
+ if (await fs29.pathExists(constitutionPath) && !opts.force) {
12575
+ console.log(chalk28.gray(` ${repo.name}: constitution exists (use --force to overwrite)`));
12576
+ continue;
12577
+ }
12578
+ console.log(chalk28.blue(` ${repo.name}: generating constitution...`));
12579
+ try {
12580
+ const gen = new ConstitutionGenerator(provider);
12581
+ const content = await gen.generate(repo.path);
12582
+ await gen.saveConstitution(repo.path, content);
12583
+ console.log(chalk28.green(` \u2714 ${repo.name}: constitution saved`));
12584
+ } catch (err) {
12585
+ console.log(chalk28.yellow(` \u26A0 ${repo.name}: failed \u2014 ${err.message}`));
12586
+ }
12286
12587
  }
12287
- return;
12288
12588
  }
12289
- const constitutionPath = path27.join(currentDir, CONSTITUTION_FILE);
12290
- if (!opts.force && await fs28.pathExists(constitutionPath)) {
12291
- console.log(chalk28.yellow(`
12292
- ${CONSTITUTION_FILE} already exists.`));
12293
- console.log(chalk28.gray(" Use --force to overwrite it."));
12294
- console.log(chalk28.gray(` Or edit it directly: ${constitutionPath}`));
12589
+ const allRepos = await getRegisteredRepos();
12590
+ if (allRepos.length === 0) {
12591
+ console.log(chalk28.yellow("\n No repos registered. Run `ai-spec init` again to add repos."));
12295
12592
  return;
12296
12593
  }
12297
- console.log(chalk28.blue("\n\u2500\u2500\u2500 Generating Project Constitution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12298
- console.log(chalk28.gray(` Provider: ${providerName}/${modelName}`));
12299
- console.log(chalk28.gray(" Analyzing codebase..."));
12300
- const generator = new ConstitutionGenerator(provider);
12301
- let constitution;
12302
- try {
12303
- constitution = await generator.generate(currentDir);
12304
- } catch (err) {
12305
- console.error(chalk28.red(" \u2718 Failed to generate constitution:"), err);
12306
- process.exit(1);
12307
- }
12308
- const saved = await generator.saveConstitution(currentDir, constitution);
12309
- const globalResult = await loadGlobalConstitution([path27.dirname(currentDir)]);
12310
- if (globalResult) {
12311
- console.log(chalk28.cyan(`
12312
- \u2139 Global constitution detected: ${globalResult.source}`));
12313
- console.log(chalk28.gray(" It will be merged with this project constitution at runtime."));
12314
- console.log(chalk28.gray(" Project rules take priority over global rules."));
12315
- }
12316
- console.log(chalk28.green(`
12317
- \u2714 Constitution saved: ${saved}`));
12318
- console.log(chalk28.gray(" This file will be automatically used in all future `ai-spec create` runs."));
12319
- console.log(chalk28.gray(" Edit it to add custom rules or red lines for your project.\n"));
12320
- console.log(chalk28.bold(" Preview:"));
12321
- console.log(chalk28.gray(constitution.split("\n").slice(0, 15).join("\n")));
12322
- if (constitution.split("\n").length > 15) {
12323
- console.log(chalk28.gray(` ... (${constitution.split("\n").length} lines total)`));
12324
- }
12594
+ const existingGlobal = await loadGlobalConstitution([currentDir]);
12595
+ const shouldGenerateGlobal = !existingGlobal || opts.force || newRepos.length > 0 ? true : await confirm2({
12596
+ message: "Global constitution exists. Re-generate it?",
12597
+ default: false
12598
+ });
12599
+ if (shouldGenerateGlobal) {
12600
+ await generateGlobalConstitution(provider, allRepos, currentDir);
12601
+ }
12602
+ console.log(chalk28.bold.green("\n\u2714 Init complete!"));
12603
+ console.log(chalk28.gray(` Repos registered: ${allRepos.length}`));
12604
+ for (const r of allRepos) {
12605
+ const icon = r.hasConstitution ? chalk28.green("\u2714") : chalk28.gray("\u25CB");
12606
+ console.log(chalk28.gray(` ${icon} ${r.name} (${r.type}/${r.role})`));
12607
+ }
12608
+ console.log(chalk28.gray(`
12609
+ Repo store: ${REPO_STORE_FILE}`));
12610
+ console.log(chalk28.gray(` Next step: ai-spec create "your feature idea"`));
12611
+ process.exit(0);
12325
12612
  });
12326
12613
  }
12327
12614
 
12328
12615
  // cli/commands/config.ts
12329
- import * as path28 from "path";
12330
- import * as fs29 from "fs-extra";
12616
+ import * as path29 from "path";
12617
+ import * as fs30 from "fs-extra";
12331
12618
  import chalk29 from "chalk";
12332
12619
  function registerConfig(program2) {
12333
12620
  program2.command("config").description(`Set default configuration for this project (saved to ${CONFIG_FILE})`).option("--provider <name>", "Default AI provider for spec generation").option("--model <name>", "Default model for spec generation").option("--codegen <mode>", "Default code generation mode (claude-code|api|plan)").option("--codegen-provider <name>", "Default provider for code generation").option("--codegen-model <name>", "Default model for code generation").option("--min-spec-score <score>", "Minimum overall spec score (1-10) to pass Approval Gate (0 = disabled)").option("--min-harness-score <score>", "Minimum harness score (1-10) for pipeline success (0 = disabled)").option("--max-error-cycles <n>", "Maximum error-feedback fix cycles (1-10, default: 2)").option("--show", "Print current configuration").option("--reset", "Reset configuration to empty").option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json").option("--clear-key <provider>", "Delete saved API key for a specific provider").option("--list-keys", "Show which providers have a saved key").action(async (opts) => {
12334
12621
  const currentDir = process.cwd();
12335
- const configPath = path28.join(currentDir, CONFIG_FILE);
12622
+ const configPath = path29.join(currentDir, CONFIG_FILE);
12336
12623
  if (opts.clearKeys) {
12337
12624
  await clearAllKeys();
12338
12625
  console.log(chalk29.green(`\u2714 All saved API keys cleared.`));
@@ -12344,7 +12631,7 @@ function registerConfig(program2) {
12344
12631
  return;
12345
12632
  }
12346
12633
  if (opts.listKeys) {
12347
- const store = await fs29.readJson(KEY_STORE_FILE).catch(() => ({}));
12634
+ const store = await fs30.readJson(KEY_STORE_FILE).catch(() => ({}));
12348
12635
  const providers = Object.keys(store);
12349
12636
  if (providers.length === 0) {
12350
12637
  console.log(chalk29.gray("No saved API keys."));
@@ -12360,7 +12647,7 @@ File: ${KEY_STORE_FILE}`));
12360
12647
  return;
12361
12648
  }
12362
12649
  if (opts.reset) {
12363
- await fs29.writeJson(configPath, {}, { spaces: 2 });
12650
+ await fs30.writeJson(configPath, {}, { spaces: 2 });
12364
12651
  console.log(chalk29.green(`\u2714 Config reset: ${configPath}`));
12365
12652
  return;
12366
12653
  }
@@ -12404,7 +12691,7 @@ File: ${KEY_STORE_FILE}`));
12404
12691
  }
12405
12692
  updated.maxErrorCycles = cycles;
12406
12693
  }
12407
- await fs29.writeJson(configPath, updated, { spaces: 2 });
12694
+ await fs30.writeJson(configPath, updated, { spaces: 2 });
12408
12695
  console.log(chalk29.green(`\u2714 Config saved to ${configPath}`));
12409
12696
  console.log(JSON.stringify(updated, null, 2));
12410
12697
  });
@@ -12412,14 +12699,10 @@ File: ${KEY_STORE_FILE}`));
12412
12699
 
12413
12700
  // cli/commands/model.ts
12414
12701
  init_spec_generator();
12415
- import * as path29 from "path";
12416
- import * as fs30 from "fs-extra";
12417
12702
  import chalk30 from "chalk";
12418
- import { input as input3, select as select7, confirm as confirm2 } from "@inquirer/prompts";
12703
+ import { input as input4, select as select9, confirm as confirm3 } from "@inquirer/prompts";
12419
12704
  function registerModel(program2) {
12420
12705
  program2.command("model").description("Interactively switch the active AI provider/model and save to .ai-spec.json").option("--list", "List all available providers and models").action(async (opts) => {
12421
- const currentDir = process.cwd();
12422
- const configPath = path29.join(currentDir, CONFIG_FILE);
12423
12706
  if (opts.list) {
12424
12707
  console.log(chalk30.bold("\nAvailable providers & models:\n"));
12425
12708
  for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
@@ -12436,8 +12719,9 @@ function registerModel(program2) {
12436
12719
  }
12437
12720
  return;
12438
12721
  }
12439
- const existing = await loadConfig(currentDir);
12722
+ const existing = await loadGlobalConfig();
12440
12723
  console.log(chalk30.blue("\n\u2500\u2500\u2500 Model Switcher \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12724
+ console.log(chalk30.gray(` Config: ${GLOBAL_CONFIG_FILE}`));
12441
12725
  if (Object.keys(existing).length > 0) {
12442
12726
  console.log(
12443
12727
  chalk30.gray(
@@ -12446,7 +12730,7 @@ function registerModel(program2) {
12446
12730
  );
12447
12731
  }
12448
12732
  console.log();
12449
- const target = await select7({
12733
+ const target = await select9({
12450
12734
  message: "Configure model for:",
12451
12735
  choices: [
12452
12736
  { name: "Spec generation (used for spec writing & refinement)", value: "spec" },
@@ -12455,7 +12739,7 @@ function registerModel(program2) {
12455
12739
  ]
12456
12740
  });
12457
12741
  async function pickProviderAndModel(label) {
12458
- const providerKey = await select7({
12742
+ const providerKey = await select9({
12459
12743
  message: `${label} \u2014 select provider:`,
12460
12744
  choices: Object.entries(PROVIDER_CATALOG).map(([key, meta2]) => ({
12461
12745
  name: `${meta2.displayName.padEnd(22)} ${chalk30.gray(meta2.description)}`,
@@ -12468,12 +12752,12 @@ function registerModel(program2) {
12468
12752
  ...meta.models.map((m) => ({ name: m, value: m })),
12469
12753
  { name: chalk30.italic("\u270E Enter custom model name..."), value: "__custom__" }
12470
12754
  ];
12471
- let chosenModel = await select7({
12755
+ let chosenModel = await select9({
12472
12756
  message: `${label} \u2014 select model (${meta.displayName}):`,
12473
12757
  choices: modelChoices
12474
12758
  });
12475
12759
  if (chosenModel === "__custom__") {
12476
- chosenModel = await input3({
12760
+ chosenModel = await input4({
12477
12761
  message: "Enter model name:",
12478
12762
  validate: (v2) => v2.trim().length > 0 || "Model name cannot be empty"
12479
12763
  });
@@ -12518,14 +12802,14 @@ function registerModel(program2) {
12518
12802
  )
12519
12803
  );
12520
12804
  }
12521
- const ok = await confirm2({ message: "Save to .ai-spec.json?", default: true });
12805
+ const ok = await confirm3({ message: `Save to ${GLOBAL_CONFIG_FILE}?`, default: true });
12522
12806
  if (!ok) {
12523
12807
  console.log(chalk30.gray(" Cancelled."));
12524
12808
  return;
12525
12809
  }
12526
- await fs30.writeJson(configPath, updated, { spaces: 2 });
12810
+ await saveGlobalConfig(updated);
12527
12811
  console.log(chalk30.green(`
12528
- \u2714 Saved to ${configPath}`));
12812
+ \u2714 Saved to ${GLOBAL_CONFIG_FILE}`));
12529
12813
  const providerToCheck = updated.provider ?? "gemini";
12530
12814
  const envKey = ENV_KEY_MAP[providerToCheck];
12531
12815
  if (envKey && !process.env[envKey]) {
@@ -12542,14 +12826,14 @@ function registerModel(program2) {
12542
12826
  import * as path30 from "path";
12543
12827
  import * as fs31 from "fs-extra";
12544
12828
  import chalk31 from "chalk";
12545
- import { input as input4, select as select8, confirm as confirm3 } from "@inquirer/prompts";
12829
+ import { input as input5, select as select10, confirm as confirm4 } from "@inquirer/prompts";
12546
12830
  function registerWorkspace(program2) {
12547
12831
  const workspaceCmd = program2.command("workspace").description("Manage multi-repo workspace configuration");
12548
12832
  workspaceCmd.command("init").description(`Interactive workspace setup \u2014 creates ${WORKSPACE_CONFIG_FILE}`).action(async () => {
12549
12833
  const currentDir = process.cwd();
12550
12834
  const configPath = path30.join(currentDir, WORKSPACE_CONFIG_FILE);
12551
12835
  if (await fs31.pathExists(configPath)) {
12552
- const overwrite = await confirm3({
12836
+ const overwrite = await confirm4({
12553
12837
  message: `${WORKSPACE_CONFIG_FILE} already exists. Overwrite?`,
12554
12838
  default: false
12555
12839
  });
@@ -12559,12 +12843,12 @@ function registerWorkspace(program2) {
12559
12843
  }
12560
12844
  }
12561
12845
  console.log(chalk31.blue("\n\u2500\u2500\u2500 Workspace Setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
12562
- const workspaceName = await input4({
12846
+ const workspaceName = await input5({
12563
12847
  message: "Workspace name:",
12564
12848
  validate: (v2) => v2.trim().length > 0 || "Name cannot be empty"
12565
12849
  });
12566
12850
  const repos = [];
12567
- const useAutoScan = await confirm3({
12851
+ const useAutoScan = await confirm4({
12568
12852
  message: "Auto-scan sibling directories for repos?",
12569
12853
  default: true
12570
12854
  });
@@ -12578,7 +12862,7 @@ function registerWorkspace(program2) {
12578
12862
  for (const r of detected) {
12579
12863
  console.log(chalk31.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
12580
12864
  }
12581
- const keepAll = await confirm3({
12865
+ const keepAll = await confirm4({
12582
12866
  message: `Include all ${detected.length} detected repo(s)?`,
12583
12867
  default: true
12584
12868
  });
@@ -12586,7 +12870,7 @@ function registerWorkspace(program2) {
12586
12870
  repos.push(...detected);
12587
12871
  } else {
12588
12872
  for (const r of detected) {
12589
- const keep = await confirm3({
12873
+ const keep = await confirm4({
12590
12874
  message: `Include "${r.name}" (${r.role}, ${r.type})?`,
12591
12875
  default: true
12592
12876
  });
@@ -12610,14 +12894,14 @@ function registerWorkspace(program2) {
12610
12894
  { name: "react-native (React Native mobile)", value: "react-native" },
12611
12895
  { name: "unknown", value: "unknown" }
12612
12896
  ];
12613
- let addMore = await confirm3({
12897
+ let addMore = await confirm4({
12614
12898
  message: repos.length > 0 ? "Manually add more repos?" : "Add repos manually?",
12615
12899
  default: repos.length === 0
12616
12900
  });
12617
12901
  while (addMore) {
12618
12902
  console.log(chalk31.cyan(`
12619
12903
  Adding repo #${repos.length + 1}`));
12620
- const repoName = await input4({
12904
+ const repoName = await input5({
12621
12905
  message: "Repo name (e.g. api, web, app):",
12622
12906
  validate: (v2) => {
12623
12907
  if (!v2.trim()) return "Name cannot be empty";
@@ -12625,7 +12909,7 @@ function registerWorkspace(program2) {
12625
12909
  return true;
12626
12910
  }
12627
12911
  });
12628
- const repoPath = await input4({
12912
+ const repoPath = await input5({
12629
12913
  message: `Relative path to "${repoName}" from here (default: ./${repoName}):`,
12630
12914
  default: `./${repoName}`
12631
12915
  });
@@ -12640,12 +12924,12 @@ function registerWorkspace(program2) {
12640
12924
  } else {
12641
12925
  console.log(chalk31.yellow(` Path "${absPath}" not found \u2014 type/role will be manual.`));
12642
12926
  }
12643
- const repoType = await select8({
12927
+ const repoType = await select10({
12644
12928
  message: `Repo type for "${repoName}":`,
12645
12929
  choices: repoTypeChoices,
12646
12930
  default: detectedType
12647
12931
  });
12648
- const repoRole = await select8({
12932
+ const repoRole = await select10({
12649
12933
  message: `Repo role for "${repoName}":`,
12650
12934
  choices: [
12651
12935
  { name: "backend", value: "backend" },
@@ -12662,7 +12946,7 @@ function registerWorkspace(program2) {
12662
12946
  role: repoRole
12663
12947
  });
12664
12948
  console.log(chalk31.green(` \u2714 Added: ${repoName} (${repoRole}, ${repoType})`));
12665
- addMore = await confirm3({
12949
+ addMore = await confirm4({
12666
12950
  message: "Add another repo?",
12667
12951
  default: false
12668
12952
  });
@@ -12673,7 +12957,7 @@ function registerWorkspace(program2) {
12673
12957
  for (const r of repos) {
12674
12958
  console.log(chalk31.gray(` - ${r.name}: ${r.role} (${r.type}) at ${r.path}`));
12675
12959
  }
12676
- const ok = await confirm3({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
12960
+ const ok = await confirm4({ message: `Save to ${WORKSPACE_CONFIG_FILE}?`, default: true });
12677
12961
  if (!ok) {
12678
12962
  console.log(chalk31.gray(" Cancelled."));
12679
12963
  return;
@@ -12718,7 +13002,7 @@ init_spec_generator();
12718
13002
  import * as path32 from "path";
12719
13003
  import * as fs33 from "fs-extra";
12720
13004
  import chalk33 from "chalk";
12721
- import { input as input5 } from "@inquirer/prompts";
13005
+ import { input as input6 } from "@inquirer/prompts";
12722
13006
 
12723
13007
  // core/spec-updater.ts
12724
13008
  import chalk32 from "chalk";
@@ -12952,7 +13236,7 @@ function registerUpdate(program2) {
12952
13236
  const currentDir = process.cwd();
12953
13237
  const config2 = await loadConfig(currentDir);
12954
13238
  if (!change) {
12955
- change = await input5({
13239
+ change = await input6({
12956
13240
  message: "Describe the change you want to make:",
12957
13241
  validate: (v2) => v2.trim().length > 0 || "Change description cannot be empty"
12958
13242
  });
@@ -13270,7 +13554,7 @@ function buildYamlDoc(obj) {
13270
13554
  return `${k2}: ${valStr}`;
13271
13555
  }).join("\n") + "\n";
13272
13556
  }
13273
- function dslToOpenApi(dsl, serverUrl = "http://localhost:3000") {
13557
+ function dslToOpenApi(dsl, serverUrl = DEFAULT_OPENAPI_SERVER_URL) {
13274
13558
  const info = {
13275
13559
  title: dsl.feature.title,
13276
13560
  description: dsl.feature.description,
@@ -13317,7 +13601,7 @@ function dslToOpenApi(dsl, serverUrl = "http://localhost:3000") {
13317
13601
  }
13318
13602
  async function exportOpenApi(dsl, projectDir, opts = {}) {
13319
13603
  const format = opts.format ?? "yaml";
13320
- const serverUrl = opts.serverUrl ?? "http://localhost:3000";
13604
+ const serverUrl = opts.serverUrl ?? DEFAULT_OPENAPI_SERVER_URL;
13321
13605
  const defaultName = `openapi.${format}`;
13322
13606
  const outputPath = opts.outputPath ? path33.isAbsolute(opts.outputPath) ? opts.outputPath : path33.join(projectDir, opts.outputPath) : path33.join(projectDir, defaultName);
13323
13607
  const doc = dslToOpenApi(dsl, serverUrl);
@@ -13513,12 +13797,12 @@ function registerMock(program2) {
13513
13797
 
13514
13798
  // cli/commands/learn.ts
13515
13799
  import chalk36 from "chalk";
13516
- import { input as input6 } from "@inquirer/prompts";
13800
+ import { input as input7 } from "@inquirer/prompts";
13517
13801
  function registerLearn(program2) {
13518
13802
  program2.command("learn").description("Append a lesson or engineering decision directly to constitution \xA79").argument("[lesson]", "The lesson or decision to record (prompted if omitted)").action(async (lesson) => {
13519
13803
  const currentDir = process.cwd();
13520
13804
  if (!lesson) {
13521
- lesson = await input6({
13805
+ lesson = await input7({
13522
13806
  message: "What lesson or engineering decision should be recorded?",
13523
13807
  validate: (v2) => v2.trim().length > 0 || "Please enter a lesson"
13524
13808
  });
@@ -13560,9 +13844,8 @@ import chalk39 from "chalk";
13560
13844
  import * as fs37 from "fs-extra";
13561
13845
  import * as path36 from "path";
13562
13846
  import chalk38 from "chalk";
13563
- var LOG_DIR2 = ".ai-spec-logs";
13564
13847
  async function loadRunLogs(workingDir) {
13565
- const logDir = path36.join(workingDir, LOG_DIR2);
13848
+ const logDir = path36.join(workingDir, DEFAULT_LOG_DIR);
13566
13849
  if (!await fs37.pathExists(logDir)) return [];
13567
13850
  const files = await fs37.readdir(logDir);
13568
13851
  const jsonFiles = new Set(files.filter((f) => f.endsWith(".json")));
@@ -13707,7 +13990,7 @@ function printTrendReport(report, workingDir) {
13707
13990
  ` ${chalk38.gray(formatDate(e.startedAt))} ${bar}${scoreStr} ${hash} ${dur}${errMark} ${spec}`
13708
13991
  );
13709
13992
  }
13710
- const logRelDir = path36.relative(workingDir, path36.join(workingDir, LOG_DIR2));
13993
+ const logRelDir = path36.relative(workingDir, path36.join(workingDir, DEFAULT_LOG_DIR));
13711
13994
  console.log(chalk38.gray(`
13712
13995
  ${entries.length} run(s) shown \xB7 logs: ${logRelDir}/`));
13713
13996
  console.log(chalk38.cyan("\u2500".repeat(63)));
@@ -14366,7 +14649,233 @@ function registerVcr(program2) {
14366
14649
 
14367
14650
  // cli/commands/scan.ts
14368
14651
  import chalk44 from "chalk";
14652
+ import * as path42 from "path";
14653
+
14654
+ // core/project-index.ts
14655
+ import * as fs42 from "fs-extra";
14369
14656
  import * as path41 from "path";
14657
+ var INDEX_FILE = ".ai-spec-index.json";
14658
+ var KEY_DEPS = [
14659
+ // Frameworks
14660
+ "express",
14661
+ "fastify",
14662
+ "koa",
14663
+ "@nestjs/core",
14664
+ "hapi",
14665
+ "next",
14666
+ "react",
14667
+ "vue",
14668
+ "nuxt",
14669
+ "svelte",
14670
+ "react-native",
14671
+ "expo",
14672
+ // DB / ORM
14673
+ "prisma",
14674
+ "@prisma/client",
14675
+ "mongoose",
14676
+ "typeorm",
14677
+ "sequelize",
14678
+ "drizzle-orm",
14679
+ // Auth
14680
+ "jsonwebtoken",
14681
+ "passport",
14682
+ "next-auth",
14683
+ "@clerk/nextjs",
14684
+ // Build / Lang
14685
+ "typescript",
14686
+ "vite",
14687
+ "webpack",
14688
+ "esbuild",
14689
+ "turbo",
14690
+ // Testing
14691
+ "jest",
14692
+ "vitest",
14693
+ "mocha",
14694
+ "cypress",
14695
+ "playwright",
14696
+ // Infra
14697
+ "redis",
14698
+ "bull",
14699
+ "socket.io",
14700
+ "graphql",
14701
+ "@trpc/server"
14702
+ ];
14703
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
14704
+ "node_modules",
14705
+ ".git",
14706
+ ".svn",
14707
+ "dist",
14708
+ "build",
14709
+ "out",
14710
+ ".next",
14711
+ ".nuxt",
14712
+ "coverage",
14713
+ ".turbo",
14714
+ ".cache",
14715
+ "__pycache__",
14716
+ "vendor",
14717
+ ".ai-spec-vcr",
14718
+ ".ai-spec-logs",
14719
+ "specs"
14720
+ ]);
14721
+ var MANIFEST_FILES = [
14722
+ "package.json",
14723
+ "go.mod",
14724
+ "Cargo.toml",
14725
+ "pom.xml",
14726
+ "build.gradle",
14727
+ "build.gradle.kts",
14728
+ "requirements.txt",
14729
+ "pyproject.toml",
14730
+ "setup.py",
14731
+ "composer.json"
14732
+ ];
14733
+ async function isProjectRoot(absPath) {
14734
+ for (const manifest of MANIFEST_FILES) {
14735
+ if (await fs42.pathExists(path41.join(absPath, manifest))) return true;
14736
+ }
14737
+ return false;
14738
+ }
14739
+ async function extractTechStack(absPath, type) {
14740
+ const stack = [];
14741
+ if (type === "go") stack.push("go");
14742
+ if (type === "rust") stack.push("rust");
14743
+ if (type === "java") stack.push("java");
14744
+ if (type === "python") stack.push("python");
14745
+ if (type === "php") stack.push("php");
14746
+ const pkgPath = path41.join(absPath, "package.json");
14747
+ if (!await fs42.pathExists(pkgPath)) return stack;
14748
+ let pkg = {};
14749
+ try {
14750
+ pkg = await fs42.readJson(pkgPath);
14751
+ } catch {
14752
+ return stack;
14753
+ }
14754
+ const allDeps = {
14755
+ ...pkg.dependencies ?? {},
14756
+ ...pkg.devDependencies ?? {}
14757
+ };
14758
+ const depKeys = new Set(Object.keys(allDeps));
14759
+ for (const dep of KEY_DEPS) {
14760
+ if (depKeys.has(dep)) stack.push(dep);
14761
+ }
14762
+ return stack;
14763
+ }
14764
+ async function discoverProjects(rootDir, maxDepth) {
14765
+ const found = [];
14766
+ async function walk(absDir, depth) {
14767
+ if (depth > maxDepth) return;
14768
+ let entries;
14769
+ try {
14770
+ entries = await fs42.readdir(absDir, { withFileTypes: true });
14771
+ } catch {
14772
+ return;
14773
+ }
14774
+ for (const entry of entries) {
14775
+ if (!entry.isDirectory()) continue;
14776
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
14777
+ const childAbs = path41.join(absDir, entry.name);
14778
+ const gitPath = path41.join(childAbs, ".git");
14779
+ if (await fs42.pathExists(gitPath)) {
14780
+ const gitStat = await fs42.stat(gitPath);
14781
+ if (gitStat.isFile()) continue;
14782
+ }
14783
+ if (await isProjectRoot(childAbs)) {
14784
+ found.push(path41.relative(rootDir, childAbs));
14785
+ } else {
14786
+ await walk(childAbs, depth + 1);
14787
+ }
14788
+ }
14789
+ }
14790
+ await walk(rootDir, 0);
14791
+ return found;
14792
+ }
14793
+ async function loadIndex(scanRoot) {
14794
+ const filePath = path41.join(scanRoot, INDEX_FILE);
14795
+ try {
14796
+ return await fs42.readJson(filePath);
14797
+ } catch {
14798
+ return null;
14799
+ }
14800
+ }
14801
+ async function saveIndex(scanRoot, index) {
14802
+ const filePath = path41.join(scanRoot, INDEX_FILE);
14803
+ await fs42.writeJson(filePath, index, { spaces: 2 });
14804
+ return filePath;
14805
+ }
14806
+ async function runScan(scanRoot, maxDepth = 2) {
14807
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14808
+ const existing = await loadIndex(scanRoot);
14809
+ const existingMap = new Map(
14810
+ (existing?.projects ?? []).map((p) => [p.path, p])
14811
+ );
14812
+ const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
14813
+ const added = [];
14814
+ const updated = [];
14815
+ const unchanged = [];
14816
+ const seenPaths = /* @__PURE__ */ new Set();
14817
+ for (const relPath of discoveredPaths) {
14818
+ const absPath = path41.join(scanRoot, relPath);
14819
+ seenPaths.add(relPath);
14820
+ const { type, role } = await detectRepoType(absPath);
14821
+ const techStack = await extractTechStack(absPath, type);
14822
+ const hasConstitution = await fs42.pathExists(path41.join(absPath, CONSTITUTION_FILE));
14823
+ const hasWorkspace = await fs42.pathExists(path41.join(absPath, WORKSPACE_CONFIG_FILE));
14824
+ const name = path41.basename(relPath);
14825
+ const prev = existingMap.get(relPath);
14826
+ if (!prev) {
14827
+ const entry = {
14828
+ name,
14829
+ path: relPath,
14830
+ type,
14831
+ role,
14832
+ techStack,
14833
+ hasConstitution,
14834
+ hasWorkspace,
14835
+ firstSeen: now,
14836
+ lastSeen: now
14837
+ };
14838
+ added.push(entry);
14839
+ existingMap.set(relPath, entry);
14840
+ } else {
14841
+ const changed = prev.type !== type || prev.role !== role || prev.hasConstitution !== hasConstitution || prev.hasWorkspace !== hasWorkspace || JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
14842
+ const entry = {
14843
+ ...prev,
14844
+ type,
14845
+ role,
14846
+ techStack,
14847
+ hasConstitution,
14848
+ hasWorkspace,
14849
+ lastSeen: now,
14850
+ missing: void 0
14851
+ // clear missing flag if it came back
14852
+ };
14853
+ existingMap.set(relPath, entry);
14854
+ if (changed) {
14855
+ updated.push(entry);
14856
+ } else {
14857
+ unchanged.push(entry);
14858
+ }
14859
+ }
14860
+ }
14861
+ const nowMissing = [];
14862
+ for (const [relPath, entry] of existingMap) {
14863
+ if (!seenPaths.has(relPath) && !entry.missing) {
14864
+ const gone = { ...entry, missing: true };
14865
+ existingMap.set(relPath, gone);
14866
+ nowMissing.push(gone);
14867
+ }
14868
+ }
14869
+ const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
14870
+ const index = {
14871
+ scanRoot,
14872
+ lastScanned: now,
14873
+ projects
14874
+ };
14875
+ return { index, added, updated, unchanged, nowMissing };
14876
+ }
14877
+
14878
+ // cli/commands/scan.ts
14370
14879
  var ROLE_COLOR = {
14371
14880
  backend: chalk44.blue,
14372
14881
  frontend: chalk44.green,
@@ -14440,7 +14949,7 @@ Scanning ${cwd} (depth: ${maxDepth})...`));
14440
14949
  }
14441
14950
  console.log(chalk44.cyan("\n\u2500".repeat(52)));
14442
14951
  console.log(chalk44.gray(" \xA7C = has constitution W = workspace root"));
14443
- console.log(chalk44.gray(` Index saved : ${path41.relative(cwd, path41.join(cwd, INDEX_FILE))}`));
14952
+ console.log(chalk44.gray(` Index saved : ${path42.relative(cwd, path42.join(cwd, INDEX_FILE))}`));
14444
14953
  console.log(chalk44.gray(` Next steps : ai-spec scan --list | ai-spec init [--global]`));
14445
14954
  });
14446
14955
  }
@@ -14448,7 +14957,7 @@ Scanning ${cwd} (depth: ${maxDepth})...`));
14448
14957
  // cli/index.ts
14449
14958
  dotenv.config();
14450
14959
  var program = new Command();
14451
- program.name("ai-spec").description("AI-driven Development Orchestrator \u2014 spec, generate, review").version("0.14.1");
14960
+ program.name("ai-spec").description("AI-driven Development Orchestrator \u2014 spec, generate, review").version(require_package().version);
14452
14961
  registerCreate(program);
14453
14962
  registerReview(program);
14454
14963
  registerInit(program);