ai-spec-dev 0.1.0 → 0.14.1
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/settings.local.json +18 -0
- package/README.md +1211 -146
- package/RELEASE_LOG.md +1444 -0
- package/cli/index.ts +1961 -0
- package/cli/welcome.ts +151 -0
- package/core/code-generator.ts +740 -0
- package/core/combined-generator.ts +63 -0
- package/core/constitution-consolidator.ts +141 -0
- package/core/constitution-generator.ts +89 -0
- package/core/context-loader.ts +453 -0
- package/core/contract-bridge.ts +217 -0
- package/core/dsl-extractor.ts +337 -0
- package/core/dsl-types.ts +166 -0
- package/core/dsl-validator.ts +450 -0
- package/core/error-feedback.ts +354 -0
- package/core/frontend-context-loader.ts +602 -0
- package/core/global-constitution.ts +88 -0
- package/core/key-store.ts +49 -0
- package/core/knowledge-memory.ts +171 -0
- package/core/mock-server-generator.ts +571 -0
- package/core/openapi-exporter.ts +361 -0
- package/core/requirement-decomposer.ts +198 -0
- package/core/reviewer.ts +259 -0
- package/core/spec-assessor.ts +99 -0
- package/core/spec-generator.ts +428 -0
- package/core/spec-refiner.ts +89 -0
- package/core/spec-updater.ts +227 -0
- package/core/spec-versioning.ts +213 -0
- package/core/task-generator.ts +174 -0
- package/core/test-generator.ts +273 -0
- package/core/workspace-loader.ts +256 -0
- package/dist/cli/index.js +6717 -672
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +6717 -670
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +147 -27
- package/dist/index.d.ts +147 -27
- package/dist/index.js +2337 -286
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2329 -285
- package/dist/index.mjs.map +1 -1
- package/git/worktree.ts +109 -0
- package/index.ts +9 -0
- package/package.json +4 -28
- package/prompts/codegen.prompt.ts +259 -0
- package/prompts/consolidate.prompt.ts +73 -0
- package/prompts/constitution.prompt.ts +63 -0
- package/prompts/decompose.prompt.ts +168 -0
- package/prompts/dsl.prompt.ts +203 -0
- package/prompts/frontend-spec.prompt.ts +191 -0
- package/prompts/global-constitution.prompt.ts +61 -0
- package/prompts/spec-assess.prompt.ts +53 -0
- package/prompts/spec.prompt.ts +102 -0
- package/prompts/tasks.prompt.ts +35 -0
- package/prompts/testgen.prompt.ts +84 -0
- package/prompts/update.prompt.ts +131 -0
- package/purpose.docx +0 -0
- package/purpose.md +444 -0
- package/tsconfig.json +14 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as fs from "fs-extra";
|
|
5
|
+
import { AIProvider } from "./spec-generator";
|
|
6
|
+
import { ProjectContext, isFrontendDeps } from "./context-loader";
|
|
7
|
+
import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
|
|
8
|
+
import { SpecTask, loadTasksForSpec, updateTaskStatus } from "./task-generator";
|
|
9
|
+
import { loadDslForSpec, buildDslContextSection } from "./dsl-extractor";
|
|
10
|
+
import { loadFrontendContext, buildFrontendContextSection } from "./frontend-context-loader";
|
|
11
|
+
|
|
12
|
+
// ─── Shared Config Helper ───────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function buildSharedConfigSection(context?: ProjectContext): string {
|
|
15
|
+
if (!context?.sharedConfigFiles || context.sharedConfigFiles.length === 0) return "";
|
|
16
|
+
|
|
17
|
+
const lines: string[] = [
|
|
18
|
+
"\n=== Existing Shared Config Files (study these to learn project conventions) ===",
|
|
19
|
+
"These are real files from the project. Use them as ground truth for naming, structure, and registration patterns.",
|
|
20
|
+
"Modify them in-place when adding new entries. Do NOT create parallel files for the same purpose.\n",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
for (const f of context.sharedConfigFiles) {
|
|
24
|
+
lines.push(`--- File: ${f.path} [${f.category}] ---`);
|
|
25
|
+
lines.push(f.preview);
|
|
26
|
+
lines.push("");
|
|
27
|
+
}
|
|
28
|
+
return lines.join("\n") + "\n";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildInstalledPackagesSection(context?: ProjectContext): string {
|
|
32
|
+
if (!context?.dependencies || context.dependencies.length === 0) return "";
|
|
33
|
+
return `\n=== Installed Packages (ONLY use packages from this list — NEVER import anything not listed here) ===\n${context.dependencies.join(", ")}\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a context section from files already written in this generation run.
|
|
38
|
+
* Injected before generating files that may import from those paths (e.g., route files
|
|
39
|
+
* importing from API files generated in an earlier task).
|
|
40
|
+
*/
|
|
41
|
+
function buildGeneratedFilesSection(cache: Map<string, string>): string {
|
|
42
|
+
if (cache.size === 0) return "";
|
|
43
|
+
const lines = [
|
|
44
|
+
"\n=== Files Already Generated in This Run — USE EXACT EXPORTS (do not rename or invent alternatives) ===",
|
|
45
|
+
];
|
|
46
|
+
for (const [filePath, content] of cache) {
|
|
47
|
+
lines.push(`\n--- ${filePath} ---`);
|
|
48
|
+
// Include enough to see all export names (first 800 chars covers most API files)
|
|
49
|
+
lines.push(content.slice(0, 800));
|
|
50
|
+
if (content.length > 800) lines.push("... (truncated)");
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n") + "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CodeGenMode = "claude-code" | "api" | "plan";
|
|
56
|
+
|
|
57
|
+
// ─── RTK Helper ────────────────────────────────────────────────────────────────
|
|
58
|
+
// RTK (Rust Token Killer) saves tokens by filtering verbose CLI output.
|
|
59
|
+
// When available, prefix 'claude' with 'rtk' for token savings.
|
|
60
|
+
|
|
61
|
+
function isRtkAvailable(): boolean {
|
|
62
|
+
try {
|
|
63
|
+
execSync("rtk --version", { stdio: "ignore" });
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface FileAction {
|
|
71
|
+
file: string;
|
|
72
|
+
action: "create" | "modify";
|
|
73
|
+
description: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function stripCodeFences(output: string): string {
|
|
79
|
+
// Remove ```lang ... ``` wrapping if present
|
|
80
|
+
const fenced = output.match(/^```(?:\w+)?\n([\s\S]*?)```\s*$/m);
|
|
81
|
+
if (fenced) return fenced[1].trim();
|
|
82
|
+
const lines = output.split("\n");
|
|
83
|
+
if (lines[0].startsWith("```")) lines.shift();
|
|
84
|
+
if (lines[lines.length - 1].trim() === "```") lines.pop();
|
|
85
|
+
return lines.join("\n").trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseJsonArray(text: string): FileAction[] {
|
|
89
|
+
// Try a JSON code fence first
|
|
90
|
+
const fenced = text.match(/```(?:json)?\n(\[[\s\S]*?\])\n```/);
|
|
91
|
+
const raw = fenced ? fenced[1] : text.match(/\[[\s\S]*?\]/)?.[0] ?? "";
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
if (Array.isArray(parsed)) return parsed as FileAction[];
|
|
95
|
+
} catch {
|
|
96
|
+
// fall through
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── CodeGenerator ────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export interface CodeGenOptions {
|
|
104
|
+
/** Run claude non-interactively via -p flag (saves tokens, good for automation) */
|
|
105
|
+
auto?: boolean;
|
|
106
|
+
/** Resume from last checkpoint — skip tasks already marked as done */
|
|
107
|
+
resume?: boolean;
|
|
108
|
+
/** Path to the DSL JSON file — if provided, structured context is injected into prompts */
|
|
109
|
+
dslFilePath?: string;
|
|
110
|
+
/** Repo language type — selects the appropriate codegen system prompt */
|
|
111
|
+
repoType?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class CodeGenerator {
|
|
115
|
+
constructor(
|
|
116
|
+
private provider: AIProvider,
|
|
117
|
+
private mode: CodeGenMode = "claude-code"
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
/** Returns the list of file paths written to disk (useful for api-mode review). */
|
|
121
|
+
async generateCode(
|
|
122
|
+
specFilePath: string,
|
|
123
|
+
workingDir: string,
|
|
124
|
+
context?: ProjectContext,
|
|
125
|
+
options: CodeGenOptions = {}
|
|
126
|
+
): Promise<string[]> {
|
|
127
|
+
let effectiveMode = this.mode;
|
|
128
|
+
|
|
129
|
+
if (effectiveMode === "claude-code" && this.provider.providerName !== "claude") {
|
|
130
|
+
console.log(
|
|
131
|
+
chalk.yellow(
|
|
132
|
+
`\n ⚠ codegen 模式 "claude-code" 需要 Claude,但当前 provider 是 "${this.provider.providerName}"。`
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
console.log(chalk.gray(` 自动切换到 "api" 模式(使用 ${this.provider.providerName}/${this.provider.modelName} 生成代码)。`));
|
|
136
|
+
console.log(chalk.gray(` 提示:运行 \`ai-spec config --codegen api\` 可固化此设置。\n`));
|
|
137
|
+
effectiveMode = "api";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (effectiveMode) {
|
|
141
|
+
case "claude-code":
|
|
142
|
+
await this.runClaudeCode(specFilePath, workingDir, options);
|
|
143
|
+
return [];
|
|
144
|
+
case "api":
|
|
145
|
+
return this.runApiMode(specFilePath, workingDir, context, options);
|
|
146
|
+
case "plan":
|
|
147
|
+
await this.runPlanMode(specFilePath);
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Mode: claude-code ──────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
private isClaudeCLIAvailable(): boolean {
|
|
155
|
+
try {
|
|
156
|
+
execSync("claude --version", { stdio: "ignore" });
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async runClaudeCode(
|
|
164
|
+
specFilePath: string,
|
|
165
|
+
workingDir: string,
|
|
166
|
+
options: CodeGenOptions = {}
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
console.log(chalk.blue("\n─── Code Generation: Claude Code CLI ───────────"));
|
|
169
|
+
|
|
170
|
+
if (!this.isClaudeCLIAvailable()) {
|
|
171
|
+
console.log(chalk.yellow(" ⚠️ Claude Code CLI not found. Falling back to plan mode."));
|
|
172
|
+
console.log(chalk.gray(" Install: npm install -g @anthropic-ai/claude-code"));
|
|
173
|
+
return this.runPlanMode(specFilePath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rtkAvailable = isRtkAvailable();
|
|
177
|
+
const claudeCmd = rtkAvailable ? "rtk claude" : "claude";
|
|
178
|
+
if (rtkAvailable) {
|
|
179
|
+
console.log(chalk.green(" ✓ RTK detected — using rtk claude for token savings"));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const tasks = await loadTasksForSpec(specFilePath);
|
|
183
|
+
|
|
184
|
+
// ── Auto + Tasks: incremental task-by-task execution ────────────────────
|
|
185
|
+
if (options.auto && tasks && tasks.length > 0) {
|
|
186
|
+
return this.runClaudeCodeIncremental(tasks, specFilePath, workingDir, claudeCmd, options);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Interactive or no tasks: single session ──────────────────────────────
|
|
190
|
+
const taskSection = tasks && tasks.length > 0
|
|
191
|
+
? `\n\n== Implementation Tasks (implement in order) ==\n${tasks
|
|
192
|
+
.map((t) => `${t.id} [${t.layer}] ${t.title}\n Files: ${t.filesToTouch.join(", ")}\n Criteria: ${t.acceptanceCriteria.join("; ")}`)
|
|
193
|
+
.join("\n")}`
|
|
194
|
+
: "";
|
|
195
|
+
|
|
196
|
+
const promptContent = `Please read the spec file at ${specFilePath} and implement all the requirements. Create or modify files as necessary.${taskSection}`;
|
|
197
|
+
const promptFile = path.join(workingDir, ".claude-prompt.txt");
|
|
198
|
+
await fs.writeFile(promptFile, promptContent, "utf-8");
|
|
199
|
+
|
|
200
|
+
if (options.auto) {
|
|
201
|
+
console.log(chalk.cyan(` 🤖 Auto mode: running claude -p (non-interactive)...`));
|
|
202
|
+
console.log(chalk.gray(` Spec: ${specFilePath}`));
|
|
203
|
+
try {
|
|
204
|
+
execSync(`${claudeCmd} -p "${promptContent.replace(/"/g, '\\"')}"`, {
|
|
205
|
+
cwd: workingDir,
|
|
206
|
+
stdio: "inherit",
|
|
207
|
+
});
|
|
208
|
+
console.log(chalk.green("\n ✔ Claude Code completed."));
|
|
209
|
+
} catch {
|
|
210
|
+
console.log(chalk.yellow("\n Claude Code exited. Check output above."));
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
console.log(chalk.cyan(` 🚀 Launching ${claudeCmd} in: ${workingDir}`));
|
|
214
|
+
console.log(chalk.gray(` Spec: ${specFilePath}`));
|
|
215
|
+
if (tasks) console.log(chalk.gray(` Tasks: ${tasks.length} tasks loaded into .claude-prompt.txt`));
|
|
216
|
+
console.log(chalk.gray(" Prompt pre-loaded in .claude-prompt.txt\n"));
|
|
217
|
+
try {
|
|
218
|
+
execSync(claudeCmd, { cwd: workingDir, stdio: "inherit" });
|
|
219
|
+
console.log(chalk.green("\n ✔ Claude Code session completed."));
|
|
220
|
+
} catch {
|
|
221
|
+
console.log(chalk.yellow("\n Claude Code session ended. Continuing workflow."));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Incremental claude-code execution: one `claude -p` call per task.
|
|
228
|
+
* Tasks marked as "done" are skipped (resume support).
|
|
229
|
+
* Progress is shown as a percentage bar.
|
|
230
|
+
*/
|
|
231
|
+
private async runClaudeCodeIncremental(
|
|
232
|
+
tasks: SpecTask[],
|
|
233
|
+
specFilePath: string,
|
|
234
|
+
workingDir: string,
|
|
235
|
+
claudeCmd: string,
|
|
236
|
+
options: CodeGenOptions
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const pending = tasks.filter((t) => t.status !== "done");
|
|
239
|
+
const doneCount = tasks.length - pending.length;
|
|
240
|
+
|
|
241
|
+
if (options.resume && doneCount > 0) {
|
|
242
|
+
console.log(chalk.cyan(`\n Resuming: ${doneCount}/${tasks.length} tasks already done — skipping.`));
|
|
243
|
+
} else {
|
|
244
|
+
console.log(chalk.cyan(`\n Incremental mode: ${tasks.length} tasks`));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let completed = doneCount;
|
|
248
|
+
|
|
249
|
+
for (const task of tasks) {
|
|
250
|
+
if (task.status === "done") {
|
|
251
|
+
printTaskProgress(completed, tasks.length, task, "skip");
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
printTaskProgress(completed, tasks.length, task, "run");
|
|
256
|
+
|
|
257
|
+
const taskPrompt =
|
|
258
|
+
`Task: ${task.id} — ${task.title}\n` +
|
|
259
|
+
`Layer: ${task.layer}\n` +
|
|
260
|
+
`Description: ${task.description}\n` +
|
|
261
|
+
`Files to touch: ${task.filesToTouch.join(", ") || "as needed"}\n` +
|
|
262
|
+
`Acceptance criteria:\n${task.acceptanceCriteria.map((c) => ` - ${c}`).join("\n")}\n\n` +
|
|
263
|
+
`Full spec is at: ${specFilePath}\n` +
|
|
264
|
+
`Implement ONLY this task. Do not implement other tasks.`;
|
|
265
|
+
|
|
266
|
+
let taskStatus: "done" | "failed" = "done";
|
|
267
|
+
try {
|
|
268
|
+
execSync(`${claudeCmd} -p "${taskPrompt.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`, {
|
|
269
|
+
cwd: workingDir,
|
|
270
|
+
stdio: "inherit",
|
|
271
|
+
});
|
|
272
|
+
completed++;
|
|
273
|
+
} catch {
|
|
274
|
+
taskStatus = "failed";
|
|
275
|
+
console.log(chalk.yellow(`\n ⚠ Task ${task.id} exited with error — marked as failed. Re-run with --resume to retry.`));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await updateTaskStatus(specFilePath, task.id, taskStatus);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const successCount = tasks.filter((t) => t.status === "done").length + (completed - doneCount);
|
|
282
|
+
console.log(
|
|
283
|
+
chalk.bold(
|
|
284
|
+
`\n ${successCount === tasks.length ? chalk.green("✔") : chalk.yellow("!")} ` +
|
|
285
|
+
`Incremental build: ${completed}/${tasks.length} tasks completed.`
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Mode: api ─────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
private async runApiMode(
|
|
293
|
+
specFilePath: string,
|
|
294
|
+
workingDir: string,
|
|
295
|
+
context?: ProjectContext,
|
|
296
|
+
options: CodeGenOptions = {}
|
|
297
|
+
): Promise<string[]> {
|
|
298
|
+
console.log(
|
|
299
|
+
chalk.blue(
|
|
300
|
+
`\n─── Code Generation: API (${this.provider.providerName}/${this.provider.modelName}) ───`
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const systemPrompt = getCodeGenSystemPrompt(options.repoType);
|
|
305
|
+
if (options.repoType && options.repoType !== "node-express" && options.repoType !== "node-koa" && options.repoType !== "unknown") {
|
|
306
|
+
console.log(chalk.gray(` Language: ${options.repoType} (using language-specific codegen prompt)`));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const spec = await fs.readFile(specFilePath, "utf-8");
|
|
310
|
+
const constitutionSection = context?.constitution
|
|
311
|
+
? `\n=== Project Constitution (MUST follow) ===\n${context.constitution.slice(0, 2000)}\n`
|
|
312
|
+
: "";
|
|
313
|
+
const contextSummary = context
|
|
314
|
+
? `Tech Stack: ${context.techStack.join(", ")}\nExisting files: ${context.fileStructure.slice(0, 20).join(", ")}`
|
|
315
|
+
: "";
|
|
316
|
+
const installedPackagesSection = buildInstalledPackagesSection(context);
|
|
317
|
+
const sharedConfigSection = buildSharedConfigSection(context);
|
|
318
|
+
|
|
319
|
+
// Load DSL for structured context injection.
|
|
320
|
+
const dsl = await loadDslForSpec(specFilePath);
|
|
321
|
+
const dslSection = dsl ? `\n${buildDslContextSection(dsl)}\n` : "";
|
|
322
|
+
if (dsl) {
|
|
323
|
+
const cmpCount = dsl.components?.length ?? 0;
|
|
324
|
+
const cmpSuffix = cmpCount > 0 ? `, ${cmpCount} components` : "";
|
|
325
|
+
console.log(chalk.green(` ✓ DSL loaded — ${dsl.endpoints.length} endpoints, ${dsl.models.length} models${cmpSuffix}`));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Load frontend context for frontend projects (React/Vue/Next/RN)
|
|
329
|
+
const isFrontend = isFrontendDeps(context?.dependencies ?? []);
|
|
330
|
+
let frontendSection = "";
|
|
331
|
+
if (isFrontend) {
|
|
332
|
+
const fctx = await loadFrontendContext(workingDir);
|
|
333
|
+
frontendSection = `\n${buildFrontendContextSection(fctx)}\n`;
|
|
334
|
+
console.log(chalk.gray(` Frontend context: ${fctx.framework} / ${fctx.httpClient} | hooks:${fctx.hookFiles.length} stores:${fctx.storeFiles.length}`));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Use tasks if available for finer-grained generation with resume support
|
|
338
|
+
const tasks = await loadTasksForSpec(specFilePath);
|
|
339
|
+
if (tasks && tasks.length > 0) {
|
|
340
|
+
return this.runApiModeWithTasks(spec, tasks, specFilePath, workingDir, constitutionSection + dslSection + installedPackagesSection, frontendSection, sharedConfigSection, options, systemPrompt, context);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Fallback: plan-then-generate
|
|
344
|
+
console.log(chalk.gray(" [1/2] Planning implementation files..."));
|
|
345
|
+
|
|
346
|
+
const planPrompt = `Based on the feature spec and project context below, list ALL files that need to be created or modified.
|
|
347
|
+
|
|
348
|
+
IMPORTANT: Check the "Existing Shared Config Files" section below FIRST. For any file listed there,
|
|
349
|
+
use action "modify" (never "create") even if you are only adding new entries.
|
|
350
|
+
IMPORTANT: Check the "Frontend Project Context" section below. Extend existing hooks/services/stores — do NOT create new parallel utilities.
|
|
351
|
+
|
|
352
|
+
=== Feature Spec ===
|
|
353
|
+
${spec}
|
|
354
|
+
${constitutionSection}${dslSection}${frontendSection}${installedPackagesSection}${sharedConfigSection}
|
|
355
|
+
=== Project Context ===
|
|
356
|
+
${contextSummary}
|
|
357
|
+
|
|
358
|
+
Output ONLY a valid JSON array:
|
|
359
|
+
[
|
|
360
|
+
{"file": "src/controllers/userController.ts", "action": "create", "description": "Handle user CRUD operations"},
|
|
361
|
+
{"file": "src/routes/client/index.ts", "action": "modify", "description": "Register new routes"}
|
|
362
|
+
]`;
|
|
363
|
+
|
|
364
|
+
let filePlan: FileAction[] = [];
|
|
365
|
+
try {
|
|
366
|
+
const planResponse = await this.provider.generate(planPrompt, systemPrompt);
|
|
367
|
+
filePlan = parseJsonArray(planResponse);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(chalk.red(" Failed to generate file plan:"), err);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (filePlan.length === 0) {
|
|
373
|
+
console.log(chalk.yellow(" Could not determine file plan. Falling back to plan mode."));
|
|
374
|
+
await this.runPlanMode(specFilePath);
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
console.log(chalk.cyan(`\n Plan: ${filePlan.length} file(s) to process`));
|
|
379
|
+
filePlan.forEach((item) => {
|
|
380
|
+
const icon = item.action === "create" ? chalk.green("+") : chalk.yellow("~");
|
|
381
|
+
console.log(` ${icon} ${item.file}: ${chalk.gray(item.description)}`);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const { files } = await this.generateFiles(filePlan, spec, workingDir, constitutionSection + dslSection + frontendSection + installedPackagesSection, systemPrompt);
|
|
385
|
+
return files;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async runApiModeWithTasks(
|
|
389
|
+
spec: string,
|
|
390
|
+
tasks: SpecTask[],
|
|
391
|
+
specFilePath: string,
|
|
392
|
+
workingDir: string,
|
|
393
|
+
constitutionSection: string,
|
|
394
|
+
frontendSection: string = "",
|
|
395
|
+
sharedConfigSection: string = "",
|
|
396
|
+
options: CodeGenOptions = {},
|
|
397
|
+
systemPrompt: string = getCodeGenSystemPrompt(),
|
|
398
|
+
context?: ProjectContext
|
|
399
|
+
): Promise<string[]> {
|
|
400
|
+
const pendingTasks = tasks.filter((t) => t.status !== "done");
|
|
401
|
+
const doneCount = tasks.length - pendingTasks.length;
|
|
402
|
+
|
|
403
|
+
if (options.resume && doneCount > 0) {
|
|
404
|
+
console.log(chalk.cyan(`\n Task-based generation (resume): ${tasks.length} tasks (${chalk.green(doneCount + " already done")}, skipping)`));
|
|
405
|
+
} else if (doneCount > 0) {
|
|
406
|
+
console.log(chalk.cyan(`\n Task-based generation: ${tasks.length} tasks (${chalk.green(doneCount + " already done")}, resuming from checkpoint)`));
|
|
407
|
+
} else {
|
|
408
|
+
console.log(chalk.cyan(`\n Task-based generation: ${tasks.length} tasks`));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Build a set of shared config file paths for quick lookup.
|
|
412
|
+
// Shared config files (e.g. routes/index.ts) are excluded from per-task parallel
|
|
413
|
+
// filePlans and instead updated once per layer after all parallel tasks complete.
|
|
414
|
+
const sharedConfigPaths = new Set(
|
|
415
|
+
(context?.sharedConfigFiles ?? []).map((f) => f.path)
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Track which shared config files have already been processed across layers
|
|
419
|
+
const processedSharedConfigs = new Set<string>();
|
|
420
|
+
|
|
421
|
+
// Cross-task generated file cache: stores content of API/service/store files
|
|
422
|
+
// written in earlier layers so subsequent layers can see exact function names.
|
|
423
|
+
const generatedFileCache = new Map<string, string>();
|
|
424
|
+
|
|
425
|
+
let totalSuccess = 0;
|
|
426
|
+
let totalFiles = 0;
|
|
427
|
+
let completedTasks = doneCount;
|
|
428
|
+
const allGeneratedFiles: string[] = [];
|
|
429
|
+
|
|
430
|
+
// ── Show already-done tasks ───────────────────────────────────────────────
|
|
431
|
+
for (const task of tasks) {
|
|
432
|
+
if (task.status === "done") {
|
|
433
|
+
printTaskProgress(completedTasks++, tasks.length, task, "skip");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Group pending tasks by layer in dependency order ──────────────────────
|
|
438
|
+
const LAYER_ORDER = ["data", "infra", "service", "api", "test"];
|
|
439
|
+
const layerGroups: Array<{ layer: string; tasks: SpecTask[] }> = [];
|
|
440
|
+
|
|
441
|
+
for (const layer of LAYER_ORDER) {
|
|
442
|
+
const group = pendingTasks.filter((t) => t.layer === layer);
|
|
443
|
+
if (group.length > 0) layerGroups.push({ layer, tasks: group });
|
|
444
|
+
}
|
|
445
|
+
// Unknown layers run last, in their original order
|
|
446
|
+
const unknownTasks = pendingTasks.filter((t) => !LAYER_ORDER.includes(t.layer));
|
|
447
|
+
if (unknownTasks.length > 0) layerGroups.push({ layer: "other", tasks: unknownTasks });
|
|
448
|
+
|
|
449
|
+
// ── Process each layer ────────────────────────────────────────────────────
|
|
450
|
+
for (const { layer, tasks: layerTasks } of layerGroups) {
|
|
451
|
+
const isParallel = layerTasks.length > 1;
|
|
452
|
+
const layerIcon = LAYER_ICONS[layer] ?? " ";
|
|
453
|
+
|
|
454
|
+
if (isParallel) {
|
|
455
|
+
const pct = Math.round((completedTasks / tasks.length) * 100);
|
|
456
|
+
const barWidth = 20;
|
|
457
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
458
|
+
const bar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(barWidth - filled));
|
|
459
|
+
console.log(
|
|
460
|
+
chalk.bold(`\n [${bar}] ${pct}% ⚡ Layer [${layer}] ${layerIcon} — ${layerTasks.length} tasks running in parallel`)
|
|
461
|
+
);
|
|
462
|
+
} else {
|
|
463
|
+
printTaskProgress(completedTasks, tasks.length, layerTasks[0], "run");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Snapshot the cache before this layer starts — all parallel tasks in the same
|
|
467
|
+
// layer see the same (pre-layer) cache, preventing partial-write races.
|
|
468
|
+
const generatedFilesSection = buildGeneratedFilesSection(generatedFileCache);
|
|
469
|
+
|
|
470
|
+
// ── Execute all tasks in this layer concurrently ──────────────────────
|
|
471
|
+
interface TaskResult {
|
|
472
|
+
task: SpecTask;
|
|
473
|
+
files: string[];
|
|
474
|
+
createdFiles: string[]; // only "create" actions — used for shared config batching
|
|
475
|
+
success: number;
|
|
476
|
+
total: number;
|
|
477
|
+
impliesRegistration: boolean;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const taskResultPromises: Promise<TaskResult>[] = layerTasks.map(async (task) => {
|
|
481
|
+
if (task.filesToTouch.length === 0) {
|
|
482
|
+
if (!isParallel) console.log(chalk.gray(" No files specified, skipping."));
|
|
483
|
+
return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Resolve file actions — exclude shared config files (they're batched post-layer)
|
|
487
|
+
const filePlan: FileAction[] = await Promise.all(
|
|
488
|
+
task.filesToTouch
|
|
489
|
+
.filter((f) => !sharedConfigPaths.has(f))
|
|
490
|
+
.map(async (f) => {
|
|
491
|
+
const exists = await fs.pathExists(path.join(workingDir, f));
|
|
492
|
+
return {
|
|
493
|
+
file: f,
|
|
494
|
+
action: (exists ? "modify" : "create") as "create" | "modify",
|
|
495
|
+
description: task.description,
|
|
496
|
+
};
|
|
497
|
+
})
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// Determine if this task creates registerable artifacts (for post-layer shared config update)
|
|
501
|
+
const createsNewFiles = filePlan.some((f) => f.action === "create");
|
|
502
|
+
const taskText = `${task.title} ${task.description}`.toLowerCase();
|
|
503
|
+
const impliesRegistration =
|
|
504
|
+
createsNewFiles &&
|
|
505
|
+
(taskText.includes("route") ||
|
|
506
|
+
taskText.includes("router") ||
|
|
507
|
+
taskText.includes("page") ||
|
|
508
|
+
taskText.includes("view") ||
|
|
509
|
+
taskText.includes("store") ||
|
|
510
|
+
taskText.includes("service") ||
|
|
511
|
+
taskText.includes("component") ||
|
|
512
|
+
taskText.includes("menu") ||
|
|
513
|
+
taskText.includes("navigation") ||
|
|
514
|
+
taskText.includes("模块") ||
|
|
515
|
+
taskText.includes("页面") ||
|
|
516
|
+
taskText.includes("路由") ||
|
|
517
|
+
taskText.includes("注册"));
|
|
518
|
+
|
|
519
|
+
if (filePlan.length === 0) {
|
|
520
|
+
return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const taskContext = `Task: ${task.id} — ${task.title}\n${task.description}\nAcceptance: ${task.acceptanceCriteria.join("; ")}`;
|
|
524
|
+
const { success, total, files } = await this.generateFiles(
|
|
525
|
+
filePlan,
|
|
526
|
+
`${spec}\n\n=== Current Task ===\n${taskContext}`,
|
|
527
|
+
workingDir,
|
|
528
|
+
constitutionSection + frontendSection + sharedConfigSection + generatedFilesSection,
|
|
529
|
+
systemPrompt,
|
|
530
|
+
isParallel ? task.id : undefined // prefix output lines with task ID in parallel mode
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const createdFiles = filePlan
|
|
534
|
+
.filter((fp) => fp.action === "create")
|
|
535
|
+
.map((fp) => fp.file);
|
|
536
|
+
|
|
537
|
+
return { task, files, createdFiles, success, total, impliesRegistration };
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const layerResults = await Promise.all(taskResultPromises);
|
|
541
|
+
|
|
542
|
+
// ── Aggregate layer results ───────────────────────────────────────────
|
|
543
|
+
if (isParallel) {
|
|
544
|
+
console.log(""); // blank line after parallel output block
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const result of layerResults) {
|
|
548
|
+
totalSuccess += result.success;
|
|
549
|
+
totalFiles += result.total;
|
|
550
|
+
allGeneratedFiles.push(...result.files);
|
|
551
|
+
|
|
552
|
+
if (isParallel) {
|
|
553
|
+
const icon = result.success === result.total ? chalk.green("✔") : chalk.yellow("!");
|
|
554
|
+
const layerTaskIcon = LAYER_ICONS[result.task.layer] ?? " ";
|
|
555
|
+
console.log(` ${icon} ${result.task.id} ${layerTaskIcon} ${result.task.title} — ${result.success}/${result.total} files`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const taskStatus = result.success === result.total ? "done" : "failed";
|
|
559
|
+
await updateTaskStatus(specFilePath, result.task.id, taskStatus);
|
|
560
|
+
if (taskStatus === "failed") {
|
|
561
|
+
console.log(chalk.yellow(` ⚠ ${result.task.id} marked as failed — re-run with --resume to retry`));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
completedTasks += layerTasks.length;
|
|
566
|
+
|
|
567
|
+
// ── Update generatedFileCache with all files written in this layer ────
|
|
568
|
+
// Done after all parallel tasks complete — ensures the next layer sees
|
|
569
|
+
// the full set of exports from this layer, not a partial view.
|
|
570
|
+
for (const result of layerResults) {
|
|
571
|
+
for (const writtenFile of result.files) {
|
|
572
|
+
if (/src[\\/](api[s]?|services?|stores?|composables?)[\\/]/.test(writtenFile)) {
|
|
573
|
+
try {
|
|
574
|
+
const content = await fs.readFile(path.join(workingDir, writtenFile), "utf-8");
|
|
575
|
+
generatedFileCache.set(writtenFile, content);
|
|
576
|
+
} catch { /* ignore */ }
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Post-layer: batch shared config update ────────────────────────────
|
|
582
|
+
// If any task in this layer created registerable files, update shared config
|
|
583
|
+
// files once using the complete list of new modules from the whole layer.
|
|
584
|
+
const anyImpliesRegistration = layerResults.some((r) => r.impliesRegistration);
|
|
585
|
+
if (anyImpliesRegistration && sharedConfigPaths.size > 0 && context?.sharedConfigFiles) {
|
|
586
|
+
const allCreatedInLayer = layerResults.flatMap((r) => r.createdFiles);
|
|
587
|
+
|
|
588
|
+
for (const sharedFile of context.sharedConfigFiles) {
|
|
589
|
+
if (processedSharedConfigs.has(sharedFile.path)) continue;
|
|
590
|
+
|
|
591
|
+
const newModuleNames = allCreatedInLayer
|
|
592
|
+
.filter((f) => f !== sharedFile.path)
|
|
593
|
+
.map((f) => path.basename(f).replace(/\.[jt]sx?$/, ""));
|
|
594
|
+
|
|
595
|
+
if (newModuleNames.length === 0 && sharedFile.category !== "route-index" && sharedFile.category !== "store-index") continue;
|
|
596
|
+
|
|
597
|
+
let purpose = `Register/update ${sharedFile.category} entries for the new feature`;
|
|
598
|
+
if ((sharedFile.category === "route-index" || sharedFile.category === "store-index") && newModuleNames.length > 0) {
|
|
599
|
+
purpose = `Add to this file: import ${newModuleNames.join(", ")} from their respective paths and register them in the export/default array. Do NOT remove any existing imports.`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
console.log(chalk.gray(`\n + updating shared config: ${sharedFile.path} [${sharedFile.category}]`));
|
|
603
|
+
const updatedGeneratedFilesSection = buildGeneratedFilesSection(generatedFileCache);
|
|
604
|
+
await this.generateFiles(
|
|
605
|
+
[{ file: sharedFile.path, action: "modify", description: purpose }],
|
|
606
|
+
`${spec}\n\n=== Context ===\nUpdating shared registration after layer [${layer}] completed. New modules: ${newModuleNames.join(", ")}.`,
|
|
607
|
+
workingDir,
|
|
608
|
+
constitutionSection + frontendSection + sharedConfigSection + updatedGeneratedFilesSection,
|
|
609
|
+
systemPrompt
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
processedSharedConfigs.add(sharedFile.path);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
console.log(
|
|
618
|
+
chalk.bold(
|
|
619
|
+
`\n ${totalSuccess === totalFiles ? chalk.green("✔") : chalk.yellow("!")} Task-based generation: ${totalSuccess}/${totalFiles} files written across ${pendingTasks.length} tasks.`
|
|
620
|
+
)
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
return allGeneratedFiles;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private async generateFiles(
|
|
627
|
+
filePlan: FileAction[],
|
|
628
|
+
spec: string,
|
|
629
|
+
workingDir: string,
|
|
630
|
+
constitutionSection: string,
|
|
631
|
+
systemPrompt: string = getCodeGenSystemPrompt(),
|
|
632
|
+
/**
|
|
633
|
+
* When set, output lines are prefixed with "[taskLabel]" (parallel mode).
|
|
634
|
+
* Uses console.log (not process.stdout.write) to avoid line interleaving.
|
|
635
|
+
*/
|
|
636
|
+
taskLabel?: string
|
|
637
|
+
): Promise<{ success: number; total: number; files: string[] }> {
|
|
638
|
+
const prefix = taskLabel ? ` [${chalk.cyan(taskLabel)}] ` : " ";
|
|
639
|
+
if (!taskLabel) {
|
|
640
|
+
console.log(chalk.gray(`\n Generating ${filePlan.length} file(s)...`));
|
|
641
|
+
}
|
|
642
|
+
let successCount = 0;
|
|
643
|
+
const writtenFiles: string[] = [];
|
|
644
|
+
|
|
645
|
+
for (const item of filePlan) {
|
|
646
|
+
const fullPath = path.join(workingDir, item.file);
|
|
647
|
+
let existingContent = "";
|
|
648
|
+
|
|
649
|
+
if (await fs.pathExists(fullPath)) {
|
|
650
|
+
existingContent = await fs.readFile(fullPath, "utf-8");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const codePrompt = `Implement this file.
|
|
654
|
+
|
|
655
|
+
File: ${item.file}
|
|
656
|
+
Purpose: ${item.description}
|
|
657
|
+
|
|
658
|
+
=== Feature Spec ===
|
|
659
|
+
${spec}
|
|
660
|
+
${constitutionSection}
|
|
661
|
+
=== ${existingContent ? "Existing content (modify and return the complete file)" : "Create this file from scratch"} ===
|
|
662
|
+
${existingContent || "Output only the complete file content."}`;
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
const raw = await this.provider.generate(codePrompt, systemPrompt);
|
|
666
|
+
const fileContent = stripCodeFences(raw);
|
|
667
|
+
await fs.ensureDir(path.dirname(fullPath));
|
|
668
|
+
await fs.writeFile(fullPath, fileContent, "utf-8");
|
|
669
|
+
console.log(`${prefix}${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)} ${chalk.green("✔")}`);
|
|
670
|
+
successCount++;
|
|
671
|
+
writtenFiles.push(item.file);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
console.log(`${prefix}${chalk.red("✘")} ${chalk.bold(item.file)} — ${chalk.red((err as Error).message)}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (!taskLabel) {
|
|
678
|
+
console.log(
|
|
679
|
+
chalk.bold(
|
|
680
|
+
` ${successCount === filePlan.length ? chalk.green("✔") : chalk.yellow("!")} ${successCount}/${filePlan.length} files written.`
|
|
681
|
+
)
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
return { success: successCount, total: filePlan.length, files: writtenFiles };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ── Mode: plan ─────────────────────────────────────────────────────────────
|
|
688
|
+
|
|
689
|
+
private async runPlanMode(specFilePath: string): Promise<void> {
|
|
690
|
+
console.log(chalk.blue("\n─── Implementation Plan ─────────────────────────"));
|
|
691
|
+
|
|
692
|
+
const spec = await fs.readFile(specFilePath, "utf-8");
|
|
693
|
+
const plan = await this.provider.generate(
|
|
694
|
+
`Create a detailed, step-by-step implementation plan for the following feature spec.
|
|
695
|
+
Be specific about:
|
|
696
|
+
- Which files to create or modify
|
|
697
|
+
- Key functions/classes to implement
|
|
698
|
+
- Data flow and integration points
|
|
699
|
+
- Suggested implementation order
|
|
700
|
+
|
|
701
|
+
${spec}`,
|
|
702
|
+
"You are a senior developer creating an actionable implementation guide."
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
console.log(chalk.cyan("\n") + plan);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ─── Progress Bar Helper ───────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
const LAYER_ICONS: Record<string, string> = {
|
|
712
|
+
data: "💾",
|
|
713
|
+
infra: "⚙️ ",
|
|
714
|
+
service: "🔧",
|
|
715
|
+
api: "🌐",
|
|
716
|
+
test: "🧪",
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
export function printTaskProgress(
|
|
720
|
+
completed: number,
|
|
721
|
+
total: number,
|
|
722
|
+
task: SpecTask,
|
|
723
|
+
mode: "run" | "skip"
|
|
724
|
+
): void {
|
|
725
|
+
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
726
|
+
const barWidth = 20;
|
|
727
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
728
|
+
const bar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(barWidth - filled));
|
|
729
|
+
const icon = LAYER_ICONS[task.layer] ?? " ";
|
|
730
|
+
|
|
731
|
+
if (mode === "skip") {
|
|
732
|
+
console.log(
|
|
733
|
+
chalk.gray(`\n [${bar}] ${pct}% ✓ ${task.id} ${icon} ${task.title} — already done`)
|
|
734
|
+
);
|
|
735
|
+
} else {
|
|
736
|
+
console.log(
|
|
737
|
+
chalk.bold(`\n [${bar}] ${pct}% → ${task.id} ${icon} ${task.title}`)
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
}
|