ai-spec-dev 0.31.0 → 0.35.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/.claude/commands/add-lesson.md +34 -0
- package/.claude/commands/check-layers.md +65 -0
- package/.claude/commands/installed-deps.md +35 -0
- package/.claude/commands/recall-lessons.md +40 -0
- package/.claude/commands/scan-singletons.md +45 -0
- package/.claude/commands/verify-imports.md +48 -0
- package/.claude/settings.local.json +15 -1
- package/README.md +531 -213
- package/RELEASE_LOG.md +460 -0
- package/cli/commands/config.ts +93 -0
- package/cli/commands/create.ts +1233 -0
- package/cli/commands/dashboard.ts +62 -0
- package/cli/commands/export.ts +66 -0
- package/cli/commands/init.ts +190 -0
- package/cli/commands/learn.ts +30 -0
- package/cli/commands/logs.ts +106 -0
- package/cli/commands/mock.ts +175 -0
- package/cli/commands/model.ts +156 -0
- package/cli/commands/restore.ts +22 -0
- package/cli/commands/review.ts +63 -0
- package/cli/commands/scan.ts +99 -0
- package/cli/commands/trend.ts +36 -0
- package/cli/commands/types.ts +69 -0
- package/cli/commands/update.ts +178 -0
- package/cli/commands/vcr.ts +70 -0
- package/cli/commands/workspace.ts +219 -0
- package/cli/index.ts +34 -2240
- package/cli/utils.ts +83 -0
- package/core/combined-generator.ts +13 -3
- package/core/dashboard-generator.ts +340 -0
- package/core/design-dialogue.ts +124 -0
- package/core/dsl-feedback.ts +285 -0
- package/core/error-feedback.ts +46 -2
- package/core/project-index.ts +301 -0
- package/core/reviewer.ts +84 -6
- package/core/run-logger.ts +109 -3
- package/core/run-trend.ts +261 -0
- package/core/self-evaluator.ts +139 -7
- package/core/spec-generator.ts +14 -8
- package/core/task-generator.ts +17 -0
- package/core/types-generator.ts +219 -0
- package/core/vcr.ts +210 -0
- package/dist/cli/index.js +6692 -4512
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +6692 -4512
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +19 -5
- package/dist/index.d.ts +19 -5
- package/dist/index.js +420 -224
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +418 -224
- package/dist/index.mjs.map +1 -1
- package/docs-assets/purpose/architecture-overview.svg +64 -0
- package/docs-assets/purpose/create-pipeline.svg +113 -0
- package/docs-assets/purpose/task-layering.svg +74 -0
- package/package.json +6 -3
- package/prompts/codegen.prompt.ts +97 -9
- package/prompts/design.prompt.ts +59 -0
- package/prompts/spec.prompt.ts +8 -1
- package/prompts/tasks.prompt.ts +27 -2
- package/purpose.md +600 -174
- package/tests/dsl-extractor.test.ts +264 -0
- package/tests/dsl-feedback.test.ts +266 -0
- package/tests/dsl-validator.test.ts +283 -0
- package/tests/error-feedback.test.ts +292 -0
- package/tests/provider-utils.test.ts +173 -0
- package/tests/run-trend.test.ts +186 -0
- package/tests/self-evaluator.test.ts +339 -0
- package/tests/spec-assessor.test.ts +142 -0
- package/tests/task-generator.test.ts +230 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as fs from "fs-extra";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { input, select, confirm } from "@inquirer/prompts";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MODELS,
|
|
8
|
+
ENV_KEY_MAP,
|
|
9
|
+
PROVIDER_CATALOG,
|
|
10
|
+
} from "../../core/spec-generator";
|
|
11
|
+
import { AiSpecConfig, CONFIG_FILE, loadConfig } from "../utils";
|
|
12
|
+
|
|
13
|
+
export function registerModel(program: Command): void {
|
|
14
|
+
program
|
|
15
|
+
.command("model")
|
|
16
|
+
.description("Interactively switch the active AI provider/model and save to .ai-spec.json")
|
|
17
|
+
.option("--list", "List all available providers and models")
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const currentDir = process.cwd();
|
|
20
|
+
const configPath = path.join(currentDir, CONFIG_FILE);
|
|
21
|
+
|
|
22
|
+
// ── --list ──────────────────────────────────────────────────────────────
|
|
23
|
+
if (opts.list) {
|
|
24
|
+
console.log(chalk.bold("\nAvailable providers & models:\n"));
|
|
25
|
+
for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
|
|
26
|
+
console.log(
|
|
27
|
+
` ${chalk.bold.cyan(key.padEnd(10))} ${chalk.white(meta.displayName)}`
|
|
28
|
+
);
|
|
29
|
+
console.log(chalk.gray(` ${meta.description}`));
|
|
30
|
+
console.log(
|
|
31
|
+
chalk.gray(
|
|
32
|
+
` env: ${meta.envKey} | models: ${meta.models.join(", ")}`
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
console.log();
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const existing: AiSpecConfig = await loadConfig(currentDir);
|
|
41
|
+
|
|
42
|
+
console.log(chalk.blue("\n─── Model Switcher ─────────────────────────────"));
|
|
43
|
+
if (Object.keys(existing).length > 0) {
|
|
44
|
+
console.log(
|
|
45
|
+
chalk.gray(
|
|
46
|
+
` Current: spec=${existing.provider ?? "gemini"}/${existing.model ?? DEFAULT_MODELS[existing.provider ?? "gemini"]}` +
|
|
47
|
+
(existing.codegenProvider
|
|
48
|
+
? ` codegen=${existing.codegenProvider}/${existing.codegenModel ?? ""}`
|
|
49
|
+
: "")
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
|
|
55
|
+
const target = await select({
|
|
56
|
+
message: "Configure model for:",
|
|
57
|
+
choices: [
|
|
58
|
+
{ name: "Spec generation (used for spec writing & refinement)", value: "spec" },
|
|
59
|
+
{ name: "Code generation (used when --codegen api is active)", value: "codegen" },
|
|
60
|
+
{ name: "Both (same provider/model for all tasks)", value: "both" },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function pickProviderAndModel(label: string): Promise<{ provider: string; model: string }> {
|
|
65
|
+
const providerKey = await select({
|
|
66
|
+
message: `${label} — select provider:`,
|
|
67
|
+
choices: Object.entries(PROVIDER_CATALOG).map(([key, meta]) => ({
|
|
68
|
+
name: `${meta.displayName.padEnd(22)} ${chalk.gray(meta.description)}`,
|
|
69
|
+
value: key,
|
|
70
|
+
short: meta.displayName,
|
|
71
|
+
})),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const meta = PROVIDER_CATALOG[providerKey];
|
|
75
|
+
const modelChoices = [
|
|
76
|
+
...meta.models.map((m) => ({ name: m, value: m })),
|
|
77
|
+
{ name: chalk.italic("✎ Enter custom model name..."), value: "__custom__" },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
let chosenModel = await select({
|
|
81
|
+
message: `${label} — select model (${meta.displayName}):`,
|
|
82
|
+
choices: modelChoices,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (chosenModel === "__custom__") {
|
|
86
|
+
chosenModel = await input({
|
|
87
|
+
message: "Enter model name:",
|
|
88
|
+
validate: (v) => v.trim().length > 0 || "Model name cannot be empty",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { provider: providerKey, model: chosenModel };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const updated: AiSpecConfig = { ...existing };
|
|
96
|
+
|
|
97
|
+
if (target === "spec" || target === "both") {
|
|
98
|
+
const { provider, model } = await pickProviderAndModel("Spec");
|
|
99
|
+
updated.provider = provider;
|
|
100
|
+
updated.model = model;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (target === "codegen" || target === "both") {
|
|
104
|
+
if (target === "both") {
|
|
105
|
+
updated.codegenProvider = updated.provider;
|
|
106
|
+
updated.codegenModel = updated.model;
|
|
107
|
+
} else {
|
|
108
|
+
const { provider, model } = await pickProviderAndModel("Codegen");
|
|
109
|
+
updated.codegenProvider = provider;
|
|
110
|
+
updated.codegenModel = model;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const effectiveCodegenProvider = updated.codegenProvider ?? updated.provider ?? "gemini";
|
|
114
|
+
if (effectiveCodegenProvider !== "claude") {
|
|
115
|
+
if (!updated.codegen || updated.codegen === "claude-code") {
|
|
116
|
+
updated.codegen = "api";
|
|
117
|
+
console.log(
|
|
118
|
+
chalk.yellow(
|
|
119
|
+
`\n ⚠ provider "${effectiveCodegenProvider}" 不支持 "claude-code" 模式。`
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
console.log(chalk.gray(` 已自动将 codegen 模式设为 "api"。`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(chalk.blue("\n Preview:"));
|
|
128
|
+
console.log(chalk.gray(` spec → ${updated.provider}/${updated.model}`));
|
|
129
|
+
if (updated.codegenProvider) {
|
|
130
|
+
console.log(
|
|
131
|
+
chalk.gray(
|
|
132
|
+
` codegen → ${updated.codegenProvider}/${updated.codegenModel} (mode: ${updated.codegen ?? "claude-code"})`
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ok = await confirm({ message: "Save to .ai-spec.json?", default: true });
|
|
138
|
+
if (!ok) {
|
|
139
|
+
console.log(chalk.gray(" Cancelled."));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await fs.writeJson(configPath, updated, { spaces: 2 });
|
|
144
|
+
console.log(chalk.green(`\n ✔ Saved to ${configPath}`));
|
|
145
|
+
|
|
146
|
+
const providerToCheck = updated.provider ?? "gemini";
|
|
147
|
+
const envKey = ENV_KEY_MAP[providerToCheck];
|
|
148
|
+
if (envKey && !process.env[envKey]) {
|
|
149
|
+
console.log(
|
|
150
|
+
chalk.yellow(
|
|
151
|
+
` ⚠ Remember to set ${envKey} in your environment or .env file.`
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { RunSnapshot } from "../../core/run-snapshot";
|
|
4
|
+
|
|
5
|
+
export function registerRestore(program: Command): void {
|
|
6
|
+
program
|
|
7
|
+
.command("restore")
|
|
8
|
+
.description("Restore files modified by a previous run")
|
|
9
|
+
.argument("<runId>", "Run ID shown at the end of a create / generate run")
|
|
10
|
+
.action(async (runId: string) => {
|
|
11
|
+
const currentDir = process.cwd();
|
|
12
|
+
const snapshot = new RunSnapshot(currentDir, runId);
|
|
13
|
+
console.log(chalk.blue(`Restoring run: ${runId}...`));
|
|
14
|
+
const restored = await snapshot.restore();
|
|
15
|
+
if (restored.length === 0) {
|
|
16
|
+
console.log(chalk.yellow(" No backup found for this run ID."));
|
|
17
|
+
} else {
|
|
18
|
+
restored.forEach((f) => console.log(chalk.green(` ✔ restored: ${f}`)));
|
|
19
|
+
console.log(chalk.bold.green(`\n✔ ${restored.length} file(s) restored.`));
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as fs from "fs-extra";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { createProvider, DEFAULT_MODELS, SUPPORTED_PROVIDERS } from "../../core/spec-generator";
|
|
6
|
+
import { CodeReviewer } from "../../core/reviewer";
|
|
7
|
+
import { loadConfig, resolveApiKey } from "../utils";
|
|
8
|
+
|
|
9
|
+
export function registerReview(program: Command): void {
|
|
10
|
+
program
|
|
11
|
+
.command("review")
|
|
12
|
+
.description("Run AI code review on current git diff against a spec")
|
|
13
|
+
.argument("[specFile]", "Path to spec file (auto-detects latest in specs/ if omitted)")
|
|
14
|
+
.option(
|
|
15
|
+
"--provider <name>",
|
|
16
|
+
`AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
|
|
17
|
+
undefined
|
|
18
|
+
)
|
|
19
|
+
.option("--model <name>", "Model name")
|
|
20
|
+
.option("-k, --key <apiKey>", "API key")
|
|
21
|
+
.action(async (specFile: string | undefined, opts) => {
|
|
22
|
+
const currentDir = process.cwd();
|
|
23
|
+
const config = await loadConfig(currentDir);
|
|
24
|
+
|
|
25
|
+
const providerName = opts.provider || config.provider || "gemini";
|
|
26
|
+
const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
|
|
27
|
+
const apiKey = await resolveApiKey(providerName, opts.key);
|
|
28
|
+
|
|
29
|
+
const provider = createProvider(providerName, apiKey, modelName);
|
|
30
|
+
const reviewer = new CodeReviewer(provider, currentDir);
|
|
31
|
+
|
|
32
|
+
let specContent = "";
|
|
33
|
+
let resolvedSpecFile: string | undefined;
|
|
34
|
+
|
|
35
|
+
if (specFile && (await fs.pathExists(specFile))) {
|
|
36
|
+
specContent = await fs.readFile(specFile, "utf-8");
|
|
37
|
+
resolvedSpecFile = specFile;
|
|
38
|
+
console.log(chalk.gray(`Using spec: ${specFile}`));
|
|
39
|
+
} else {
|
|
40
|
+
// Auto-detect the latest spec in specs/
|
|
41
|
+
const specsDir = path.join(currentDir, "specs");
|
|
42
|
+
if (await fs.pathExists(specsDir)) {
|
|
43
|
+
const files = (await fs.readdir(specsDir))
|
|
44
|
+
.filter((f) => f.endsWith(".md"))
|
|
45
|
+
.sort()
|
|
46
|
+
.reverse();
|
|
47
|
+
if (files.length > 0) {
|
|
48
|
+
const latest = path.join(specsDir, files[0]);
|
|
49
|
+
specContent = await fs.readFile(latest, "utf-8");
|
|
50
|
+
resolvedSpecFile = latest;
|
|
51
|
+
console.log(chalk.gray(`Auto-detected spec: specs/${files[0]}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!specContent) {
|
|
57
|
+
console.log(chalk.yellow("No spec file found. Running review without spec context."));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await reviewer.reviewCode(specContent, resolvedSpecFile);
|
|
61
|
+
await reviewer.printScoreTrend();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { runScan, saveIndex, loadIndex, INDEX_FILE, ProjectEntry } from "../../core/project-index";
|
|
5
|
+
|
|
6
|
+
const ROLE_COLOR: Record<string, (s: string) => string> = {
|
|
7
|
+
backend: chalk.blue,
|
|
8
|
+
frontend: chalk.green,
|
|
9
|
+
mobile: chalk.magenta,
|
|
10
|
+
shared: chalk.gray,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function formatEntry(entry: ProjectEntry): string {
|
|
14
|
+
const roleColor = ROLE_COLOR[entry.role] ?? chalk.white;
|
|
15
|
+
const role = roleColor(entry.role.padEnd(8));
|
|
16
|
+
const type = chalk.gray(entry.type.padEnd(14));
|
|
17
|
+
const name = (entry.missing ? chalk.strikethrough.gray : chalk.white)(entry.path.padEnd(30));
|
|
18
|
+
const badges: string[] = [];
|
|
19
|
+
if (entry.hasConstitution) badges.push(chalk.cyan("§C"));
|
|
20
|
+
if (entry.hasWorkspace) badges.push(chalk.yellow("W"));
|
|
21
|
+
if (entry.missing) badges.push(chalk.red("missing"));
|
|
22
|
+
const stack = chalk.gray(entry.techStack.slice(0, 5).join(", "));
|
|
23
|
+
return ` ${name} ${role} ${type} ${badges.join(" ")} ${stack}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerScan(program: Command): void {
|
|
27
|
+
program
|
|
28
|
+
.command("scan")
|
|
29
|
+
.description("Discover and index all projects under the current directory")
|
|
30
|
+
.option("-d, --depth <n>", "Max directory depth to search", "2")
|
|
31
|
+
.option("--list", "Just print the current index without rescanning")
|
|
32
|
+
.action(async (opts) => {
|
|
33
|
+
const cwd = process.cwd();
|
|
34
|
+
|
|
35
|
+
// ── List mode ─────────────────────────────────────────────────────────
|
|
36
|
+
if (opts.list) {
|
|
37
|
+
const existing = await loadIndex(cwd);
|
|
38
|
+
if (!existing || existing.projects.length === 0) {
|
|
39
|
+
console.log(chalk.gray("No index found. Run: ai-spec scan"));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(chalk.cyan(`\n─── Project Index (${existing.projects.length} projects) ─────────────────────────────`));
|
|
44
|
+
console.log(chalk.gray(` Last scanned : ${existing.lastScanned.slice(0, 19).replace("T", " ")}`));
|
|
45
|
+
console.log(chalk.gray(` Root : ${existing.scanRoot}\n`));
|
|
46
|
+
|
|
47
|
+
const active = existing.projects.filter((p) => !p.missing);
|
|
48
|
+
const missing = existing.projects.filter((p) => p.missing);
|
|
49
|
+
|
|
50
|
+
for (const entry of active) {
|
|
51
|
+
console.log(formatEntry(entry));
|
|
52
|
+
}
|
|
53
|
+
if (missing.length > 0) {
|
|
54
|
+
console.log(chalk.gray(`\n (${missing.length} previously seen, now missing)`));
|
|
55
|
+
for (const entry of missing) {
|
|
56
|
+
console.log(formatEntry(entry));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(chalk.cyan("\n─".repeat(52)));
|
|
61
|
+
console.log(chalk.gray(" §C = has constitution W = workspace root"));
|
|
62
|
+
console.log(chalk.gray(` Index file: ${INDEX_FILE}`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Scan mode ─────────────────────────────────────────────────────────
|
|
67
|
+
const maxDepth = parseInt(opts.depth, 10);
|
|
68
|
+
console.log(chalk.blue(`\nScanning ${cwd} (depth: ${maxDepth})...`));
|
|
69
|
+
|
|
70
|
+
const { index, added, updated, unchanged, nowMissing } = await runScan(cwd, maxDepth);
|
|
71
|
+
await saveIndex(cwd, index);
|
|
72
|
+
|
|
73
|
+
const active = index.projects.filter((p) => !p.missing);
|
|
74
|
+
|
|
75
|
+
// ── Summary ───────────────────────────────────────────────────────────
|
|
76
|
+
console.log(chalk.cyan(`\n─── Scan Results ────────────────────────────────────`));
|
|
77
|
+
if (added.length > 0) console.log(chalk.green(` + ${added.length} new project(s) added`));
|
|
78
|
+
if (updated.length > 0) console.log(chalk.yellow(` ~ ${updated.length} project(s) updated`));
|
|
79
|
+
if (unchanged.length > 0) console.log(chalk.gray(` · ${unchanged.length} project(s) unchanged`));
|
|
80
|
+
if (nowMissing.length > 0) console.log(chalk.red(` ✘ ${nowMissing.length} project(s) no longer found (marked missing)`));
|
|
81
|
+
|
|
82
|
+
if (added.length === 0 && updated.length === 0 && nowMissing.length === 0) {
|
|
83
|
+
console.log(chalk.gray(" Nothing changed."));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Full listing ──────────────────────────────────────────────────────
|
|
87
|
+
if (active.length > 0) {
|
|
88
|
+
console.log(chalk.cyan(`\n Projects (${active.length}):`));
|
|
89
|
+
for (const entry of active) {
|
|
90
|
+
console.log(formatEntry(entry));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(chalk.cyan("\n─".repeat(52)));
|
|
95
|
+
console.log(chalk.gray(" §C = has constitution W = workspace root"));
|
|
96
|
+
console.log(chalk.gray(` Index saved : ${path.relative(cwd, path.join(cwd, INDEX_FILE))}`));
|
|
97
|
+
console.log(chalk.gray(` Next steps : ai-spec scan --list | ai-spec init [--global]`));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { loadRunLogs, buildTrendReport, printTrendReport } from "../../core/run-trend";
|
|
4
|
+
|
|
5
|
+
export function registerTrend(program: Command): void {
|
|
6
|
+
program
|
|
7
|
+
.command("trend")
|
|
8
|
+
.description("Show harness score trend across past create runs")
|
|
9
|
+
.option("--last <n>", "Number of recent scored runs to show (default: 15)", "15")
|
|
10
|
+
.option("--prompt <hash>", "Filter to a specific prompt hash (prefix match)")
|
|
11
|
+
.option("--json", "Output raw JSON instead of formatted table")
|
|
12
|
+
.action(async (opts: { last: string; prompt?: string; json?: boolean }) => {
|
|
13
|
+
const currentDir = process.cwd();
|
|
14
|
+
const last = parseInt(opts.last, 10) || 15;
|
|
15
|
+
|
|
16
|
+
const logs = await loadRunLogs(currentDir);
|
|
17
|
+
if (logs.length === 0) {
|
|
18
|
+
console.log(chalk.yellow(
|
|
19
|
+
"\n No run logs found. Run `ai-spec create` at least once to start tracking.\n"
|
|
20
|
+
));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const report = buildTrendReport(logs, {
|
|
25
|
+
last,
|
|
26
|
+
promptFilter: opts.prompt,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
console.log(JSON.stringify(report, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
printTrendReport(report, currentDir);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as fs from "fs-extra";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { findLatestDslFile } from "../../core/mock-server-generator";
|
|
6
|
+
import { SpecDSL } from "../../core/dsl-types";
|
|
7
|
+
import { saveTypescriptTypes, generateTypescriptTypes } from "../../core/types-generator";
|
|
8
|
+
|
|
9
|
+
export function registerTypes(program: Command): void {
|
|
10
|
+
program
|
|
11
|
+
.command("types")
|
|
12
|
+
.description("Generate TypeScript types from the latest DSL (models + endpoint request types)")
|
|
13
|
+
.option("--dsl <path>", "Path to a specific .dsl.json file (auto-detected if omitted)")
|
|
14
|
+
.option("--output <path>", "Output file path (default: .ai-spec/<feature>.types.ts)")
|
|
15
|
+
.option("--stdout", "Print generated types to stdout instead of writing a file")
|
|
16
|
+
.option("--no-endpoint-types", "Skip endpoint request/response type generation")
|
|
17
|
+
.option("--no-endpoint-map", "Skip the API_ENDPOINTS constant map")
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const currentDir = process.cwd();
|
|
20
|
+
|
|
21
|
+
// ── Resolve DSL ──────────────────────────────────────────────────────────
|
|
22
|
+
let dslPath: string | null = opts.dsl ?? null;
|
|
23
|
+
if (!dslPath) {
|
|
24
|
+
dslPath = await findLatestDslFile(currentDir);
|
|
25
|
+
if (!dslPath) {
|
|
26
|
+
console.error(
|
|
27
|
+
chalk.red(" No .dsl.json file found. Run `ai-spec create` first or use --dsl <path>.")
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let dsl: SpecDSL;
|
|
34
|
+
try {
|
|
35
|
+
dsl = await fs.readJson(dslPath);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(chalk.red(` Failed to read DSL: ${(err as Error).message}`));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const genOpts = {
|
|
42
|
+
includeEndpointTypes: opts.endpointTypes !== false,
|
|
43
|
+
includeEndpointMap: opts.endpointMap !== false,
|
|
44
|
+
outputPath: opts.output,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ── Stdout mode ──────────────────────────────────────────────────────────
|
|
48
|
+
if (opts.stdout) {
|
|
49
|
+
const content = generateTypescriptTypes(dsl, genOpts);
|
|
50
|
+
process.stdout.write(content);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── File mode ────────────────────────────────────────────────────────────
|
|
55
|
+
const outputPath = await saveTypescriptTypes(dsl, currentDir, genOpts);
|
|
56
|
+
const relPath = path.relative(currentDir, outputPath);
|
|
57
|
+
|
|
58
|
+
console.log(chalk.green(`\n ✔ TypeScript types generated: ${relPath}`));
|
|
59
|
+
console.log(chalk.gray(` Feature : ${dsl.feature.title}`));
|
|
60
|
+
console.log(chalk.gray(` Models : ${dsl.models.length}`));
|
|
61
|
+
console.log(chalk.gray(` Endpoints: ${dsl.endpoints.length}`));
|
|
62
|
+
if (dsl.components?.length) {
|
|
63
|
+
console.log(chalk.gray(` Components: ${dsl.components.length}`));
|
|
64
|
+
}
|
|
65
|
+
console.log(chalk.blue(`\n Usage:`));
|
|
66
|
+
console.log(chalk.gray(` import type { ${dsl.models.slice(0, 3).map((m) => m.name).join(", ")}${dsl.models.length > 3 ? ", ..." : ""} } from './${relPath}';`));
|
|
67
|
+
console.log(chalk.gray(` import { API_ENDPOINTS } from './${relPath}';\n`));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as fs from "fs-extra";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { input } from "@inquirer/prompts";
|
|
6
|
+
import { createProvider, DEFAULT_MODELS, SUPPORTED_PROVIDERS } from "../../core/spec-generator";
|
|
7
|
+
import { ContextLoader } from "../../core/context-loader";
|
|
8
|
+
import { CodeGenerator } from "../../core/code-generator";
|
|
9
|
+
import { CodeReviewer } from "../../core/reviewer";
|
|
10
|
+
import { SpecUpdater } from "../../core/spec-updater";
|
|
11
|
+
import { accumulateReviewKnowledge } from "../../core/knowledge-memory";
|
|
12
|
+
import { generateRunId, RunLogger, setActiveLogger } from "../../core/run-logger";
|
|
13
|
+
import { RunSnapshot, setActiveSnapshot } from "../../core/run-snapshot";
|
|
14
|
+
import { detectRepoType } from "../../core/workspace-loader";
|
|
15
|
+
import { getCodeGenSystemPrompt } from "../../prompts/codegen.prompt";
|
|
16
|
+
import { loadConfig, resolveApiKey } from "../utils";
|
|
17
|
+
|
|
18
|
+
export function registerUpdate(program: Command): void {
|
|
19
|
+
program
|
|
20
|
+
.command("update")
|
|
21
|
+
.description("Update an existing spec with a change request, re-extract DSL, and identify affected files")
|
|
22
|
+
.argument("[change]", "Change description (prompted if omitted)")
|
|
23
|
+
.option("--provider <name>", `AI provider (${SUPPORTED_PROVIDERS.join("|")})`, undefined)
|
|
24
|
+
.option("--model <name>", "Model name")
|
|
25
|
+
.option("-k, --key <apiKey>", "API key")
|
|
26
|
+
.option("--spec <path>", "Path to the existing spec file (auto-detected if omitted)")
|
|
27
|
+
.option("--codegen", "Regenerate affected files automatically after updating spec")
|
|
28
|
+
.option("--codegen-provider <name>", "Provider for code generation")
|
|
29
|
+
.option("--codegen-model <name>", "Model for code generation")
|
|
30
|
+
.option("--codegen-key <key>", "API key for code generation")
|
|
31
|
+
.option("--skip-affected", "Skip identifying affected files")
|
|
32
|
+
.action(async (change: string | undefined, opts) => {
|
|
33
|
+
const currentDir = process.cwd();
|
|
34
|
+
const config = await loadConfig(currentDir);
|
|
35
|
+
|
|
36
|
+
if (!change) {
|
|
37
|
+
change = await input({
|
|
38
|
+
message: "Describe the change you want to make:",
|
|
39
|
+
validate: (v) => v.trim().length > 0 || "Change description cannot be empty",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const providerName = opts.provider || config.provider || "gemini";
|
|
44
|
+
const modelName = opts.model || config.model || DEFAULT_MODELS[providerName];
|
|
45
|
+
const apiKey = await resolveApiKey(providerName, opts.key);
|
|
46
|
+
const provider = createProvider(providerName, apiKey, modelName);
|
|
47
|
+
|
|
48
|
+
console.log(chalk.blue("\n─── ai-spec update ─────────────────────────────"));
|
|
49
|
+
console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
|
|
50
|
+
|
|
51
|
+
const updateRunId = generateRunId();
|
|
52
|
+
const updateSnapshot = new RunSnapshot(currentDir, updateRunId);
|
|
53
|
+
setActiveSnapshot(updateSnapshot);
|
|
54
|
+
const updateLogger = new RunLogger(currentDir, updateRunId, { provider: providerName, model: modelName });
|
|
55
|
+
setActiveLogger(updateLogger);
|
|
56
|
+
console.log(chalk.gray(` Run ID: ${updateRunId}`));
|
|
57
|
+
|
|
58
|
+
let specPath: string | null = opts.spec ?? null;
|
|
59
|
+
if (!specPath) {
|
|
60
|
+
const specsDir = path.join(currentDir, "specs");
|
|
61
|
+
const latest = await SpecUpdater.findLatestSpec(specsDir);
|
|
62
|
+
if (!latest) {
|
|
63
|
+
console.error(chalk.red(" No spec files found in specs/. Run `ai-spec create` first or use --spec <path>."));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
specPath = latest.filePath;
|
|
67
|
+
console.log(chalk.gray(` Using spec: ${path.relative(currentDir, specPath)} (v${latest.version})`));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(chalk.gray(" Loading project context..."));
|
|
71
|
+
const loader = new ContextLoader(currentDir);
|
|
72
|
+
const context = await loader.loadProjectContext();
|
|
73
|
+
if (context.constitution && context.constitution.length > 6000) {
|
|
74
|
+
console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { type: repoType } = await detectRepoType(currentDir);
|
|
78
|
+
|
|
79
|
+
const updater = new SpecUpdater(provider);
|
|
80
|
+
let result;
|
|
81
|
+
try {
|
|
82
|
+
result = await updater.update(change!, specPath, currentDir, context, {
|
|
83
|
+
skipAffectedFiles: opts.skipAffected,
|
|
84
|
+
repoType,
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(chalk.red(` Update failed: ${(err as Error).message}`));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(chalk.green(`\n ✔ Spec updated → v${result.newVersion}: ${path.relative(currentDir, result.newSpecPath)}`));
|
|
92
|
+
if (result.newDslPath) {
|
|
93
|
+
console.log(chalk.green(` ✔ DSL updated: ${path.relative(currentDir, result.newDslPath)}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (result.affectedFiles.length > 0) {
|
|
97
|
+
console.log(chalk.cyan("\n Affected files:"));
|
|
98
|
+
for (const f of result.affectedFiles) {
|
|
99
|
+
const icon = f.action === "create" ? chalk.green("+") : chalk.yellow("~");
|
|
100
|
+
console.log(` ${icon} ${f.file}: ${chalk.gray(f.description)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (opts.codegen && result.affectedFiles.length > 0) {
|
|
105
|
+
const codegenProviderName = opts.codegenProvider || config.codegenProvider || providerName;
|
|
106
|
+
const codegenModelName = opts.codegenModel || config.codegenModel || DEFAULT_MODELS[codegenProviderName];
|
|
107
|
+
const codegenApiKey = opts.codegenKey ?? (codegenProviderName === providerName ? apiKey : await resolveApiKey(codegenProviderName, opts.codegenKey));
|
|
108
|
+
const codegenProvider = createProvider(codegenProviderName, codegenApiKey, codegenModelName);
|
|
109
|
+
|
|
110
|
+
console.log(chalk.blue("\n Regenerating affected files..."));
|
|
111
|
+
new CodeGenerator(codegenProvider, "api");
|
|
112
|
+
|
|
113
|
+
const specContent = await fs.readFile(result.newSpecPath, "utf-8");
|
|
114
|
+
const constitutionSection = context.constitution
|
|
115
|
+
? `\n=== Project Constitution (MUST follow) ===\n${context.constitution}\n`
|
|
116
|
+
: "";
|
|
117
|
+
const dslSection = result.updatedDsl
|
|
118
|
+
? `\n=== DSL Context ===\n${JSON.stringify(result.updatedDsl, null, 2).slice(0, 3000)}\n`
|
|
119
|
+
: "";
|
|
120
|
+
|
|
121
|
+
updateLogger.stageStart("update_codegen");
|
|
122
|
+
for (const affected of result.affectedFiles) {
|
|
123
|
+
const fullPath = path.join(currentDir, affected.file);
|
|
124
|
+
let existing = "";
|
|
125
|
+
try { existing = await fs.readFile(fullPath, "utf-8"); } catch { /* new file */ }
|
|
126
|
+
|
|
127
|
+
const codePrompt = `Apply this change to the file.
|
|
128
|
+
|
|
129
|
+
Change: ${change}
|
|
130
|
+
File: ${affected.file}
|
|
131
|
+
Purpose: ${affected.description}
|
|
132
|
+
|
|
133
|
+
=== Feature Spec (updated) ===
|
|
134
|
+
${specContent}
|
|
135
|
+
${constitutionSection}${dslSection}
|
|
136
|
+
=== ${existing ? "Current File (return the FULL updated content)" : "New File"} ===
|
|
137
|
+
${existing || "Create from scratch."}`;
|
|
138
|
+
|
|
139
|
+
process.stdout.write(` ${existing ? chalk.yellow("~") : chalk.green("+")} ${affected.file}... `);
|
|
140
|
+
try {
|
|
141
|
+
const raw = await codegenProvider.generate(codePrompt, getCodeGenSystemPrompt(repoType));
|
|
142
|
+
const content = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
|
|
143
|
+
await fs.ensureDir(path.dirname(fullPath));
|
|
144
|
+
await updateSnapshot.snapshotFile(fullPath);
|
|
145
|
+
await fs.writeFile(fullPath, content, "utf-8");
|
|
146
|
+
updateLogger.fileWritten(affected.file);
|
|
147
|
+
console.log(chalk.green("✔"));
|
|
148
|
+
} catch (err) {
|
|
149
|
+
updateLogger.stageFail("update_codegen", `${affected.file}: ${(err as Error).message}`);
|
|
150
|
+
console.log(chalk.red(`✘ ${(err as Error).message}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
updateLogger.stageEnd("update_codegen", { filesUpdated: result.affectedFiles.length });
|
|
154
|
+
|
|
155
|
+
const updatedSpecContent = await fs.readFile(result.newSpecPath, "utf-8").catch(() => "");
|
|
156
|
+
if (updatedSpecContent) {
|
|
157
|
+
const updateReviewer = new CodeReviewer(provider, currentDir);
|
|
158
|
+
const reviewResult = await updateReviewer.reviewCode(updatedSpecContent, result.newSpecPath).catch(() => "");
|
|
159
|
+
if (reviewResult && reviewResult !== "No changes") {
|
|
160
|
+
await accumulateReviewKnowledge(provider, currentDir, reviewResult);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
updateLogger.finish();
|
|
166
|
+
updateLogger.printSummary();
|
|
167
|
+
if (updateSnapshot.fileCount > 0) {
|
|
168
|
+
console.log(chalk.gray(` To undo changes: ai-spec restore ${updateRunId}`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!opts.codegen && result.affectedFiles.length > 0) {
|
|
172
|
+
console.log(chalk.blue("\n Next steps:"));
|
|
173
|
+
console.log(chalk.gray(` • Re-run with --codegen to regenerate affected files automatically`));
|
|
174
|
+
console.log(chalk.gray(` • Or update files manually based on the affected files list above`));
|
|
175
|
+
console.log(chalk.gray(` • Run \`ai-spec mock\` to refresh the mock server with the new DSL`));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|