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/.ai-spec-workspace.json +17 -0
- package/.ai-spec.json +7 -0
- 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/pipeline/single-repo.ts +19 -10
- package/cli/utils.ts +72 -4
- package/core/cli-ui.ts +136 -0
- package/core/code-generator.ts +4 -2
- 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 +7 -4
- package/core/openapi-exporter.ts +3 -2
- package/core/provider-utils.ts +8 -7
- 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 +1042 -533
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +1042 -533
- 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 +123 -61
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +123 -61
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/RELEASE_LOG.md +0 -2962
- package/purpose.md +0 -1434
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);
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
loadVcrRecording,
|
|
52
52
|
} from "../../core/vcr";
|
|
53
53
|
import { printBanner } from "./helpers";
|
|
54
|
+
import { startSpinner, startStage } from "../../core/cli-ui";
|
|
54
55
|
|
|
55
56
|
// ─── Pipeline Options ────────────────────────────────────────────────────────
|
|
56
57
|
|
|
@@ -168,7 +169,7 @@ export async function runSingleRepoPipeline(
|
|
|
168
169
|
console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
|
|
169
170
|
}
|
|
170
171
|
} else {
|
|
171
|
-
|
|
172
|
+
const constitutionSpinner = startSpinner("Constitution not found — auto-generating...");
|
|
172
173
|
try {
|
|
173
174
|
const constitutionGen = new ConstitutionGenerator(
|
|
174
175
|
createProvider(specProviderName, specApiKey, specModelName)
|
|
@@ -176,9 +177,9 @@ export async function runSingleRepoPipeline(
|
|
|
176
177
|
const constitutionContent = await constitutionGen.generate(currentDir);
|
|
177
178
|
await constitutionGen.saveConstitution(currentDir, constitutionContent);
|
|
178
179
|
context.constitution = constitutionContent;
|
|
179
|
-
|
|
180
|
+
constitutionSpinner.succeed("Constitution generated and saved (.ai-spec-constitution.md)");
|
|
180
181
|
} catch (err) {
|
|
181
|
-
|
|
182
|
+
constitutionSpinner.fail(`Constitution auto-generation failed (${(err as Error).message}), continuing without it.`);
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
185
|
|
|
@@ -214,17 +215,18 @@ export async function runSingleRepoPipeline(
|
|
|
214
215
|
let initialTasks: import("../../core/task-generator").SpecTask[] = [];
|
|
215
216
|
|
|
216
217
|
runLogger.stageStart("spec_gen", { provider: specProviderName, model: specModelName });
|
|
218
|
+
const specSpinner = startStage("spec_gen", `Generating spec with ${specProviderName}/${specModelName}...`);
|
|
217
219
|
try {
|
|
218
220
|
if (opts.skipTasks) {
|
|
219
221
|
const { SpecGenerator } = await import("../../core/spec-generator");
|
|
220
222
|
const generator = new SpecGenerator(specProvider);
|
|
221
223
|
initialSpec = await generator.generateSpec(idea, context, architectureDecision);
|
|
222
|
-
|
|
224
|
+
specSpinner.succeed("Spec generated.");
|
|
223
225
|
} else {
|
|
224
226
|
const result = await generateSpecWithTasks(specProvider, idea, context, architectureDecision);
|
|
225
227
|
initialSpec = result.spec;
|
|
226
228
|
initialTasks = result.tasks;
|
|
227
|
-
|
|
229
|
+
specSpinner.succeed("Spec generated.");
|
|
228
230
|
if (initialTasks.length > 0) {
|
|
229
231
|
console.log(chalk.green(` ✔ ${initialTasks.length} tasks generated (combined call).`));
|
|
230
232
|
} else {
|
|
@@ -233,8 +235,8 @@ export async function runSingleRepoPipeline(
|
|
|
233
235
|
}
|
|
234
236
|
runLogger.stageEnd("spec_gen", { taskCount: initialTasks.length });
|
|
235
237
|
} catch (err) {
|
|
238
|
+
specSpinner.fail(`Spec generation failed: ${(err as Error).message}`);
|
|
236
239
|
runLogger.stageFail("spec_gen", (err as Error).message);
|
|
237
|
-
console.error(chalk.red(" ✘ Spec generation failed:"), err);
|
|
238
240
|
process.exit(1);
|
|
239
241
|
}
|
|
240
242
|
|
|
@@ -262,7 +264,9 @@ export async function runSingleRepoPipeline(
|
|
|
262
264
|
console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
|
|
263
265
|
}
|
|
264
266
|
runLogger.stageStart("spec_assess");
|
|
267
|
+
const assessSpinner = startStage("spec_assess", "Evaluating spec quality...");
|
|
265
268
|
const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? undefined);
|
|
269
|
+
assessSpinner.stop();
|
|
266
270
|
if (assessment) {
|
|
267
271
|
runLogger.stageEnd("spec_assess", { overallScore: assessment.overallScore });
|
|
268
272
|
if (!opts.auto) printSpecAssessment(assessment);
|
|
@@ -366,21 +370,24 @@ export async function runSingleRepoPipeline(
|
|
|
366
370
|
console.log(chalk.blue("\n[DSL] Extracting structured DSL from spec..."));
|
|
367
371
|
console.log(chalk.gray(` Provider: ${specProviderName}/${specModelName}`));
|
|
368
372
|
runLogger.stageStart("dsl_extract");
|
|
373
|
+
const dslSpinner = startStage("dsl_extract", "Extracting DSL from spec...");
|
|
369
374
|
try {
|
|
370
375
|
const isFrontend = isFrontendDeps(context.dependencies);
|
|
371
|
-
if (isFrontend)
|
|
376
|
+
if (isFrontend) {
|
|
377
|
+
dslSpinner.update("🔗 Extracting DSL (frontend ComponentSpec mode)...");
|
|
378
|
+
}
|
|
372
379
|
const dslExtractor = new DslExtractor(specProvider);
|
|
373
380
|
extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
|
|
374
381
|
if (extractedDsl) {
|
|
375
382
|
runLogger.stageEnd("dsl_extract", { endpoints: extractedDsl.endpoints?.length ?? 0, models: extractedDsl.models?.length ?? 0 });
|
|
376
|
-
|
|
383
|
+
dslSpinner.succeed("DSL extracted and validated.");
|
|
377
384
|
} else {
|
|
378
385
|
runLogger.stageEnd("dsl_extract", { skipped: true });
|
|
379
|
-
|
|
386
|
+
dslSpinner.fail("DSL skipped — codegen will use Spec + Tasks only.");
|
|
380
387
|
}
|
|
381
388
|
} catch (err) {
|
|
382
389
|
runLogger.stageFail("dsl_extract", (err as Error).message);
|
|
383
|
-
|
|
390
|
+
dslSpinner.fail(`DSL extraction error: ${(err as Error).message} — continuing without DSL.`);
|
|
384
391
|
}
|
|
385
392
|
}
|
|
386
393
|
|
|
@@ -590,6 +597,7 @@ export async function runSingleRepoPipeline(
|
|
|
590
597
|
if (!opts.skipReview) {
|
|
591
598
|
console.log(chalk.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
|
|
592
599
|
runLogger.stageStart("review");
|
|
600
|
+
const reviewSpinner = startStage("review", "Running 3-pass code review...");
|
|
593
601
|
const reviewer = new CodeReviewer(specProvider, workingDir);
|
|
594
602
|
const savedSpec = await fs.readFile(specFile, "utf-8");
|
|
595
603
|
|
|
@@ -598,6 +606,7 @@ export async function runSingleRepoPipeline(
|
|
|
598
606
|
} else {
|
|
599
607
|
reviewResult = await reviewer.reviewCode(savedSpec, specFile);
|
|
600
608
|
}
|
|
609
|
+
reviewSpinner.succeed("Code review complete.");
|
|
601
610
|
runLogger.stageEnd("review");
|
|
602
611
|
|
|
603
612
|
// Surface Pass 0 compliance score
|