ai-spec-dev 0.41.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/utils.ts CHANGED
@@ -1,19 +1,25 @@
1
1
  import * as path from "path";
2
2
  import * as fs from "fs-extra";
3
+ import * as os from "os";
3
4
  import chalk from "chalk";
4
5
  import { input, select } from "@inquirer/prompts";
5
6
  import { CodeGenMode } from "../core/code-generator";
6
- import { ENV_KEY_MAP } from "../core/spec-generator";
7
+ import { ENV_KEY_MAP, PROVIDER_CATALOG } from "../core/spec-generator";
7
8
  import { getSavedKey, saveKey, KEY_STORE_FILE } from "../core/key-store";
8
9
 
9
10
  // ─── Config ───────────────────────────────────────────────────────────────────
10
11
 
11
- export interface AiSpecConfig {
12
+ /** User-level preferences (stored in ~/.ai-spec-config.json) */
13
+ export interface AiSpecGlobalConfig {
12
14
  provider?: string;
13
15
  model?: string;
14
16
  codegen?: CodeGenMode;
15
17
  codegenProvider?: string;
16
18
  codegenModel?: string;
19
+ }
20
+
21
+ /** Full merged config (global + project-level overrides) */
22
+ export interface AiSpecConfig extends AiSpecGlobalConfig {
17
23
  /** Minimum overall spec score (1-10) required to pass Approval Gate. 0 = disabled (default). */
18
24
  minSpecScore?: number;
19
25
  /** Minimum harness score (1-10) required for pipeline success. 0 = disabled (default). */
@@ -22,16 +28,70 @@ export interface AiSpecConfig {
22
28
  maxErrorCycles?: number;
23
29
  /** §9 lesson count threshold for auto-consolidation (default: 12). */
24
30
  autoConsolidateThreshold?: number;
31
+
32
+ // ── Directory & file overrides ─────────────────────────────────────────────
33
+ /** Run log directory (default: ".ai-spec-logs") */
34
+ logDir?: string;
35
+ /** VCR recording directory (default: ".ai-spec-vcr") */
36
+ vcrDir?: string;
37
+ /** File backup directory (default: ".ai-spec-backup") */
38
+ backupDir?: string;
39
+ /** Review history file (default: ".ai-spec-reviews.json") */
40
+ reviewHistoryFile?: string;
41
+
42
+ // ── URL overrides ──────────────────────────────────────────────────────────
43
+ /** Default server URL for OpenAPI export (default: "http://localhost:3000") */
44
+ openApiServerUrl?: string;
45
+
46
+ // ── Numeric limits ─────────────────────────────────────────────────────────
47
+ /** Max chars captured from build/test/lint command output (default: 30000) */
48
+ maxCommandOutputChars?: number;
49
+ /** Max chars of source file sent to AI for auto-fix (default: 60000) */
50
+ maxFixFileChars?: number;
51
+ /** Max DSL extraction retries (default: 2) */
52
+ dslMaxRetries?: number;
53
+ /** Max constitution chars in codegen prompt (default: 4000) */
54
+ maxConstitutionChars?: number;
55
+ /** Per-provider token budget overrides (e.g. { "gemini": 900000, "claude": 180000 }) */
56
+ providerTokenBudgets?: Record<string, number>;
25
57
  }
26
58
 
27
59
  export const CONFIG_FILE = ".ai-spec.json";
60
+ export const GLOBAL_CONFIG_FILE = path.join(os.homedir(), ".ai-spec-config.json");
61
+
62
+ /** Load global user-level config from ~/.ai-spec-config.json */
63
+ export async function loadGlobalConfig(): Promise<AiSpecGlobalConfig> {
64
+ try {
65
+ if (await fs.pathExists(GLOBAL_CONFIG_FILE)) {
66
+ return await fs.readJson(GLOBAL_CONFIG_FILE);
67
+ }
68
+ } catch { /* ignore */ }
69
+ return {};
70
+ }
28
71
 
72
+ /** Save global user-level config to ~/.ai-spec-config.json */
73
+ export async function saveGlobalConfig(config: AiSpecGlobalConfig): Promise<void> {
74
+ await fs.ensureFile(GLOBAL_CONFIG_FILE);
75
+ await fs.writeJson(GLOBAL_CONFIG_FILE, config, { spaces: 2 });
76
+ }
77
+
78
+ /**
79
+ * Load merged config: global (baseline) + project-level (override).
80
+ * Provider/model from global, project-specific settings from local .ai-spec.json.
81
+ */
29
82
  export async function loadConfig(dir: string): Promise<AiSpecConfig> {
83
+ const globalConfig = await loadGlobalConfig();
84
+
85
+ let localConfig: AiSpecConfig = {};
30
86
  const p = path.join(dir, CONFIG_FILE);
31
87
  if (await fs.pathExists(p)) {
32
- return fs.readJson(p);
88
+ try {
89
+ localConfig = await fs.readJson(p);
90
+ } catch { /* ignore */ }
33
91
  }
34
- return {};
92
+
93
+ // Local overrides global
94
+ return { ...globalConfig, ...localConfig };
35
95
  }
36
96
 
37
97
  // ─── API Key Resolution ───────────────────────────────────────────────────────
@@ -45,6 +105,14 @@ export async function resolveApiKey(
45
105
  const envVar = ENV_KEY_MAP[providerName];
46
106
  if (envVar && process.env[envVar]) return process.env[envVar]!;
47
107
 
108
+ // Check fallback env vars (e.g. MiMo reads ANTHROPIC_AUTH_TOKEN from token-plan)
109
+ const meta = PROVIDER_CATALOG[providerName];
110
+ if (meta?.fallbackEnvKeys) {
111
+ for (const key of meta.fallbackEnvKeys) {
112
+ if (process.env[key]) return process.env[key]!;
113
+ }
114
+ }
115
+
48
116
  const savedKey = await getSavedKey(providerName);
49
117
  if (savedKey) {
50
118
  const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);
package/core/cli-ui.ts ADDED
@@ -0,0 +1,136 @@
1
+ import chalk from "chalk";
2
+
3
+ // ─── Spinner ──────────────────────────────────────────────────────────────────
4
+
5
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+
7
+ export interface Spinner {
8
+ /** Update the text shown after the spinner. */
9
+ update(text: string): void;
10
+ /** Stop the spinner and show a final message. */
11
+ stop(finalText?: string): void;
12
+ /** Stop with a success (✔) mark. */
13
+ succeed(text: string): void;
14
+ /** Stop with a failure (✘) mark. */
15
+ fail(text: string): void;
16
+ }
17
+
18
+ /**
19
+ * Start a CLI spinner that renders on a single line.
20
+ * Works in any TTY; silently degrades to static text in non-TTY (CI).
21
+ */
22
+ export function startSpinner(text: string): Spinner {
23
+ const isTTY = process.stderr.isTTY;
24
+ let frame = 0;
25
+ let currentText = text;
26
+ let stopped = false;
27
+
28
+ function render() {
29
+ if (stopped) return;
30
+ const symbol = chalk.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
31
+ if (isTTY) {
32
+ process.stderr.write(`\r ${symbol} ${currentText}${" ".repeat(10)}`);
33
+ }
34
+ frame++;
35
+ }
36
+
37
+ // Print initial line for non-TTY
38
+ if (!isTTY) {
39
+ process.stderr.write(` … ${currentText}\n`);
40
+ }
41
+
42
+ const timer = setInterval(render, 80);
43
+ render();
44
+
45
+ return {
46
+ update(newText: string) {
47
+ currentText = newText;
48
+ },
49
+ stop(finalText?: string) {
50
+ if (stopped) return;
51
+ stopped = true;
52
+ clearInterval(timer);
53
+ if (isTTY) {
54
+ process.stderr.write(`\r${" ".repeat(currentText.length + 20)}\r`);
55
+ }
56
+ if (finalText) {
57
+ process.stderr.write(` ${finalText}\n`);
58
+ }
59
+ },
60
+ succeed(successText: string) {
61
+ this.stop(chalk.green(`✔ ${successText}`));
62
+ },
63
+ fail(failText: string) {
64
+ this.stop(chalk.red(`✘ ${failText}`));
65
+ },
66
+ };
67
+ }
68
+
69
+ // ─── Retry Countdown ──────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Show an animated countdown during retry wait.
73
+ * Displays error details + a live seconds countdown.
74
+ */
75
+ export async function retryCountdown(opts: {
76
+ attempt: number;
77
+ maxAttempts: number;
78
+ waitMs: number;
79
+ errorMessage: string;
80
+ label: string;
81
+ }): Promise<void> {
82
+ const { attempt, maxAttempts, waitMs, errorMessage, label } = opts;
83
+ const isTTY = process.stderr.isTTY;
84
+
85
+ // Error box
86
+ const shortErr = errorMessage.length > 120
87
+ ? errorMessage.slice(0, 117) + "..."
88
+ : errorMessage;
89
+
90
+ process.stderr.write("\n");
91
+ process.stderr.write(chalk.yellow(` ┌─ Retry ${attempt}/${maxAttempts} `) + chalk.gray(`[${label}]`) + chalk.yellow(` ${"─".repeat(Math.max(1, 40 - label.length))}\n`));
92
+ process.stderr.write(chalk.yellow(` │ `) + chalk.white(shortErr) + "\n");
93
+ process.stderr.write(chalk.yellow(` │ `) + chalk.gray(`Waiting before retry...`) + "\n");
94
+
95
+ // Animated countdown
96
+ const totalSeconds = Math.ceil(waitMs / 1000);
97
+ for (let s = totalSeconds; s > 0; s--) {
98
+ const bar = chalk.green("█".repeat(totalSeconds - s)) + chalk.gray("░".repeat(s));
99
+ const line = chalk.yellow(` │ `) + `${bar} ${chalk.bold.white(`${s}s`)}`;
100
+ if (isTTY) {
101
+ process.stderr.write(`\r${line}${" ".repeat(10)}`);
102
+ }
103
+ await new Promise<void>((r) => setTimeout(r, 1000));
104
+ }
105
+
106
+ if (isTTY) {
107
+ process.stderr.write(`\r${" ".repeat(70)}\r`);
108
+ }
109
+ process.stderr.write(chalk.yellow(` └─ `) + chalk.cyan(`Retrying now...`) + "\n\n");
110
+ }
111
+
112
+ // ─── Stage Progress ───────────────────────────────────────────────────────────
113
+
114
+ const STAGE_ICONS: Record<string, string> = {
115
+ context_load: "📂",
116
+ design_dialogue: "💬",
117
+ spec_gen: "📝",
118
+ spec_refine: "✏️ ",
119
+ spec_assess: "📊",
120
+ dsl_extract: "🔗",
121
+ dsl_gap_feedback: "🔍",
122
+ codegen: "⚙️ ",
123
+ test_gen: "🧪",
124
+ error_feedback: "🔧",
125
+ review: "🔎",
126
+ self_eval: "📈",
127
+ };
128
+
129
+ /**
130
+ * Start a pipeline stage with a spinner.
131
+ * Returns a handle to succeed/fail/update the stage display.
132
+ */
133
+ export function startStage(stageKey: string, label: string): Spinner {
134
+ const icon = STAGE_ICONS[stageKey] ?? "▸";
135
+ return startSpinner(`${icon} ${label}`);
136
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "./codegen/helpers";
23
23
  import { topoSortLayerTasks, printTaskProgress, LAYER_ICONS } from "./codegen/topo-sort";
24
24
  import { estimateTokens, getDefaultBudget } from "./token-budget";
25
+ import { startSpinner } from "./cli-ui";
25
26
 
26
27
  // Re-export public symbols for backward compatibility
27
28
  export { extractBehavioralContract } from "./codegen/helpers";
@@ -654,6 +655,7 @@ ${constitutionSection}
654
655
  === ${existingContent ? "Existing content (modify and return the complete file)" : "Create this file from scratch"} ===
655
656
  ${existingContent || "Output only the complete file content."}`;
656
657
 
658
+ const fileSpinner = startSpinner(`${prefix}Generating ${chalk.bold(item.file)}...`);
657
659
  try {
658
660
  const raw = await this.provider.generate(codePrompt, systemPrompt);
659
661
  const fileContent = stripCodeFences(raw);
@@ -661,11 +663,11 @@ ${existingContent || "Output only the complete file content."}`;
661
663
  await fs.ensureDir(path.dirname(fullPath));
662
664
  await fs.writeFile(fullPath, fileContent, "utf-8");
663
665
  getActiveLogger()?.fileWritten(item.file);
664
- console.log(`${prefix}${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)} ${chalk.green("✔")}`);
666
+ fileSpinner.succeed(`${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)}`);
665
667
  successCount++;
666
668
  writtenFiles.push(item.file);
667
669
  } catch (err) {
668
- console.log(`${prefix}${chalk.red("✘")} ${chalk.bold(item.file)} — ${chalk.red((err as Error).message)}`);
670
+ fileSpinner.fail(`${chalk.bold(item.file)} — ${(err as Error).message}`);
669
671
  }
670
672
  }
671
673
 
@@ -0,0 +1,44 @@
1
+ /**
2
+ * config-defaults.ts — Centralized default values for all configurable constants.
3
+ *
4
+ * Modules import their defaults from here instead of defining local magic numbers.
5
+ * The pipeline can override any value via AiSpecConfig at runtime.
6
+ */
7
+
8
+ // ─── Directory & File Names ─────────────────────────────────────────────────
9
+
10
+ export const DEFAULT_LOG_DIR = ".ai-spec-logs";
11
+ export const DEFAULT_VCR_DIR = ".ai-spec-vcr";
12
+ export const DEFAULT_BACKUP_DIR = ".ai-spec-backup";
13
+ export const DEFAULT_REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
14
+
15
+ // ─── URLs ───────────────────────────────────────────────────────────────────
16
+
17
+ export const DEFAULT_OPENAPI_SERVER_URL = "http://localhost:3000";
18
+
19
+ // ─── Numeric Limits ─────────────────────────────────────────────────────────
20
+
21
+ /** Max chars captured from build/test/lint command output before parsing. */
22
+ export const DEFAULT_MAX_COMMAND_OUTPUT_CHARS = 30_000;
23
+
24
+ /** Max chars of an existing file sent to the AI for auto-fix. */
25
+ export const DEFAULT_MAX_FIX_FILE_CHARS = 60_000;
26
+
27
+ /** Max DSL extraction retries on parse failure. */
28
+ export const DEFAULT_DSL_MAX_RETRIES = 2;
29
+
30
+ /** Max constitution chars in codegen prompts (trimmed if exceeded). */
31
+ export const DEFAULT_MAX_CONSTITUTION_CHARS = 4_000;
32
+
33
+ /** Max chars of file content sent per file in review (reviewFiles mode). */
34
+ export const DEFAULT_MAX_REVIEW_FILE_CHARS = 3_000;
35
+
36
+ // ─── Token Budgets ──────────────────────────────────────────────────────────
37
+
38
+ export const DEFAULT_TOKEN_BUDGETS: Record<string, number> = {
39
+ gemini: 900_000,
40
+ claude: 180_000,
41
+ openai: 120_000,
42
+ deepseek: 60_000,
43
+ default: 100_000,
44
+ };
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import { AIProvider } from "./spec-generator";
5
5
  import { ContextLoader, ProjectContext } from "./context-loader";
6
6
  import { constitutionSystemPrompt } from "../prompts/constitution.prompt";
7
+ import { DEFAULT_MAX_CONSTITUTION_CHARS } from "./config-defaults";
7
8
 
8
9
  export const CONSTITUTION_FILE = ".ai-spec-constitution.md";
9
10
 
@@ -41,7 +42,7 @@ function buildConstitutionPrompt(context: ProjectContext, projectRoot: string):
41
42
  }
42
43
 
43
44
  if (context.schema) {
44
- parts.push(`=== Prisma Schema ===\n${context.schema.slice(0, 4000)}\n`);
45
+ parts.push(`=== Prisma Schema ===\n${context.schema.slice(0, DEFAULT_MAX_CONSTITUTION_CHARS)}\n`);
45
46
  }
46
47
 
47
48
  if (context.errorPatterns) {
@@ -13,6 +13,7 @@ import {
13
13
  } from "../prompts/dsl.prompt";
14
14
  import { estimateTokens, getDefaultBudget } from "./token-budget";
15
15
  import { parseJsonFromAiOutput } from "./safe-json";
16
+ import { DEFAULT_DSL_MAX_RETRIES } from "./config-defaults";
16
17
 
17
18
  // ─── DSL Sanitizer ───────────────────────────────────────────────────────────
18
19
 
@@ -50,7 +51,7 @@ function sanitizeDsl(raw: unknown): unknown {
50
51
  // ─── Constants ────────────────────────────────────────────────────────────────
51
52
 
52
53
  /** Maximum AI attempts (1 initial + up to this many retries). */
53
- const MAX_RETRIES = 2;
54
+ const MAX_RETRIES = DEFAULT_DSL_MAX_RETRIES;
54
55
 
55
56
  /** Default maximum spec length passed to AI. Overridden by token budget when provider is known. */
56
57
  const DEFAULT_MAX_SPEC_CHARS = 12_000;
@@ -7,6 +7,8 @@ import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
7
7
  import { SpecDSL } from "./dsl-types";
8
8
  import { buildDslContextSection } from "./dsl-extractor";
9
9
  import { getActiveSnapshot } from "./run-snapshot";
10
+ import { startSpinner } from "./cli-ui";
11
+ import { DEFAULT_MAX_COMMAND_OUTPUT_CHARS, DEFAULT_MAX_FIX_FILE_CHARS } from "./config-defaults";
10
12
 
11
13
  // ─── Types ──────────────────────────────────────────────────────────────────────
12
14
 
@@ -29,14 +31,14 @@ interface FixResult {
29
31
  * ~10K tokens — enough for any realistic error listing; prevents a pathological
30
32
  * build output (e.g. 10MB of warnings) from ballooning the AI context.
31
33
  */
32
- const MAX_COMMAND_OUTPUT_CHARS = 50_000;
34
+ const MAX_COMMAND_OUTPUT_CHARS = DEFAULT_MAX_COMMAND_OUTPUT_CHARS;
33
35
 
34
36
  /**
35
37
  * Maximum characters of an existing file sent to the AI for auto-fix.
36
38
  * ~12K tokens — covers large files; content beyond this is truncated with a
37
39
  * notice so the AI knows it may be seeing an incomplete file.
38
40
  */
39
- const MAX_FIX_FILE_CHARS = 60_000;
41
+ const MAX_FIX_FILE_CHARS = DEFAULT_MAX_FIX_FILE_CHARS;
40
42
 
41
43
  // ─── Error Detection ────────────────────────────────────────────────────────────
42
44
 
@@ -369,16 +371,17 @@ ${fileContent}
369
371
 
370
372
  Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
371
373
 
374
+ const fixSpinner = startSpinner(`Fixing ${chalk.bold(file)} (${fileErrors.length} error(s))...`);
372
375
  try {
373
376
  const raw = await provider.generate(prompt, getCodeGenSystemPrompt());
374
377
  const fixed = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
375
378
  await getActiveSnapshot()?.snapshotFile(fullPath);
376
379
  await fs.writeFile(fullPath, fixed, "utf-8");
377
380
  results.push({ fixed: true, file, explanation: `Fixed ${fileErrors.length} error(s)` });
378
- console.log(chalk.green(`Auto-fixed: ${file}`));
381
+ fixSpinner.succeed(`Auto-fixed: ${file}`);
379
382
  } catch (err) {
380
383
  results.push({ fixed: false, file, explanation: `AI fix failed: ${(err as Error).message}` });
381
- console.log(chalk.yellow(`Could not auto-fix: ${file}`));
384
+ fixSpinner.fail(`Could not auto-fix: ${file}`);
382
385
  }
383
386
  }
384
387
 
@@ -1,6 +1,7 @@
1
1
  import * as path from "path";
2
2
  import * as fs from "fs-extra";
3
3
  import { SpecDSL, ApiEndpoint, DataModel, ModelField, FieldMap } from "./dsl-types";
4
+ import { DEFAULT_OPENAPI_SERVER_URL } from "./config-defaults";
4
5
 
5
6
  // ─── Types ────────────────────────────────────────────────────────────────────
6
7
 
@@ -272,7 +273,7 @@ function buildYamlDoc(obj: Record<string, unknown>): string {
272
273
  * Convert a SpecDSL to an OpenAPI 3.1.0 document.
273
274
  * Returns the document as a plain JS object (can be serialised to YAML or JSON).
274
275
  */
275
- export function dslToOpenApi(dsl: SpecDSL, serverUrl = "http://localhost:3000"): Record<string, unknown> {
276
+ export function dslToOpenApi(dsl: SpecDSL, serverUrl = DEFAULT_OPENAPI_SERVER_URL): Record<string, unknown> {
276
277
  // ── Info ──────────────────────────────────────────────────────────────────
277
278
  const info = {
278
279
  title: dsl.feature.title,
@@ -338,7 +339,7 @@ export async function exportOpenApi(
338
339
  opts: OpenApiExportOptions = {}
339
340
  ): Promise<string> {
340
341
  const format = opts.format ?? "yaml";
341
- const serverUrl = opts.serverUrl ?? "http://localhost:3000";
342
+ const serverUrl = opts.serverUrl ?? DEFAULT_OPENAPI_SERVER_URL;
342
343
  const defaultName = `openapi.${format}`;
343
344
  const outputPath = opts.outputPath
344
345
  ? path.isAbsolute(opts.outputPath)
@@ -1,6 +1,5 @@
1
1
  import chalk from "chalk";
2
-
3
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
2
+ import { retryCountdown } from "./cli-ui";
4
3
 
5
4
  // ─── Error Classification ──────────────────────────────────────────────────────
6
5
 
@@ -112,12 +111,14 @@ export async function withReliability<T>(
112
111
  throw classifyError(err, label);
113
112
  }
114
113
  const waitMs = attempt === 0 ? 2_000 : 6_000;
115
- console.warn(
116
- chalk.yellow(` ⚠ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${waitMs / 1000}s`) +
117
- chalk.gray(` — ${(err as Error).message}`)
118
- );
119
114
  onRetry?.(attempt + 1, err);
120
- await sleep(waitMs);
115
+ await retryCountdown({
116
+ attempt: attempt + 1,
117
+ maxAttempts: retries + 1,
118
+ waitMs,
119
+ errorMessage: (err as Error).message ?? String(err),
120
+ label,
121
+ });
121
122
  }
122
123
  }
123
124
  /* istanbul ignore next */
@@ -0,0 +1,95 @@
1
+ import * as fs from "fs-extra";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import { RepoType, RepoRole } from "./workspace-loader";
5
+
6
+ const REPO_STORE_FILE = path.join(os.homedir(), ".ai-spec-repos.json");
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────────
9
+
10
+ export interface RegisteredRepo {
11
+ /** Display name (defaults to directory basename) */
12
+ name: string;
13
+ /** Absolute path to the repo root */
14
+ path: string;
15
+ /** Auto-detected repo type */
16
+ type: RepoType;
17
+ /** Auto-detected repo role */
18
+ role: RepoRole;
19
+ /** Whether a project constitution has been generated */
20
+ hasConstitution: boolean;
21
+ /** ISO timestamp of registration */
22
+ registeredAt: string;
23
+ }
24
+
25
+ interface RepoStoreData {
26
+ repos: RegisteredRepo[];
27
+ }
28
+
29
+ // ─── Read / Write ────────────────────────────────────────────────────────────
30
+
31
+ async function readStore(): Promise<RepoStoreData> {
32
+ try {
33
+ if (await fs.pathExists(REPO_STORE_FILE)) {
34
+ return await fs.readJson(REPO_STORE_FILE);
35
+ }
36
+ } catch (err) {
37
+ console.warn(`Warning: Could not read repo store at ${REPO_STORE_FILE}: ${(err as Error).message}.`);
38
+ }
39
+ return { repos: [] };
40
+ }
41
+
42
+ async function writeStore(store: RepoStoreData): Promise<void> {
43
+ await fs.ensureFile(REPO_STORE_FILE);
44
+ await fs.writeJson(REPO_STORE_FILE, store, { spaces: 2 });
45
+ }
46
+
47
+ // ─── Public API ──────────────────────────────────────────────────────────────
48
+
49
+ /** Get all registered repos. */
50
+ export async function getRegisteredRepos(): Promise<RegisteredRepo[]> {
51
+ const store = await readStore();
52
+ return store.repos;
53
+ }
54
+
55
+ /** Find a registered repo by its absolute path. */
56
+ export async function getRepoByPath(absPath: string): Promise<RegisteredRepo | undefined> {
57
+ const store = await readStore();
58
+ return store.repos.find((r) => r.path === absPath);
59
+ }
60
+
61
+ /** Register a new repo. If already registered (by path), update it. */
62
+ export async function registerRepo(repo: RegisteredRepo): Promise<void> {
63
+ const store = await readStore();
64
+ const idx = store.repos.findIndex((r) => r.path === repo.path);
65
+ if (idx >= 0) {
66
+ store.repos[idx] = repo;
67
+ } else {
68
+ store.repos.push(repo);
69
+ }
70
+ await writeStore(store);
71
+ }
72
+
73
+ /** Remove a registered repo by path. */
74
+ export async function unregisterRepo(absPath: string): Promise<boolean> {
75
+ const store = await readStore();
76
+ const before = store.repos.length;
77
+ store.repos = store.repos.filter((r) => r.path !== absPath);
78
+ if (store.repos.length < before) {
79
+ await writeStore(store);
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /** Update the hasConstitution flag for a repo. */
86
+ export async function markRepoConstitution(absPath: string, has: boolean): Promise<void> {
87
+ const store = await readStore();
88
+ const repo = store.repos.find((r) => r.path === absPath);
89
+ if (repo) {
90
+ repo.hasConstitution = has;
91
+ await writeStore(store);
92
+ }
93
+ }
94
+
95
+ export { REPO_STORE_FILE };
package/core/reviewer.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  reviewImpactComplexitySystemPrompt,
11
11
  } from "../prompts/codegen.prompt";
12
12
  import { CONSTITUTION_FILE } from "./constitution-generator";
13
+ import { DEFAULT_REVIEW_HISTORY_FILE, DEFAULT_MAX_REVIEW_FILE_CHARS } from "./config-defaults";
13
14
 
14
15
  // ─── Constitution Lessons Helper ──────────────────────────────────────────────
15
16
 
@@ -48,7 +49,7 @@ interface ReviewHistoryEntry {
48
49
  complexityLevel?: "低" | "中" | "高";
49
50
  }
50
51
 
51
- const REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
52
+ const REVIEW_HISTORY_FILE = DEFAULT_REVIEW_HISTORY_FILE;
52
53
 
53
54
  async function loadReviewHistory(projectRoot: string): Promise<ReviewHistoryEntry[]> {
54
55
  const historyPath = path.join(projectRoot, REVIEW_HISTORY_FILE);
@@ -225,16 +226,19 @@ ${codeContext}`;
225
226
  console.log(chalk.gray(" Pass 2/3: Implementation review..."));
226
227
 
227
228
  // ── Pass 2: Implementation + History ─────────────────────────────────────
229
+ // Token savings: Pass 2/3 receive a spec digest instead of the full spec,
230
+ // and omit the raw code context (Pass 1 already analyzed it).
231
+ const specDigest = specContent && specContent.length > 600
232
+ ? specContent.slice(0, 600) + "\n... [spec truncated — see Pass 0/1 for full text]"
233
+ : specContent || "(No spec)";
234
+
228
235
  const history = await loadReviewHistory(this.projectRoot);
229
236
  const historyContext = buildHistoryContext(history);
230
237
 
231
238
  const implPrompt = `Review the implementation details of this change.
232
239
 
233
- === Feature Spec ===
234
- ${specContent || "(No spec — review for general code quality)"}
235
-
236
- === Code ===
237
- ${codeContext}
240
+ === Feature Spec (digest — full spec was provided in Pass 0/1) ===
241
+ ${specDigest}
238
242
 
239
243
  === Architecture Review (Pass 1 — do NOT repeat these findings) ===
240
244
  ${archReview}
@@ -246,11 +250,8 @@ ${historyContext}`;
246
250
  // ── Pass 3: Impact & Complexity ───────────────────────────────────────────
247
251
  const impactPrompt = `Assess the impact and complexity of this change.
248
252
 
249
- === Feature Spec ===
250
- ${specContent || "(No spec — review for general code quality)"}
251
-
252
- === Code ===
253
- ${codeContext}
253
+ === Feature Spec (digest) ===
254
+ ${specDigest}
254
255
 
255
256
  === Architecture Review (Pass 1 — do NOT repeat) ===
256
257
  ${archReview}
@@ -338,8 +339,8 @@ ${implReview}`;
338
339
  const fullPath = path.join(workingDir, filePath);
339
340
  try {
340
341
  const content = await fs.readFile(fullPath, "utf-8");
341
- filesSection += `\n\n=== ${filePath} ===\n${content.slice(0, 3000)}`;
342
- if (content.length > 3000) filesSection += `\n... (truncated, ${content.length} chars total)`;
342
+ filesSection += `\n\n=== ${filePath} ===\n${content.slice(0, DEFAULT_MAX_REVIEW_FILE_CHARS)}`;
343
+ if (content.length > DEFAULT_MAX_REVIEW_FILE_CHARS) filesSection += `\n... (truncated, ${content.length} chars total)`;
343
344
  } catch {
344
345
  filesSection += `\n\n=== ${filePath} ===\n(file not found)`;
345
346
  }
@@ -1,8 +1,7 @@
1
1
  import * as fs from "fs-extra";
2
2
  import * as path from "path";
3
3
  import chalk from "chalk";
4
-
5
- const LOG_DIR = ".ai-spec-logs";
4
+ import { DEFAULT_LOG_DIR } from "./config-defaults";
6
5
 
7
6
  // ─── JSONL helpers ────────────────────────────────────────────────────────────
8
7
  // Each event is synchronously appended as one JSON line to a `.jsonl` shadow
@@ -131,8 +130,8 @@ export class RunLogger {
131
130
  meta?: { provider?: string; model?: string; specPath?: string }
132
131
  ) {
133
132
  this.startMs = Date.now();
134
- this.logPath = path.join(workingDir, LOG_DIR, `${runId}.json`);
135
- this.jsonlPath = path.join(workingDir, LOG_DIR, `${runId}.jsonl`);
133
+ this.logPath = path.join(workingDir, DEFAULT_LOG_DIR, `${runId}.json`);
134
+ this.jsonlPath = path.join(workingDir, DEFAULT_LOG_DIR, `${runId}.jsonl`);
136
135
  this.log = {
137
136
  runId,
138
137
  startedAt: new Date().toISOString(),
@@ -1,7 +1,6 @@
1
1
  import * as fs from "fs-extra";
2
2
  import * as path from "path";
3
-
4
- const BACKUP_DIR = ".ai-spec-backup";
3
+ import { DEFAULT_BACKUP_DIR } from "./config-defaults";
5
4
 
6
5
  // ─── RunSnapshot ──────────────────────────────────────────────────────────────
7
6
  /**
@@ -18,7 +17,7 @@ export class RunSnapshot {
18
17
  private readonly workingDir: string,
19
18
  readonly runId: string
20
19
  ) {
21
- this.backupRoot = path.join(workingDir, BACKUP_DIR, runId);
20
+ this.backupRoot = path.join(workingDir, DEFAULT_BACKUP_DIR, runId);
22
21
  }
23
22
 
24
23
  /**