ai-spec-dev 0.38.0 → 0.42.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.
Files changed (89) hide show
  1. package/.ai-spec-workspace.json +17 -0
  2. package/.ai-spec.json +7 -0
  3. package/cli/commands/create.ts +9 -1176
  4. package/cli/commands/dashboard.ts +1 -1
  5. package/cli/pipeline/helpers.ts +34 -0
  6. package/cli/pipeline/multi-repo.ts +483 -0
  7. package/cli/pipeline/single-repo.ts +764 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/cli-ui.ts +136 -0
  10. package/core/code-generator.ts +56 -343
  11. package/core/codegen/helpers.ts +219 -0
  12. package/core/codegen/topo-sort.ts +98 -0
  13. package/core/constitution-consolidator.ts +2 -2
  14. package/core/dsl-coverage-checker.ts +298 -0
  15. package/core/dsl-extractor.ts +19 -46
  16. package/core/dsl-feedback.ts +1 -1
  17. package/core/dsl-validator.ts +74 -0
  18. package/core/error-feedback.ts +99 -13
  19. package/core/frontend-context-loader.ts +27 -5
  20. package/core/knowledge-memory.ts +52 -0
  21. package/core/mock/fixtures.ts +89 -0
  22. package/core/mock/proxy.ts +380 -0
  23. package/core/mock-server-generator.ts +12 -460
  24. package/core/provider-utils.ts +8 -7
  25. package/core/requirement-decomposer.ts +4 -28
  26. package/core/reviewer.ts +1 -1
  27. package/core/safe-json.ts +76 -0
  28. package/core/spec-updater.ts +5 -21
  29. package/core/token-budget.ts +124 -0
  30. package/core/vcr.ts +20 -1
  31. package/demo-backend/.ai-spec-constitution.md +65 -0
  32. package/demo-backend/package.json +21 -0
  33. package/demo-backend/prisma/schema.prisma +22 -0
  34. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
  35. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
  36. package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
  37. package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
  38. package/demo-backend/src/index.ts +17 -0
  39. package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
  40. package/demo-backend/src/routes/bookmark.routes.ts +11 -0
  41. package/demo-backend/src/routes/index.ts +8 -0
  42. package/demo-backend/src/services/bookmark.service.test.ts +433 -0
  43. package/demo-backend/src/services/bookmark.service.ts +261 -0
  44. package/demo-backend/tsconfig.json +12 -0
  45. package/demo-frontend/.ai-spec-constitution.md +95 -0
  46. package/demo-frontend/package.json +23 -0
  47. package/demo-frontend/src/App.tsx +12 -0
  48. package/demo-frontend/src/main.tsx +9 -0
  49. package/demo-frontend/tsconfig.json +13 -0
  50. package/dist/cli/index.js +4351 -3666
  51. package/dist/cli/index.js.map +1 -1
  52. package/dist/cli/index.mjs +3997 -3312
  53. package/dist/cli/index.mjs.map +1 -1
  54. package/dist/index.d.mts +18 -16
  55. package/dist/index.d.ts +18 -16
  56. package/dist/index.js +388 -188
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +386 -186
  59. package/dist/index.mjs.map +1 -1
  60. package/package.json +2 -2
  61. package/tests/auto-consolidation.test.ts +109 -0
  62. package/tests/combined-generator.test.ts +81 -0
  63. package/tests/constitution-consolidator.test.ts +161 -0
  64. package/tests/constitution-generator.test.ts +94 -0
  65. package/tests/contract-bridge.test.ts +201 -0
  66. package/tests/design-dialogue.test.ts +108 -0
  67. package/tests/dsl-coverage-checker.test.ts +230 -0
  68. package/tests/dsl-feedback.test.ts +45 -0
  69. package/tests/dsl-validator-xref.test.ts +99 -0
  70. package/tests/error-feedback-repair.test.ts +319 -0
  71. package/tests/error-feedback-validation.test.ts +91 -0
  72. package/tests/frontend-context-loader.test.ts +609 -0
  73. package/tests/global-constitution.test.ts +110 -0
  74. package/tests/key-store.test.ts +73 -0
  75. package/tests/knowledge-memory.test.ts +327 -0
  76. package/tests/project-index.test.ts +206 -0
  77. package/tests/prompt-hasher.test.ts +19 -0
  78. package/tests/requirement-decomposer.test.ts +171 -0
  79. package/tests/reviewer.test.ts +4 -1
  80. package/tests/run-logger.test.ts +289 -0
  81. package/tests/run-snapshot.test.ts +113 -0
  82. package/tests/safe-json.test.ts +63 -0
  83. package/tests/spec-updater.test.ts +161 -0
  84. package/tests/test-generator.test.ts +146 -0
  85. package/tests/token-budget.test.ts +124 -0
  86. package/tests/vcr-hash.test.ts +101 -0
  87. package/tests/workspace-loader.test.ts +277 -0
  88. package/RELEASE_LOG.md +0 -2731
  89. package/purpose.md +0 -1294
package/cli/utils.ts CHANGED
@@ -20,6 +20,8 @@ export interface AiSpecConfig {
20
20
  minHarnessScore?: number;
21
21
  /** Maximum error-feedback cycles before giving up (default: 2, TDD default: 3). */
22
22
  maxErrorCycles?: number;
23
+ /** §9 lesson count threshold for auto-consolidation (default: 12). */
24
+ autoConsolidateThreshold?: number;
23
25
  }
24
26
 
25
27
  export const CONFIG_FILE = ".ai-spec.json";
package/core/cli-ui.ts ADDED
@@ -0,0 +1,136 @@
1
+ import chalk from "chalk";
2
+
3
+ // ─── Spinner ──────────────────────────────────────────────────────────────────
4
+
5
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+
7
+ export interface Spinner {
8
+ /** Update the text shown after the spinner. */
9
+ update(text: string): void;
10
+ /** Stop the spinner and show a final message. */
11
+ stop(finalText?: string): void;
12
+ /** Stop with a success (✔) mark. */
13
+ succeed(text: string): void;
14
+ /** Stop with a failure (✘) mark. */
15
+ fail(text: string): void;
16
+ }
17
+
18
+ /**
19
+ * Start a CLI spinner that renders on a single line.
20
+ * Works in any TTY; silently degrades to static text in non-TTY (CI).
21
+ */
22
+ export function startSpinner(text: string): Spinner {
23
+ const isTTY = process.stderr.isTTY;
24
+ let frame = 0;
25
+ let currentText = text;
26
+ let stopped = false;
27
+
28
+ function render() {
29
+ if (stopped) return;
30
+ const symbol = chalk.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
31
+ if (isTTY) {
32
+ process.stderr.write(`\r ${symbol} ${currentText}${" ".repeat(10)}`);
33
+ }
34
+ frame++;
35
+ }
36
+
37
+ // Print initial line for non-TTY
38
+ if (!isTTY) {
39
+ process.stderr.write(` … ${currentText}\n`);
40
+ }
41
+
42
+ const timer = setInterval(render, 80);
43
+ render();
44
+
45
+ return {
46
+ update(newText: string) {
47
+ currentText = newText;
48
+ },
49
+ stop(finalText?: string) {
50
+ if (stopped) return;
51
+ stopped = true;
52
+ clearInterval(timer);
53
+ if (isTTY) {
54
+ process.stderr.write(`\r${" ".repeat(currentText.length + 20)}\r`);
55
+ }
56
+ if (finalText) {
57
+ process.stderr.write(` ${finalText}\n`);
58
+ }
59
+ },
60
+ succeed(successText: string) {
61
+ this.stop(chalk.green(`✔ ${successText}`));
62
+ },
63
+ fail(failText: string) {
64
+ this.stop(chalk.red(`✘ ${failText}`));
65
+ },
66
+ };
67
+ }
68
+
69
+ // ─── Retry Countdown ──────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Show an animated countdown during retry wait.
73
+ * Displays error details + a live seconds countdown.
74
+ */
75
+ export async function retryCountdown(opts: {
76
+ attempt: number;
77
+ maxAttempts: number;
78
+ waitMs: number;
79
+ errorMessage: string;
80
+ label: string;
81
+ }): Promise<void> {
82
+ const { attempt, maxAttempts, waitMs, errorMessage, label } = opts;
83
+ const isTTY = process.stderr.isTTY;
84
+
85
+ // Error box
86
+ const shortErr = errorMessage.length > 120
87
+ ? errorMessage.slice(0, 117) + "..."
88
+ : errorMessage;
89
+
90
+ process.stderr.write("\n");
91
+ process.stderr.write(chalk.yellow(` ┌─ Retry ${attempt}/${maxAttempts} `) + chalk.gray(`[${label}]`) + chalk.yellow(` ${"─".repeat(Math.max(1, 40 - label.length))}\n`));
92
+ process.stderr.write(chalk.yellow(` │ `) + chalk.white(shortErr) + "\n");
93
+ process.stderr.write(chalk.yellow(` │ `) + chalk.gray(`Waiting before retry...`) + "\n");
94
+
95
+ // Animated countdown
96
+ const totalSeconds = Math.ceil(waitMs / 1000);
97
+ for (let s = totalSeconds; s > 0; s--) {
98
+ const bar = chalk.green("█".repeat(totalSeconds - s)) + chalk.gray("░".repeat(s));
99
+ const line = chalk.yellow(` │ `) + `${bar} ${chalk.bold.white(`${s}s`)}`;
100
+ if (isTTY) {
101
+ process.stderr.write(`\r${line}${" ".repeat(10)}`);
102
+ }
103
+ await new Promise<void>((r) => setTimeout(r, 1000));
104
+ }
105
+
106
+ if (isTTY) {
107
+ process.stderr.write(`\r${" ".repeat(70)}\r`);
108
+ }
109
+ process.stderr.write(chalk.yellow(` └─ `) + chalk.cyan(`Retrying now...`) + "\n\n");
110
+ }
111
+
112
+ // ─── Stage Progress ───────────────────────────────────────────────────────────
113
+
114
+ const STAGE_ICONS: Record<string, string> = {
115
+ context_load: "📂",
116
+ design_dialogue: "💬",
117
+ spec_gen: "📝",
118
+ spec_refine: "✏️ ",
119
+ spec_assess: "📊",
120
+ dsl_extract: "🔗",
121
+ dsl_gap_feedback: "🔍",
122
+ codegen: "⚙️ ",
123
+ test_gen: "🧪",
124
+ error_feedback: "🔧",
125
+ review: "🔎",
126
+ self_eval: "📈",
127
+ };
128
+
129
+ /**
130
+ * Start a pipeline stage with a spinner.
131
+ * Returns a handle to succeed/fail/update the stage display.
132
+ */
133
+ export function startStage(stageKey: string, label: string): Spinner {
134
+ const icon = STAGE_ICONS[stageKey] ?? "▸";
135
+ return startSpinner(`${icon} ${label}`);
136
+ }
@@ -10,242 +10,26 @@ import { loadDslForSpec, buildDslContextSection } from "./dsl-extractor";
10
10
  import { loadFrontendContext, buildFrontendContextSection } from "./frontend-context-loader";
11
11
  import { getActiveSnapshot } from "./run-snapshot";
12
12
  import { getActiveLogger } from "./run-logger";
13
-
14
- // ─── Shared Config Helper ───────────────────────────────────────────────────
15
-
16
- function buildSharedConfigSection(context?: ProjectContext): string {
17
- if (!context?.sharedConfigFiles || context.sharedConfigFiles.length === 0) return "";
18
-
19
- const lines: string[] = [
20
- "\n=== Existing Shared Config Files (study these to learn project conventions) ===",
21
- "These are real files from the project. Use them as ground truth for naming, structure, and registration patterns.",
22
- "Modify them in-place when adding new entries. Do NOT create parallel files for the same purpose.\n",
23
- ];
24
-
25
- for (const f of context.sharedConfigFiles) {
26
- lines.push(`--- File: ${f.path} [${f.category}] ---`);
27
- lines.push(f.preview);
28
- lines.push("");
29
- }
30
- return lines.join("\n") + "\n";
31
- }
32
-
33
- function buildInstalledPackagesSection(context?: ProjectContext): string {
34
- if (!context?.dependencies || context.dependencies.length === 0) return "";
35
- return `\n=== Installed Packages (ONLY use packages from this list — NEVER import anything not listed here) ===\n${context.dependencies.join(", ")}\n`;
36
- }
37
-
38
- /**
39
- * Extract a behavioral contract summary from a generated file.
40
- *
41
- * Captures:
42
- * - export interface / type / enum — full multi-line blocks (the actual TS contracts)
43
- * - export function / const / class — opening signature line
44
- * - Throw statements — error codes & validation constraints
45
- *
46
- * Multi-line blocks (interface, type alias with {}) are captured in full so
47
- * downstream tasks see complete method signatures and field shapes, not just
48
- * a single-line "export interface Foo {" that conveys nothing.
49
- *
50
- * Falls back to first 3000 chars for CommonJS files with no explicit exports.
51
- */
52
- export function extractBehavioralContract(content: string): string {
53
- const lines = content.split("\n");
54
- const contractLines: string[] = [];
55
- const throwLines: string[] = [];
56
- let i = 0;
57
-
58
- while (i < lines.length) {
59
- const line = lines[i];
60
- const trimmed = line.trim();
61
-
62
- // ── Multi-line block exports: interface / type X = { / class / enum ──────
63
- // Capture the full block so downstream tasks see the complete contract.
64
- if (/^export\s+(interface|type|class|abstract\s+class|enum)\s/.test(trimmed)) {
65
- contractLines.push(line.trimEnd());
66
- if (trimmed.includes("{")) {
67
- let depth =
68
- (trimmed.match(/\{/g) ?? []).length -
69
- (trimmed.match(/\}/g) ?? []).length;
70
- i++;
71
- while (i < lines.length && depth > 0) {
72
- const inner = lines[i];
73
- contractLines.push(inner.trimEnd());
74
- depth += (inner.match(/\{/g) ?? []).length;
75
- depth -= (inner.match(/\}/g) ?? []).length;
76
- i++;
77
- }
78
- } else {
79
- i++;
80
- }
81
- continue;
82
- }
83
-
84
- // ── export const X = defineStore(...) — capture full block ───────────────
85
- // Pinia stores wrap all actions inside defineStore(). Without the full block
86
- // the consumer only sees "export const useTaskStore = defineStore(" and has
87
- // to guess every action name — the primary source of fetchTasks→fetchTaskList
88
- // hallucinations. Capture the complete defineStore(...) call so the return
89
- // object (public API) is visible.
90
- if (/^export\s+const\s+\w+\s*=\s*(defineStore|createStore|createSlice)\s*\(/.test(trimmed)) {
91
- contractLines.push(line.trimEnd());
92
- let depth = (trimmed.match(/\(/g) ?? []).length - (trimmed.match(/\)/g) ?? []).length;
93
- i++;
94
- while (i < lines.length && depth > 0) {
95
- const inner = lines[i];
96
- contractLines.push(inner.trimEnd());
97
- depth += (inner.match(/\(/g) ?? []).length;
98
- depth -= (inner.match(/\)/g) ?? []).length;
99
- i++;
100
- }
101
- continue;
102
- }
103
-
104
- // ── return { ... } — composable/store public API surface ─────────────────
105
- // In Pinia composition-API stores and Vue composables the return object is
106
- // the definitive list of exposed names. Capture it so consumers see the
107
- // exact exported identifiers (e.g. "fetchTasks" not "fetchTaskList").
108
- if (/^return\s*\{/.test(trimmed)) {
109
- contractLines.push("// public API (return object):");
110
- contractLines.push(line.trimEnd());
111
- let depth = (trimmed.match(/\{/g) ?? []).length - (trimmed.match(/\}/g) ?? []).length;
112
- i++;
113
- while (i < lines.length && depth > 0) {
114
- const inner = lines[i];
115
- contractLines.push(inner.trimEnd());
116
- depth += (inner.match(/\{/g) ?? []).length;
117
- depth -= (inner.match(/\}/g) ?? []).length;
118
- i++;
119
- }
120
- continue;
121
- }
122
-
123
- // ── export default function/class — capture full block ───────────────────
124
- // Needed for React components (export default function Foo()) and Vue
125
- // composables (export default class Foo {}). Without full-block capture the
126
- // consumer only sees the opening line and can't know the return shape.
127
- if (/^export\s+default\s+(async\s+)?(function|class)\b/.test(trimmed)) {
128
- contractLines.push(line.trimEnd());
129
- if (trimmed.includes("{")) {
130
- let depth =
131
- (trimmed.match(/\{/g) ?? []).length -
132
- (trimmed.match(/\}/g) ?? []).length;
133
- i++;
134
- while (i < lines.length && depth > 0) {
135
- const inner = lines[i];
136
- contractLines.push(inner.trimEnd());
137
- depth += (inner.match(/\{/g) ?? []).length;
138
- depth -= (inner.match(/\}/g) ?? []).length;
139
- i++;
140
- }
141
- } else {
142
- i++;
143
- }
144
- continue;
145
- }
146
-
147
- // ── Single-line export declarations (functions, consts, re-exports) ───────
148
- if (/^export\s/.test(trimmed)) {
149
- contractLines.push(line.trimEnd());
150
- }
151
-
152
- // ── Throw patterns — validation constraints and named error codes ─────────
153
- if (
154
- /throw\s+(new\s+)?\w*[Ee]rror\b|throw\s+create[A-Z]\w*|@throws/.test(line) &&
155
- throwLines.length < 20
156
- ) {
157
- throwLines.push(" // " + trimmed);
158
- }
159
-
160
- i++;
161
- }
162
-
163
- if (contractLines.length === 0 && throwLines.length === 0) {
164
- return content.slice(0, 3000);
165
- }
166
-
167
- const parts: string[] = [...contractLines];
168
- if (throwLines.length > 0) {
169
- parts.push("", "// Error contracts (throws / validation):", ...throwLines);
170
- }
171
- return parts.join("\n");
172
- }
173
-
174
- /**
175
- * Build a context section from files already written in this generation run.
176
- * Injected before generating files that may import from those paths (e.g., route files
177
- * importing from API files generated in an earlier task).
178
- */
179
- function buildGeneratedFilesSection(cache: Map<string, string>): string {
180
- if (cache.size === 0) return "";
181
- const lines = [
182
- "\n=== Files Already Generated in This Run — USE EXACT EXPORTS (do not rename or invent alternatives) ===",
183
- "// CRITICAL: function/action names and file paths below are ground truth. Copy them EXACTLY.",
184
- "// Do NOT add suffixes (List, Data, All, Info) or change casing.",
185
- "// For '// exists:' entries: use the EXACT filename shown — do NOT substitute index.vue or other defaults.",
186
- ];
187
- for (const [filePath, content] of cache) {
188
- // View/page components: only show the path as a name sentinel.
189
- // The router needs to know the exact filename (e.g. TaskManagement.vue, NOT index.vue).
190
- const isViewFile = /src[\\/](views?|pages?)[\\/]/i.test(filePath);
191
- if (isViewFile) {
192
- lines.push(`\n// exists: ${filePath}`);
193
- continue;
194
- }
195
- lines.push(`\n--- ${filePath} ---`);
196
- // Store and composable files: pass full content — the entire file IS the contract
197
- const isStoreOrComposable = /src[\\/](stores?|composables?)[\\/]/i.test(filePath);
198
- lines.push(isStoreOrComposable ? content : extractBehavioralContract(content));
199
- }
200
- return lines.join("\n") + "\n";
201
- }
13
+ import {
14
+ buildSharedConfigSection,
15
+ buildInstalledPackagesSection,
16
+ buildGeneratedFilesSection,
17
+ extractBehavioralContract,
18
+ stripCodeFences,
19
+ parseJsonArray,
20
+ isRtkAvailable,
21
+ FileAction,
22
+ } from "./codegen/helpers";
23
+ import { topoSortLayerTasks, printTaskProgress, LAYER_ICONS } from "./codegen/topo-sort";
24
+ import { estimateTokens, getDefaultBudget } from "./token-budget";
25
+ import { startSpinner } from "./cli-ui";
26
+
27
+ // Re-export public symbols for backward compatibility
28
+ export { extractBehavioralContract } from "./codegen/helpers";
29
+ export { printTaskProgress } from "./codegen/topo-sort";
202
30
 
203
31
  export type CodeGenMode = "claude-code" | "api" | "plan";
204
32
 
205
- // ─── RTK Helper ────────────────────────────────────────────────────────────────
206
- // RTK (Rust Token Killer) saves tokens by filtering verbose CLI output.
207
- // When available, prefix 'claude' with 'rtk' for token savings.
208
-
209
- function isRtkAvailable(): boolean {
210
- try {
211
- execSync("rtk --version", { stdio: "ignore" });
212
- return true;
213
- } catch {
214
- return false;
215
- }
216
- }
217
-
218
- interface FileAction {
219
- file: string;
220
- action: "create" | "modify";
221
- description: string;
222
- }
223
-
224
- // ─── Helpers ───────────────────────────────────────────────────────────────────
225
-
226
- function stripCodeFences(output: string): string {
227
- // Remove ```lang ... ``` wrapping if present
228
- const fenced = output.match(/^```(?:\w+)?\n([\s\S]*?)```\s*$/m);
229
- if (fenced) return fenced[1].trim();
230
- const lines = output.split("\n");
231
- if (lines[0].startsWith("```")) lines.shift();
232
- if (lines[lines.length - 1].trim() === "```") lines.pop();
233
- return lines.join("\n").trim();
234
- }
235
-
236
- function parseJsonArray(text: string): FileAction[] {
237
- // Try a JSON code fence first
238
- const fenced = text.match(/```(?:json)?\n(\[[\s\S]*?\])\n```/);
239
- const raw = fenced ? fenced[1] : text.match(/\[[\s\S]*?\]/)?.[0] ?? "";
240
- try {
241
- const parsed = JSON.parse(raw);
242
- if (Array.isArray(parsed)) return parsed as FileAction[];
243
- } catch {
244
- // fall through
245
- }
246
- return [];
247
- }
248
-
249
33
  // ─── CodeGenerator ────────────────────────────────────────────────────────────
250
34
 
251
35
  export interface CodeGenOptions {
@@ -301,7 +85,7 @@ export class CodeGenerator {
301
85
 
302
86
  private isClaudeCLIAvailable(): boolean {
303
87
  try {
304
- execSync("claude --version", { stdio: "ignore" });
88
+ execSync("claude --version", { stdio: "ignore", timeout: 10_000 });
305
89
  return true;
306
90
  } catch {
307
91
  return false;
@@ -457,7 +241,7 @@ export class CodeGenerator {
457
241
  }
458
242
 
459
243
  const spec = await fs.readFile(specFilePath, "utf-8");
460
- const constitutionSection = context?.constitution
244
+ let constitutionSection = context?.constitution
461
245
  ? `\n=== Project Constitution (MUST follow) ===\n${context.constitution}\n`
462
246
  : "";
463
247
  const contextSummary = context
@@ -484,6 +268,27 @@ export class CodeGenerator {
484
268
  console.log(chalk.gray(` Frontend context: ${fctx.framework} / ${fctx.httpClient} | hooks:${fctx.hookFiles.length} stores:${fctx.storeFiles.length}`));
485
269
  }
486
270
 
271
+ // Token budget check — warn if context sections are large
272
+ const allContextText = spec + constitutionSection + dslSection + frontendSection + installedPackagesSection + sharedConfigSection;
273
+ const estimatedTokenCount = estimateTokens(allContextText);
274
+ const budget = getDefaultBudget(this.provider.providerName);
275
+ if (estimatedTokenCount > budget * 0.7) {
276
+ console.log(
277
+ chalk.yellow(
278
+ ` ⚠ Context size: ~${Math.round(estimatedTokenCount / 1000)}K tokens (budget: ${Math.round(budget / 1000)}K for ${this.provider.providerName})`
279
+ )
280
+ );
281
+ // Trim constitution §9 if it's the largest contributor
282
+ if (constitutionSection.length > 4000) {
283
+ const s9Start = constitutionSection.indexOf("## 9.");
284
+ if (s9Start > 0) {
285
+ constitutionSection = constitutionSection.slice(0, s9Start) +
286
+ "## 9. 积累教训 (Accumulated Lessons)\n[Trimmed for context budget — run `ai-spec init --consolidate` to prune]\n";
287
+ console.log(chalk.gray(" → §9 trimmed from constitution to save tokens."));
288
+ }
289
+ }
290
+ }
291
+
487
292
  // Use tasks if available for finer-grained generation with resume support
488
293
  const tasks = await loadTasksForSpec(specFilePath);
489
294
  if (tasks && tasks.length > 0) {
@@ -724,16 +529,19 @@ Output ONLY a valid JSON array:
724
529
 
725
530
  for (const batch of taskBatches) {
726
531
  const batchIsParallel = batch.length > 1;
727
- // Wrap each task in .catch() so a single unexpected failure (disk full,
728
- // provider timeout, mkdir error) degrades gracefully instead of rejecting
729
- // the entire Promise.all and aborting all sibling tasks in the batch.
730
- const batchResultPromises = batch.map((task) =>
731
- executeTask(task, batchIsParallel).catch((err): TaskResult => {
732
- console.log(chalk.yellow(` ⚠ ${task.id} threw unexpectedly: ${(err as Error).message}`));
733
- return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false };
734
- })
735
- );
736
- const batchResults = await Promise.all(batchResultPromises);
532
+ const batchResultPromises = batch.map((task) => executeTask(task, batchIsParallel));
533
+ const settled = await Promise.allSettled(batchResultPromises);
534
+ const batchResults: TaskResult[] = [];
535
+ for (let i = 0; i < settled.length; i++) {
536
+ const outcome = settled[i];
537
+ if (outcome.status === "fulfilled") {
538
+ batchResults.push(outcome.value);
539
+ } else {
540
+ const task = batch[i];
541
+ console.log(chalk.yellow(` ⚠ ${task.id} threw unexpectedly: ${outcome.reason?.message ?? outcome.reason}`));
542
+ batchResults.push({ task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false });
543
+ }
544
+ }
737
545
  layerResults.push(...batchResults);
738
546
  // Update cache after each batch so the next batch sees the exports.
739
547
  await updateCacheFromBatch(batchResults);
@@ -847,6 +655,7 @@ ${constitutionSection}
847
655
  === ${existingContent ? "Existing content (modify and return the complete file)" : "Create this file from scratch"} ===
848
656
  ${existingContent || "Output only the complete file content."}`;
849
657
 
658
+ const fileSpinner = startSpinner(`${prefix}Generating ${chalk.bold(item.file)}...`);
850
659
  try {
851
660
  const raw = await this.provider.generate(codePrompt, systemPrompt);
852
661
  const fileContent = stripCodeFences(raw);
@@ -854,11 +663,11 @@ ${existingContent || "Output only the complete file content."}`;
854
663
  await fs.ensureDir(path.dirname(fullPath));
855
664
  await fs.writeFile(fullPath, fileContent, "utf-8");
856
665
  getActiveLogger()?.fileWritten(item.file);
857
- console.log(`${prefix}${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)} ${chalk.green("✔")}`);
666
+ fileSpinner.succeed(`${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)}`);
858
667
  successCount++;
859
668
  writtenFiles.push(item.file);
860
669
  } catch (err) {
861
- console.log(`${prefix}${chalk.red("✘")} ${chalk.bold(item.file)} — ${chalk.red((err as Error).message)}`);
670
+ fileSpinner.fail(`${chalk.bold(item.file)} — ${(err as Error).message}`);
862
671
  }
863
672
  }
864
673
 
@@ -893,99 +702,3 @@ ${spec}`,
893
702
  console.log(chalk.cyan("\n") + plan);
894
703
  }
895
704
  }
896
-
897
- // ─── Topological Batch Sort ────────────────────────────────────────────────────
898
-
899
- /**
900
- * Partition tasks within a layer into ordered batches that respect the
901
- * `dependencies` field. Tasks in the same batch have no intra-layer
902
- * dependencies on each other and can run in parallel. Tasks in later batches
903
- * wait for earlier batches to complete.
904
- *
905
- * Only intra-layer dependencies (i.e. deps whose IDs also appear in `tasks`)
906
- * are considered — cross-layer ordering is already handled by LAYER_ORDER.
907
- *
908
- * Returns at least one batch. On circular-dependency detection the remaining
909
- * tasks are dumped into a final batch so execution always completes.
910
- */
911
- function topoSortLayerTasks(tasks: SpecTask[]): SpecTask[][] {
912
- if (tasks.length <= 1) return [tasks];
913
-
914
- const idSet = new Set(tasks.map((t) => t.id));
915
- const taskById = new Map(tasks.map((t) => [t.id, t]));
916
- const inDegree = new Map<string, number>();
917
- const dependents = new Map<string, string[]>(); // dep → tasks that depend on it
918
-
919
- for (const task of tasks) {
920
- inDegree.set(task.id, 0);
921
- dependents.set(task.id, []);
922
- }
923
-
924
- for (const task of tasks) {
925
- const intraDeps = task.dependencies.filter((dep) => idSet.has(dep));
926
- inDegree.set(task.id, intraDeps.length);
927
- for (const dep of intraDeps) {
928
- dependents.get(dep)!.push(task.id);
929
- }
930
- }
931
-
932
- const batches: SpecTask[][] = [];
933
- const remaining = new Set(tasks.map((t) => t.id));
934
-
935
- while (remaining.size > 0) {
936
- const batch = [...remaining]
937
- .filter((id) => inDegree.get(id) === 0)
938
- .map((id) => taskById.get(id)!);
939
-
940
- if (batch.length === 0) {
941
- // Circular dependency — run all remaining tasks in parallel to avoid deadlock
942
- batches.push([...remaining].map((id) => taskById.get(id)!));
943
- break;
944
- }
945
-
946
- batches.push(batch);
947
- for (const task of batch) {
948
- remaining.delete(task.id);
949
- for (const dependent of dependents.get(task.id)!) {
950
- inDegree.set(dependent, inDegree.get(dependent)! - 1);
951
- }
952
- }
953
- }
954
-
955
- return batches;
956
- }
957
-
958
- // ─── Progress Bar Helper ───────────────────────────────────────────────────────
959
-
960
- const LAYER_ICONS: Record<string, string> = {
961
- data: "💾",
962
- infra: "⚙️ ",
963
- service: "🔧",
964
- api: "🌐",
965
- view: "🖥️ ",
966
- route: "🗺️ ",
967
- test: "🧪",
968
- };
969
-
970
- export function printTaskProgress(
971
- completed: number,
972
- total: number,
973
- task: SpecTask,
974
- mode: "run" | "skip"
975
- ): void {
976
- const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
977
- const barWidth = 20;
978
- const filled = Math.round((pct / 100) * barWidth);
979
- const bar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(barWidth - filled));
980
- const icon = LAYER_ICONS[task.layer] ?? " ";
981
-
982
- if (mode === "skip") {
983
- console.log(
984
- chalk.gray(`\n [${bar}] ${pct}% ✓ ${task.id} ${icon} ${task.title} — already done`)
985
- );
986
- } else {
987
- console.log(
988
- chalk.bold(`\n [${bar}] ${pct}% → ${task.id} ${icon} ${task.title}`)
989
- );
990
- }
991
- }