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.
Files changed (60) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/README.md +1211 -146
  3. package/RELEASE_LOG.md +1444 -0
  4. package/cli/index.ts +1961 -0
  5. package/cli/welcome.ts +151 -0
  6. package/core/code-generator.ts +740 -0
  7. package/core/combined-generator.ts +63 -0
  8. package/core/constitution-consolidator.ts +141 -0
  9. package/core/constitution-generator.ts +89 -0
  10. package/core/context-loader.ts +453 -0
  11. package/core/contract-bridge.ts +217 -0
  12. package/core/dsl-extractor.ts +337 -0
  13. package/core/dsl-types.ts +166 -0
  14. package/core/dsl-validator.ts +450 -0
  15. package/core/error-feedback.ts +354 -0
  16. package/core/frontend-context-loader.ts +602 -0
  17. package/core/global-constitution.ts +88 -0
  18. package/core/key-store.ts +49 -0
  19. package/core/knowledge-memory.ts +171 -0
  20. package/core/mock-server-generator.ts +571 -0
  21. package/core/openapi-exporter.ts +361 -0
  22. package/core/requirement-decomposer.ts +198 -0
  23. package/core/reviewer.ts +259 -0
  24. package/core/spec-assessor.ts +99 -0
  25. package/core/spec-generator.ts +428 -0
  26. package/core/spec-refiner.ts +89 -0
  27. package/core/spec-updater.ts +227 -0
  28. package/core/spec-versioning.ts +213 -0
  29. package/core/task-generator.ts +174 -0
  30. package/core/test-generator.ts +273 -0
  31. package/core/workspace-loader.ts +256 -0
  32. package/dist/cli/index.js +6717 -672
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/index.mjs +6717 -670
  35. package/dist/cli/index.mjs.map +1 -1
  36. package/dist/index.d.mts +147 -27
  37. package/dist/index.d.ts +147 -27
  38. package/dist/index.js +2337 -286
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2329 -285
  41. package/dist/index.mjs.map +1 -1
  42. package/git/worktree.ts +109 -0
  43. package/index.ts +9 -0
  44. package/package.json +4 -28
  45. package/prompts/codegen.prompt.ts +259 -0
  46. package/prompts/consolidate.prompt.ts +73 -0
  47. package/prompts/constitution.prompt.ts +63 -0
  48. package/prompts/decompose.prompt.ts +168 -0
  49. package/prompts/dsl.prompt.ts +203 -0
  50. package/prompts/frontend-spec.prompt.ts +191 -0
  51. package/prompts/global-constitution.prompt.ts +61 -0
  52. package/prompts/spec-assess.prompt.ts +53 -0
  53. package/prompts/spec.prompt.ts +102 -0
  54. package/prompts/tasks.prompt.ts +35 -0
  55. package/prompts/testgen.prompt.ts +84 -0
  56. package/prompts/update.prompt.ts +131 -0
  57. package/purpose.docx +0 -0
  58. package/purpose.md +444 -0
  59. package/tsconfig.json +14 -0
  60. 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
+ }