ai-spec-dev 0.42.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -17
- package/cli/commands/create.ts +232 -11
- package/cli/commands/init.ts +310 -107
- package/cli/commands/model.ts +7 -11
- package/cli/index.ts +1 -1
- package/cli/utils.ts +72 -4
- package/core/config-defaults.ts +44 -0
- package/core/constitution-generator.ts +2 -1
- package/core/dsl-extractor.ts +2 -1
- package/core/error-feedback.ts +3 -2
- package/core/openapi-exporter.ts +3 -2
- package/core/repo-store.ts +95 -0
- package/core/reviewer.ts +14 -13
- package/core/run-logger.ts +3 -4
- package/core/run-snapshot.ts +2 -3
- package/core/run-trend.ts +3 -4
- package/core/spec-generator.ts +27 -42
- package/core/token-budget.ts +3 -8
- package/core/vcr.ts +3 -1
- package/dist/cli/index.js +919 -519
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +912 -512
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +43 -53
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +43 -53
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/demo-backend/.ai-spec-constitution.md +0 -65
- package/demo-backend/package.json +0 -21
- package/demo-backend/prisma/schema.prisma +0 -22
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +0 -186
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +0 -211
- package/demo-backend/src/controllers/bookmark.controller.test.ts +0 -255
- package/demo-backend/src/controllers/bookmark.controller.ts +0 -187
- package/demo-backend/src/index.ts +0 -17
- package/demo-backend/src/routes/bookmark.routes.test.ts +0 -264
- package/demo-backend/src/routes/bookmark.routes.ts +0 -11
- package/demo-backend/src/routes/index.ts +0 -8
- package/demo-backend/src/services/bookmark.service.test.ts +0 -433
- package/demo-backend/src/services/bookmark.service.ts +0 -261
- package/demo-backend/tsconfig.json +0 -12
- package/demo-frontend/.ai-spec-constitution.md +0 -95
- package/demo-frontend/package.json +0 -23
- package/demo-frontend/src/App.tsx +0 -12
- package/demo-frontend/src/main.tsx +0 -9
- package/demo-frontend/tsconfig.json +0 -13
package/cli/commands/init.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { Command } from "commander";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as fs from "fs-extra";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import {
|
|
5
|
+
import { input, select, confirm } from "@inquirer/prompts";
|
|
6
|
+
import { RepoRole } from "../../core/workspace-loader";
|
|
7
|
+
import { createProvider, DEFAULT_MODELS, SUPPORTED_PROVIDERS, AIProvider } from "../../core/spec-generator";
|
|
6
8
|
import { ContextLoader } from "../../core/context-loader";
|
|
7
9
|
import { ConstitutionGenerator, CONSTITUTION_FILE } from "../../core/constitution-generator";
|
|
8
10
|
import { ConstitutionConsolidator } from "../../core/constitution-consolidator";
|
|
@@ -17,11 +19,200 @@ import {
|
|
|
17
19
|
} from "../../prompts/global-constitution.prompt";
|
|
18
20
|
import { loadConfig, resolveApiKey } from "../utils";
|
|
19
21
|
import { loadIndex, ProjectEntry } from "../../core/project-index";
|
|
22
|
+
import { detectRepoType } from "../../core/workspace-loader";
|
|
23
|
+
import {
|
|
24
|
+
RegisteredRepo,
|
|
25
|
+
getRegisteredRepos,
|
|
26
|
+
registerRepo,
|
|
27
|
+
REPO_STORE_FILE,
|
|
28
|
+
} from "../../core/repo-store";
|
|
29
|
+
|
|
30
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const ROLE_LABELS: Record<RepoRole, string> = {
|
|
33
|
+
frontend: "frontend",
|
|
34
|
+
backend: "backend",
|
|
35
|
+
mobile: "mobile",
|
|
36
|
+
shared: "shared",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prompt user to select a repo role.
|
|
41
|
+
*/
|
|
42
|
+
async function promptRepoRole(): Promise<RepoRole> {
|
|
43
|
+
return select<RepoRole>({
|
|
44
|
+
message: "What type of repo is this?",
|
|
45
|
+
choices: [
|
|
46
|
+
{ name: "Frontend", value: "frontend" },
|
|
47
|
+
{ name: "Backend", value: "backend" },
|
|
48
|
+
{ name: "Mobile", value: "mobile" },
|
|
49
|
+
{ name: "Shared / Other", value: "shared" },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Prompt user for a repo absolute path with validation.
|
|
56
|
+
*/
|
|
57
|
+
async function promptRepoPath(role: RepoRole): Promise<string> {
|
|
58
|
+
const label = ROLE_LABELS[role];
|
|
59
|
+
const raw = await input({
|
|
60
|
+
message: `Enter your ${label} repo path (absolute path):`,
|
|
61
|
+
validate: (v) => {
|
|
62
|
+
const trimmed = v.trim();
|
|
63
|
+
if (trimmed.length === 0) return "Path cannot be empty";
|
|
64
|
+
if (!path.isAbsolute(trimmed)) return "Please provide an absolute path";
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Strip shell escape backslashes (e.g. "文稿\ -\ hongzhong" → "文稿 - hongzhong")
|
|
70
|
+
const cleaned = raw.trim().replace(/\\ /g, " ");
|
|
71
|
+
const resolved = path.resolve(cleaned);
|
|
72
|
+
if (!(await fs.pathExists(resolved))) {
|
|
73
|
+
console.log(chalk.red(` Path does not exist: ${resolved}`));
|
|
74
|
+
return promptRepoPath(role);
|
|
75
|
+
}
|
|
76
|
+
const stat = await fs.stat(resolved);
|
|
77
|
+
if (!stat.isDirectory()) {
|
|
78
|
+
console.log(chalk.red(` Not a directory: ${resolved}`));
|
|
79
|
+
return promptRepoPath(role);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register a single repo: detect type, generate project constitution, return entry.
|
|
86
|
+
* @param roleOverride — user-selected role (takes priority over auto-detection)
|
|
87
|
+
*/
|
|
88
|
+
async function registerSingleRepo(
|
|
89
|
+
repoPath: string,
|
|
90
|
+
provider: AIProvider,
|
|
91
|
+
roleOverride?: RepoRole
|
|
92
|
+
): Promise<RegisteredRepo> {
|
|
93
|
+
const { type, role: detectedRole } = await detectRepoType(repoPath);
|
|
94
|
+
const role = roleOverride ?? detectedRole;
|
|
95
|
+
const repoName = path.basename(repoPath);
|
|
96
|
+
|
|
97
|
+
console.log(chalk.gray(` Detected: ${repoName} → ${type} (${role})`));
|
|
98
|
+
|
|
99
|
+
// Generate project constitution
|
|
100
|
+
const constitutionPath = path.join(repoPath, CONSTITUTION_FILE);
|
|
101
|
+
let hasConstitution = await fs.pathExists(constitutionPath);
|
|
102
|
+
|
|
103
|
+
if (!hasConstitution) {
|
|
104
|
+
console.log(chalk.blue(` Generating project constitution for ${repoName}...`));
|
|
105
|
+
try {
|
|
106
|
+
const gen = new ConstitutionGenerator(provider);
|
|
107
|
+
const content = await gen.generate(repoPath);
|
|
108
|
+
await gen.saveConstitution(repoPath, content);
|
|
109
|
+
hasConstitution = true;
|
|
110
|
+
console.log(chalk.green(` ✔ Constitution saved: ${constitutionPath}`));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.log(chalk.yellow(` ⚠ Constitution generation failed: ${(err as Error).message}`));
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
console.log(chalk.green(` ✔ Constitution already exists: ${constitutionPath}`));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const entry: RegisteredRepo = {
|
|
119
|
+
name: repoName,
|
|
120
|
+
path: repoPath,
|
|
121
|
+
type,
|
|
122
|
+
role,
|
|
123
|
+
hasConstitution,
|
|
124
|
+
registeredAt: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await registerRepo(entry);
|
|
128
|
+
return entry;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build per-project summaries for global constitution generation from registered repos.
|
|
133
|
+
*/
|
|
134
|
+
async function buildProjectSummaries(
|
|
135
|
+
repos: RegisteredRepo[]
|
|
136
|
+
): Promise<Array<{ name: string; summary: string }>> {
|
|
137
|
+
const summaries: Array<{ name: string; summary: string }> = [];
|
|
138
|
+
|
|
139
|
+
for (const repo of repos) {
|
|
140
|
+
if (!(await fs.pathExists(repo.path))) continue;
|
|
141
|
+
|
|
142
|
+
const lines: string[] = [
|
|
143
|
+
`Type: ${repo.type} (${repo.role})`,
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Load tech stack from context
|
|
147
|
+
try {
|
|
148
|
+
const loader = new ContextLoader(repo.path);
|
|
149
|
+
const ctx = await loader.loadProjectContext();
|
|
150
|
+
lines.push(`Tech stack: ${ctx.techStack.join(", ") || "unknown"}`);
|
|
151
|
+
lines.push(`Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`);
|
|
152
|
+
} catch {
|
|
153
|
+
lines.push(`Tech stack: ${repo.type}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Include constitution excerpt if available
|
|
157
|
+
if (repo.hasConstitution) {
|
|
158
|
+
try {
|
|
159
|
+
const constitutionPath = path.join(repo.path, CONSTITUTION_FILE);
|
|
160
|
+
const raw = await fs.readFile(constitutionPath, "utf-8");
|
|
161
|
+
lines.push("", "Constitution excerpt:", raw.slice(0, 2000));
|
|
162
|
+
} catch { /* skip */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
summaries.push({ name: repo.name, summary: lines.join("\n") });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return summaries;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Generate or update global constitution based on all registered repos.
|
|
173
|
+
*/
|
|
174
|
+
async function generateGlobalConstitution(
|
|
175
|
+
provider: AIProvider,
|
|
176
|
+
repos: RegisteredRepo[],
|
|
177
|
+
currentDir: string
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
console.log(chalk.blue("\n─── Generating Global Constitution ──────────────"));
|
|
180
|
+
console.log(chalk.gray(` Based on ${repos.length} registered repo(s)`));
|
|
181
|
+
|
|
182
|
+
const summaries = await buildProjectSummaries(repos);
|
|
183
|
+
if (summaries.length === 0) {
|
|
184
|
+
console.log(chalk.yellow(" No valid repos found — skipping global constitution."));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const prompt = buildGlobalConstitutionPrompt(summaries);
|
|
189
|
+
let globalConstitution: string;
|
|
190
|
+
try {
|
|
191
|
+
globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error(chalk.red(` ✘ Failed to generate global constitution: ${(err as Error).message}`));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const saved = await saveGlobalConstitution(globalConstitution, currentDir);
|
|
198
|
+
console.log(chalk.green(` ✔ Global constitution saved: ${saved}`));
|
|
199
|
+
console.log(chalk.gray(" Project constitutions will be merged with this at runtime."));
|
|
200
|
+
|
|
201
|
+
// Preview
|
|
202
|
+
const lines = globalConstitution.split("\n");
|
|
203
|
+
console.log(chalk.bold("\n Preview:"));
|
|
204
|
+
console.log(chalk.gray(lines.slice(0, 10).join("\n")));
|
|
205
|
+
if (lines.length > 10) {
|
|
206
|
+
console.log(chalk.gray(` ... (${lines.length} lines total)`));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Command: init ───────────────────────────────────────────────────────────
|
|
20
211
|
|
|
21
212
|
export function registerInit(program: Command): void {
|
|
22
213
|
program
|
|
23
214
|
.command("init")
|
|
24
|
-
.description(
|
|
215
|
+
.description("Setup workspace: register repos, generate constitutions")
|
|
25
216
|
.option(
|
|
26
217
|
"--provider <name>",
|
|
27
218
|
`AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
|
|
@@ -29,13 +220,10 @@ export function registerInit(program: Command): void {
|
|
|
29
220
|
)
|
|
30
221
|
.option("--model <name>", "Model name")
|
|
31
222
|
.option("-k, --key <apiKey>", "API key")
|
|
32
|
-
.option("--force", "Overwrite existing
|
|
33
|
-
.option(
|
|
34
|
-
"--global",
|
|
35
|
-
`Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
|
|
36
|
-
)
|
|
37
|
-
.option("--consolidate", "Consolidate §9 accumulated lessons into §1–§8 core rules (prune & rebase)")
|
|
223
|
+
.option("--force", "Overwrite existing constitutions")
|
|
224
|
+
.option("--consolidate", "Consolidate §9 accumulated lessons into §1–§8 core rules")
|
|
38
225
|
.option("--dry-run", "Preview consolidation result without writing (use with --consolidate)")
|
|
226
|
+
.option("--add-repo", "Add a new repo to the registered list")
|
|
39
227
|
.action(async (opts) => {
|
|
40
228
|
const currentDir = process.cwd();
|
|
41
229
|
const config = await loadConfig(currentDir);
|
|
@@ -68,123 +256,138 @@ export function registerInit(program: Command): void {
|
|
|
68
256
|
return;
|
|
69
257
|
}
|
|
70
258
|
|
|
71
|
-
// ──
|
|
72
|
-
if (opts.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
259
|
+
// ── Add-repo shortcut ──────────────────────────────────────────────────
|
|
260
|
+
if (opts.addRepo) {
|
|
261
|
+
console.log(chalk.blue("\n─── Register New Repo ──────────────────────────"));
|
|
262
|
+
const role = await promptRepoRole();
|
|
263
|
+
const repoPath = await promptRepoPath(role);
|
|
264
|
+
const entry = await registerSingleRepo(repoPath, provider, role);
|
|
265
|
+
console.log(chalk.green(`\n ✔ Repo registered: ${entry.name} (${entry.type} / ${entry.role})`));
|
|
266
|
+
console.log(chalk.gray(` Saved to: ${REPO_STORE_FILE}`));
|
|
267
|
+
|
|
268
|
+
// Ask if user wants to update global constitution
|
|
269
|
+
const updateGlobal = await confirm({
|
|
270
|
+
message: "Update global constitution with this repo's context?",
|
|
271
|
+
default: true,
|
|
272
|
+
});
|
|
273
|
+
if (updateGlobal) {
|
|
274
|
+
const allRepos = await getRegisteredRepos();
|
|
275
|
+
await generateGlobalConstitution(provider, allRepos, currentDir);
|
|
78
276
|
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
79
279
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const index = await loadIndex(currentDir);
|
|
86
|
-
|
|
87
|
-
if (index && index.projects.length > 0) {
|
|
88
|
-
const active = index.projects.filter((p: ProjectEntry) => !p.missing);
|
|
89
|
-
console.log(chalk.gray(` Found project index: ${active.length} project(s) — reading constitutions...`));
|
|
90
|
-
|
|
91
|
-
for (const entry of active) {
|
|
92
|
-
const absPath = path.join(currentDir, entry.path);
|
|
93
|
-
const lines: string[] = [
|
|
94
|
-
`Type: ${entry.type} (${entry.role})`,
|
|
95
|
-
`Tech stack: ${entry.techStack.join(", ") || "unknown"}`,
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
// Include §1–§6 of project constitution if available (skip §9 lessons)
|
|
99
|
-
if (entry.hasConstitution) {
|
|
100
|
-
try {
|
|
101
|
-
const constitutionPath = path.join(absPath, CONSTITUTION_FILE);
|
|
102
|
-
const raw = await fs.readFile(constitutionPath, "utf-8");
|
|
103
|
-
// Take up to first 2000 chars (covers §1–§6 without §9 noise)
|
|
104
|
-
const excerpt = raw.slice(0, 2000);
|
|
105
|
-
lines.push("", "Constitution excerpt:", excerpt);
|
|
106
|
-
} catch { /* skip if unreadable */ }
|
|
107
|
-
}
|
|
280
|
+
// ── Full init flow ─────────────────────────────────────────────────────
|
|
281
|
+
console.log(chalk.blue("\n" + "─".repeat(52)));
|
|
282
|
+
console.log(chalk.bold(" ai-spec init — Workspace Setup"));
|
|
283
|
+
console.log(chalk.blue("─".repeat(52)));
|
|
284
|
+
console.log(chalk.gray(` Provider: ${providerName}/${modelName}\n`));
|
|
108
285
|
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
} else {
|
|
112
|
-
// No index — fall back to scanning just the current directory
|
|
113
|
-
console.log(chalk.yellow(" No project index found. Run `ai-spec scan` first for better results."));
|
|
114
|
-
console.log(chalk.gray(" Falling back: scanning current directory only..."));
|
|
115
|
-
const loader = new ContextLoader(currentDir);
|
|
116
|
-
const ctx = await loader.loadProjectContext();
|
|
117
|
-
projectSummaries.push({
|
|
118
|
-
name: path.basename(currentDir),
|
|
119
|
-
summary: [
|
|
120
|
-
`Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
|
|
121
|
-
`Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`,
|
|
122
|
-
].join("\n"),
|
|
123
|
-
});
|
|
124
|
-
}
|
|
286
|
+
const existingRepos = await getRegisteredRepos();
|
|
125
287
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
console.error(chalk.red(" ✘ Failed to generate global constitution:"), err);
|
|
133
|
-
process.exit(1);
|
|
288
|
+
// ── Step 1: Show existing repos if any ─────────────────────────────────
|
|
289
|
+
if (existingRepos.length > 0) {
|
|
290
|
+
console.log(chalk.cyan(" Registered repos:"));
|
|
291
|
+
for (const r of existingRepos) {
|
|
292
|
+
const constitutionIcon = r.hasConstitution ? chalk.green("✔") : chalk.gray("○");
|
|
293
|
+
console.log(chalk.gray(` ${constitutionIcon} ${r.name} (${r.type} / ${r.role}) → ${r.path}`));
|
|
134
294
|
}
|
|
295
|
+
console.log();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Step 2: Register repos ─────────────────────────────────────────────
|
|
299
|
+
const action = existingRepos.length > 0
|
|
300
|
+
? await select({
|
|
301
|
+
message: "What would you like to do?",
|
|
302
|
+
choices: [
|
|
303
|
+
{ name: "Add new repo(s)", value: "add" as const },
|
|
304
|
+
{ name: "Re-generate constitutions for existing repos", value: "regen" as const },
|
|
305
|
+
{ name: "Skip — proceed to global constitution", value: "skip" as const },
|
|
306
|
+
],
|
|
307
|
+
})
|
|
308
|
+
: "add" as const;
|
|
309
|
+
|
|
310
|
+
const newRepos: RegisteredRepo[] = [];
|
|
135
311
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
312
|
+
if (action === "add") {
|
|
313
|
+
let addMore = true;
|
|
314
|
+
while (addMore) {
|
|
315
|
+
console.log(chalk.blue(`\n ── Register Repo #${existingRepos.length + newRepos.length + 1} ──`));
|
|
316
|
+
const role = await promptRepoRole();
|
|
317
|
+
const repoPath = await promptRepoPath(role);
|
|
318
|
+
|
|
319
|
+
// Check if already registered
|
|
320
|
+
const alreadyRegistered = [...existingRepos, ...newRepos].find((r) => r.path === repoPath);
|
|
321
|
+
if (alreadyRegistered) {
|
|
322
|
+
console.log(chalk.yellow(` Already registered: ${alreadyRegistered.name}`));
|
|
323
|
+
} else {
|
|
324
|
+
const entry = await registerSingleRepo(repoPath, provider, role);
|
|
325
|
+
newRepos.push(entry);
|
|
326
|
+
console.log(chalk.green(` ✔ Registered: ${entry.name}`));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
addMore = await confirm({
|
|
330
|
+
message: "Add another repo?",
|
|
331
|
+
default: false,
|
|
332
|
+
});
|
|
144
333
|
}
|
|
145
|
-
return;
|
|
146
334
|
}
|
|
147
335
|
|
|
148
|
-
|
|
149
|
-
|
|
336
|
+
if (action === "regen") {
|
|
337
|
+
console.log(chalk.blue("\n Re-generating project constitutions..."));
|
|
338
|
+
for (const repo of existingRepos) {
|
|
339
|
+
if (!(await fs.pathExists(repo.path))) {
|
|
340
|
+
console.log(chalk.yellow(` ⚠ ${repo.name}: path not found — skipping`));
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
150
343
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
344
|
+
const constitutionPath = path.join(repo.path, CONSTITUTION_FILE);
|
|
345
|
+
if ((await fs.pathExists(constitutionPath)) && !opts.force) {
|
|
346
|
+
console.log(chalk.gray(` ${repo.name}: constitution exists (use --force to overwrite)`));
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
157
349
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
350
|
+
console.log(chalk.blue(` ${repo.name}: generating constitution...`));
|
|
351
|
+
try {
|
|
352
|
+
const gen = new ConstitutionGenerator(provider);
|
|
353
|
+
const content = await gen.generate(repo.path);
|
|
354
|
+
await gen.saveConstitution(repo.path, content);
|
|
355
|
+
console.log(chalk.green(` ✔ ${repo.name}: constitution saved`));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
console.log(chalk.yellow(` ⚠ ${repo.name}: failed — ${(err as Error).message}`));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
161
361
|
|
|
162
|
-
|
|
362
|
+
// ── Step 3: Generate/update global constitution ────────────────────────
|
|
363
|
+
const allRepos = await getRegisteredRepos();
|
|
163
364
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
} catch (err) {
|
|
168
|
-
console.error(chalk.red(" ✘ Failed to generate constitution:"), err);
|
|
169
|
-
process.exit(1);
|
|
365
|
+
if (allRepos.length === 0) {
|
|
366
|
+
console.log(chalk.yellow("\n No repos registered. Run `ai-spec init` again to add repos."));
|
|
367
|
+
return;
|
|
170
368
|
}
|
|
171
369
|
|
|
172
|
-
const
|
|
370
|
+
const existingGlobal = await loadGlobalConstitution([currentDir]);
|
|
371
|
+
const shouldGenerateGlobal = !existingGlobal || opts.force || newRepos.length > 0
|
|
372
|
+
? true
|
|
373
|
+
: await confirm({
|
|
374
|
+
message: "Global constitution exists. Re-generate it?",
|
|
375
|
+
default: false,
|
|
376
|
+
});
|
|
173
377
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
console.log(chalk.cyan(`\n ℹ Global constitution detected: ${globalResult.source}`));
|
|
177
|
-
console.log(chalk.gray(" It will be merged with this project constitution at runtime."));
|
|
178
|
-
console.log(chalk.gray(" Project rules take priority over global rules."));
|
|
378
|
+
if (shouldGenerateGlobal) {
|
|
379
|
+
await generateGlobalConstitution(provider, allRepos, currentDir);
|
|
179
380
|
}
|
|
180
381
|
|
|
181
|
-
|
|
182
|
-
console.log(chalk.
|
|
183
|
-
console.log(chalk.gray(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
console.log(chalk.gray(` ... (${constitution.split("\n").length} lines total)`));
|
|
382
|
+
// ── Done ───────────────────────────────────────────────────────────────
|
|
383
|
+
console.log(chalk.bold.green("\n✔ Init complete!"));
|
|
384
|
+
console.log(chalk.gray(` Repos registered: ${allRepos.length}`));
|
|
385
|
+
for (const r of allRepos) {
|
|
386
|
+
const icon = r.hasConstitution ? chalk.green("✔") : chalk.gray("○");
|
|
387
|
+
console.log(chalk.gray(` ${icon} ${r.name} (${r.type}/${r.role})`));
|
|
188
388
|
}
|
|
389
|
+
console.log(chalk.gray(`\n Repo store: ${REPO_STORE_FILE}`));
|
|
390
|
+
console.log(chalk.gray(` Next step: ai-spec create "your feature idea"`));
|
|
391
|
+
process.exit(0);
|
|
189
392
|
});
|
|
190
393
|
}
|
package/cli/commands/model.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import * as fs from "fs-extra";
|
|
4
2
|
import chalk from "chalk";
|
|
5
3
|
import { input, select, confirm } from "@inquirer/prompts";
|
|
6
4
|
import {
|
|
@@ -8,7 +6,7 @@ import {
|
|
|
8
6
|
ENV_KEY_MAP,
|
|
9
7
|
PROVIDER_CATALOG,
|
|
10
8
|
} from "../../core/spec-generator";
|
|
11
|
-
import {
|
|
9
|
+
import { AiSpecGlobalConfig, GLOBAL_CONFIG_FILE, loadGlobalConfig, saveGlobalConfig } from "../utils";
|
|
12
10
|
|
|
13
11
|
export function registerModel(program: Command): void {
|
|
14
12
|
program
|
|
@@ -16,9 +14,6 @@ export function registerModel(program: Command): void {
|
|
|
16
14
|
.description("Interactively switch the active AI provider/model and save to .ai-spec.json")
|
|
17
15
|
.option("--list", "List all available providers and models")
|
|
18
16
|
.action(async (opts) => {
|
|
19
|
-
const currentDir = process.cwd();
|
|
20
|
-
const configPath = path.join(currentDir, CONFIG_FILE);
|
|
21
|
-
|
|
22
17
|
// ── --list ──────────────────────────────────────────────────────────────
|
|
23
18
|
if (opts.list) {
|
|
24
19
|
console.log(chalk.bold("\nAvailable providers & models:\n"));
|
|
@@ -37,9 +32,10 @@ export function registerModel(program: Command): void {
|
|
|
37
32
|
return;
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
const existing:
|
|
35
|
+
const existing: AiSpecGlobalConfig = await loadGlobalConfig();
|
|
41
36
|
|
|
42
37
|
console.log(chalk.blue("\n─── Model Switcher ─────────────────────────────"));
|
|
38
|
+
console.log(chalk.gray(` Config: ${GLOBAL_CONFIG_FILE}`));
|
|
43
39
|
if (Object.keys(existing).length > 0) {
|
|
44
40
|
console.log(
|
|
45
41
|
chalk.gray(
|
|
@@ -92,7 +88,7 @@ export function registerModel(program: Command): void {
|
|
|
92
88
|
return { provider: providerKey, model: chosenModel };
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
const updated:
|
|
91
|
+
const updated: AiSpecGlobalConfig = { ...existing };
|
|
96
92
|
|
|
97
93
|
if (target === "spec" || target === "both") {
|
|
98
94
|
const { provider, model } = await pickProviderAndModel("Spec");
|
|
@@ -134,14 +130,14 @@ export function registerModel(program: Command): void {
|
|
|
134
130
|
);
|
|
135
131
|
}
|
|
136
132
|
|
|
137
|
-
const ok = await confirm({ message:
|
|
133
|
+
const ok = await confirm({ message: `Save to ${GLOBAL_CONFIG_FILE}?`, default: true });
|
|
138
134
|
if (!ok) {
|
|
139
135
|
console.log(chalk.gray(" Cancelled."));
|
|
140
136
|
return;
|
|
141
137
|
}
|
|
142
138
|
|
|
143
|
-
await
|
|
144
|
-
console.log(chalk.green(`\n ✔ Saved to ${
|
|
139
|
+
await saveGlobalConfig(updated);
|
|
140
|
+
console.log(chalk.green(`\n ✔ Saved to ${GLOBAL_CONFIG_FILE}`));
|
|
145
141
|
|
|
146
142
|
const providerToCheck = updated.provider ?? "gemini";
|
|
147
143
|
const envKey = ENV_KEY_MAP[providerToCheck];
|
package/cli/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ const program = new Command();
|
|
|
27
27
|
program
|
|
28
28
|
.name("ai-spec")
|
|
29
29
|
.description("AI-driven Development Orchestrator — spec, generate, review")
|
|
30
|
-
.version("
|
|
30
|
+
.version(require("../package.json").version);
|
|
31
31
|
|
|
32
32
|
registerCreate(program);
|
|
33
33
|
registerReview(program);
|
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
|
-
|
|
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
|
-
|
|
88
|
+
try {
|
|
89
|
+
localConfig = await fs.readJson(p);
|
|
90
|
+
} catch { /* ignore */ }
|
|
33
91
|
}
|
|
34
|
-
|
|
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);
|