ai-spec-dev 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude/commands/add-lesson.md +34 -0
  2. package/.claude/commands/check-layers.md +65 -0
  3. package/.claude/commands/installed-deps.md +35 -0
  4. package/.claude/commands/recall-lessons.md +40 -0
  5. package/.claude/commands/scan-singletons.md +45 -0
  6. package/.claude/commands/verify-imports.md +48 -0
  7. package/.claude/settings.local.json +11 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +305 -0
  10. package/cli/commands/create.ts +1233 -0
  11. package/cli/commands/dashboard.ts +62 -0
  12. package/cli/commands/init.ts +45 -8
  13. package/cli/commands/mock.ts +175 -0
  14. package/cli/commands/scan.ts +99 -0
  15. package/cli/commands/types.ts +69 -0
  16. package/cli/commands/vcr.ts +70 -0
  17. package/cli/index.ts +34 -2517
  18. package/core/combined-generator.ts +13 -3
  19. package/core/dashboard-generator.ts +340 -0
  20. package/core/design-dialogue.ts +124 -0
  21. package/core/dsl-feedback.ts +34 -4
  22. package/core/error-feedback.ts +46 -2
  23. package/core/project-index.ts +301 -0
  24. package/core/reviewer.ts +84 -6
  25. package/core/run-logger.ts +109 -3
  26. package/core/run-trend.ts +24 -4
  27. package/core/self-evaluator.ts +39 -11
  28. package/core/spec-generator.ts +14 -8
  29. package/core/task-generator.ts +17 -0
  30. package/core/types-generator.ts +219 -0
  31. package/core/vcr.ts +210 -0
  32. package/dist/cli/index.js +7297 -5640
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/index.mjs +8728 -7071
  35. package/dist/cli/index.mjs.map +1 -1
  36. package/dist/index.d.mts +19 -5
  37. package/dist/index.d.ts +19 -5
  38. package/dist/index.js +420 -224
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +418 -224
  41. package/dist/index.mjs.map +1 -1
  42. package/docs-assets/purpose/architecture-overview.svg +64 -0
  43. package/docs-assets/purpose/create-pipeline.svg +113 -0
  44. package/docs-assets/purpose/task-layering.svg +74 -0
  45. package/package.json +1 -1
  46. package/prompts/codegen.prompt.ts +97 -9
  47. package/prompts/design.prompt.ts +59 -0
  48. package/prompts/spec.prompt.ts +8 -1
  49. package/prompts/tasks.prompt.ts +27 -2
  50. package/purpose.md +600 -174
@@ -20,18 +20,28 @@ const tasksInstruction = `
20
20
  After outputting the complete spec above, append EXACTLY this line on its own (no extra text before or after it):
21
21
  ${TASKS_SEPARATOR}
22
22
  Then output a valid JSON array of implementation tasks. Each element must have these exact fields:
23
- {"id":"TASK-001","title":"...","description":"1-2 sentences, specific","layer":"data|infra|service|api|test","filesToTouch":["src/..."],"acceptanceCriteria":["verifiable condition"],"dependencies":[],"priority":"high|medium|low"}
23
+ {"id":"TASK-001","title":"...","description":"1-2 sentences, specific","layer":"data|infra|service|api|test","filesToTouch":["src/..."],"acceptanceCriteria":["behavioral condition"],"verificationSteps":["concrete runnable check → expected result"],"dependencies":[],"priority":"high|medium|low"}
24
+ verificationSteps rules: each step is a specific command or action with observable expected output (e.g. "POST /api/orders → 201 {id, status:'pending'}"). At least 2 per task, max 5. Never vague.
24
25
  Layer order: data → infra → service → api → test. 4-10 tasks total. filesToTouch must use real paths from the project context.`;
25
26
 
26
27
  export async function generateSpecWithTasks(
27
28
  provider: AIProvider,
28
29
  idea: string,
29
- context?: ProjectContext
30
+ context?: ProjectContext,
31
+ architectureDecision?: string
30
32
  ): Promise<{ spec: string; tasks: SpecTask[] }> {
31
33
  // Use buildTaskPrompt to get the full verified-inventory context,
32
34
  // then prepend the idea so the spec generator also sees it.
33
35
  const contextBlock = buildTaskPrompt("", context).trim();
34
- const fullPrompt = [idea, contextBlock].filter(Boolean).join("\n\n");
36
+
37
+ const parts: string[] = [idea];
38
+ if (architectureDecision) {
39
+ parts.push(
40
+ `\n=== Architecture Decision (MUST follow this approach in the spec) ===\n${architectureDecision}`
41
+ );
42
+ }
43
+ if (contextBlock) parts.push(contextBlock);
44
+ const fullPrompt = parts.join("\n\n");
35
45
 
36
46
  const combinedSystemPrompt = specPrompt + tasksInstruction;
37
47
  const raw = await provider.generate(fullPrompt, combinedSystemPrompt);
@@ -0,0 +1,340 @@
1
+ import { RunLog } from "./run-logger";
2
+ import { buildTrendReport } from "./run-trend";
3
+ import { computePromptHash } from "./prompt-hasher";
4
+
5
+ // ─── SVG chart helpers ────────────────────────────────────────────────────────
6
+
7
+ function scoreColor(score: number): string {
8
+ if (score >= 8) return "#22c55e"; // green
9
+ if (score >= 6) return "#f59e0b"; // amber
10
+ return "#ef4444"; // red
11
+ }
12
+
13
+ function renderSparkline(scores: number[], width = 400, height = 60): string {
14
+ if (scores.length < 2) return `<svg width="${width}" height="${height}"></svg>`;
15
+ const pad = 6;
16
+ const w = width - pad * 2;
17
+ const h = height - pad * 2;
18
+ const max = Math.max(...scores, 10);
19
+ const min = Math.min(...scores, 0);
20
+ const range = max - min || 1;
21
+
22
+ const points = scores.map((s, i) => {
23
+ const x = pad + (i / (scores.length - 1)) * w;
24
+ const y = pad + h - ((s - min) / range) * h;
25
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
26
+ });
27
+
28
+ const lastX = parseFloat(points[points.length - 1].split(",")[0]);
29
+ const lastY = parseFloat(points[points.length - 1].split(",")[1]);
30
+ const lastScore = scores[scores.length - 1];
31
+
32
+ return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
33
+ <polyline points="${points.join(" ")}" fill="none" stroke="${scoreColor(lastScore)}" stroke-width="2" stroke-linejoin="round"/>
34
+ <circle cx="${lastX}" cy="${lastY}" r="3" fill="${scoreColor(lastScore)}"/>
35
+ </svg>`;
36
+ }
37
+
38
+ function renderBarChart(
39
+ entries: { label: string; value: number; color?: string }[],
40
+ maxWidth = 280
41
+ ): string {
42
+ const maxVal = Math.max(...entries.map((e) => e.value), 1);
43
+ const rows = entries
44
+ .map((e) => {
45
+ const pct = (e.value / maxVal) * maxWidth;
46
+ const color = e.color ?? "#6366f1";
47
+ return `
48
+ <div style="display:flex;align-items:center;gap:8px;margin:4px 0">
49
+ <div style="width:110px;font-size:11px;color:#94a3b8;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${e.label}">${e.label}</div>
50
+ <div style="height:14px;width:${pct.toFixed(0)}px;background:${color};border-radius:2px;min-width:2px"></div>
51
+ <div style="font-size:11px;color:#e2e8f0">${e.value.toFixed(1)}</div>
52
+ </div>`;
53
+ })
54
+ .join("");
55
+ return `<div style="margin:0">${rows}</div>`;
56
+ }
57
+
58
+ // ─── Data preparation ─────────────────────────────────────────────────────────
59
+
60
+ interface DashboardData {
61
+ totalRuns: number;
62
+ scoredRuns: number;
63
+ avgScore: number | null;
64
+ compilePassRate: number | null;
65
+ currentPromptHash: string;
66
+ recentScores: { runId: string; date: string; score: number; hash: string }[];
67
+ promptGroups: { hash: string; runs: number; avg: number; best: number; worst: number; isCurrent: boolean }[];
68
+ stageDurations: { stage: string; avgMs: number }[];
69
+ topErrors: { message: string; count: number }[];
70
+ lastRunAt: string | null;
71
+ }
72
+
73
+ function prepareDashboardData(logs: RunLog[]): DashboardData {
74
+ const currentHash = computePromptHash();
75
+ const report = buildTrendReport(logs, { last: 50 });
76
+
77
+ // Recent scored runs (last 30)
78
+ const recentScores = logs
79
+ .filter((l) => l.harnessScore !== undefined)
80
+ .slice(0, 30)
81
+ .reverse()
82
+ .map((l) => ({
83
+ runId: l.runId,
84
+ date: l.startedAt.slice(0, 10),
85
+ score: l.harnessScore!,
86
+ hash: l.promptHash ?? "(no hash)",
87
+ }));
88
+
89
+ // Stage duration aggregation
90
+ const stageAccum: Record<string, { total: number; count: number }> = {};
91
+ for (const log of logs.slice(0, 20)) {
92
+ const stages: Record<string, number> = {};
93
+ for (const entry of log.entries ?? []) {
94
+ if (entry.event.endsWith(":done") || entry.event.endsWith(":failed")) {
95
+ const stageName = entry.event.replace(/:done$|:failed$/, "");
96
+ const ms = entry.data?.durationMs;
97
+ if (typeof ms === "number") stages[stageName] = ms;
98
+ }
99
+ }
100
+ for (const [stage, ms] of Object.entries(stages)) {
101
+ if (!stageAccum[stage]) stageAccum[stage] = { total: 0, count: 0 };
102
+ stageAccum[stage].total += ms;
103
+ stageAccum[stage].count++;
104
+ }
105
+ }
106
+ const stageDurations = Object.entries(stageAccum)
107
+ .map(([stage, { total, count }]) => ({ stage, avgMs: total / count }))
108
+ .sort((a, b) => b.avgMs - a.avgMs)
109
+ .slice(0, 8);
110
+
111
+ // Top errors
112
+ const errorCounts: Record<string, number> = {};
113
+ for (const log of logs) {
114
+ for (const err of log.errors ?? []) {
115
+ const key = err.slice(0, 80);
116
+ errorCounts[key] = (errorCounts[key] ?? 0) + 1;
117
+ }
118
+ }
119
+ const topErrors = Object.entries(errorCounts)
120
+ .sort((a, b) => b[1] - a[1])
121
+ .slice(0, 5)
122
+ .map(([message, count]) => ({ message, count }));
123
+
124
+ // Compile pass rate (from error_feedback stage)
125
+ let compilePassed = 0, compileTotal = 0;
126
+ for (const log of logs) {
127
+ const feedback = log.entries?.find((e) => e.event === "error_feedback:done" || e.event === "error_feedback:failed");
128
+ if (feedback) {
129
+ compileTotal++;
130
+ if (feedback.event.endsWith(":done")) compilePassed++;
131
+ }
132
+ }
133
+
134
+ const scoredLogs = logs.filter((l) => l.harnessScore !== undefined);
135
+ const avgScore = scoredLogs.length > 0
136
+ ? scoredLogs.reduce((s, l) => s + l.harnessScore!, 0) / scoredLogs.length
137
+ : null;
138
+
139
+ return {
140
+ totalRuns: logs.length,
141
+ scoredRuns: scoredLogs.length,
142
+ avgScore,
143
+ compilePassRate: compileTotal > 0 ? compilePassed / compileTotal : null,
144
+ currentPromptHash: currentHash,
145
+ recentScores,
146
+ promptGroups: report.promptGroups.map((g) => ({
147
+ hash: g.promptHash,
148
+ runs: g.runs,
149
+ avg: g.avg,
150
+ best: g.best,
151
+ worst: g.worst,
152
+ isCurrent: g.isCurrent,
153
+ })),
154
+ stageDurations,
155
+ topErrors,
156
+ lastRunAt: logs[0]?.startedAt ?? null,
157
+ };
158
+ }
159
+
160
+ // ─── HTML renderer ────────────────────────────────────────────────────────────
161
+
162
+ function renderHtml(data: DashboardData, generatedAt: string, totalLogsAnalyzed: number): string {
163
+ const sparkline = renderSparkline(data.recentScores.map((r) => r.score));
164
+
165
+ const stageBar = renderBarChart(
166
+ data.stageDurations.map((s) => ({
167
+ label: s.stage.replace(/_/g, " "),
168
+ value: Math.round(s.avgMs / 100) / 10, // seconds
169
+ color: "#6366f1",
170
+ })),
171
+ 240
172
+ );
173
+
174
+ const promptRows = data.promptGroups
175
+ .map((g) => {
176
+ const isCurrent = g.isCurrent;
177
+ const badge = isCurrent ? `<span style="background:#4f46e5;color:#fff;font-size:9px;padding:1px 6px;border-radius:9px;margin-left:6px">current</span>` : "";
178
+ const avgColor = scoreColor(g.avg);
179
+ return `<tr style="${isCurrent ? "background:#1e1b4b" : ""}">
180
+ <td style="font-family:monospace;font-size:12px;color:#a5b4fc">${g.hash}${badge}</td>
181
+ <td style="text-align:center;color:#94a3b8">${g.runs}</td>
182
+ <td style="text-align:center;color:${avgColor};font-weight:600">${g.avg.toFixed(1)}</td>
183
+ <td style="text-align:center;color:#22c55e">${g.best.toFixed(1)}</td>
184
+ <td style="text-align:center;color:#ef4444">${g.worst.toFixed(1)}</td>
185
+ </tr>`;
186
+ })
187
+ .join("");
188
+
189
+ const recentRows = data.recentScores
190
+ .slice()
191
+ .reverse()
192
+ .slice(-10)
193
+ .map((r) => {
194
+ const color = scoreColor(r.score);
195
+ const bar = "█".repeat(Math.round(r.score)) + "░".repeat(10 - Math.round(r.score));
196
+ return `<tr>
197
+ <td style="font-family:monospace;font-size:11px;color:#64748b">${r.date}</td>
198
+ <td style="font-family:monospace;font-size:11px;color:#94a3b8">${r.runId.slice(-12)}</td>
199
+ <td style="color:${color};font-size:12px;font-family:monospace">${bar}</td>
200
+ <td style="text-align:right;color:${color};font-weight:600">${r.score.toFixed(1)}</td>
201
+ </tr>`;
202
+ })
203
+ .join("");
204
+
205
+ const avgDisplay = data.avgScore !== null
206
+ ? `<span style="color:${scoreColor(data.avgScore)}">${data.avgScore.toFixed(1)}</span>`
207
+ : `<span style="color:#475569">—</span>`;
208
+
209
+ const compileDisplay = data.compilePassRate !== null
210
+ ? `<span style="color:${data.compilePassRate >= 0.8 ? "#22c55e" : "#f59e0b"}">${Math.round(data.compilePassRate * 100)}%</span>`
211
+ : `<span style="color:#475569">—</span>`;
212
+
213
+ const errorRows = data.topErrors.length > 0
214
+ ? data.topErrors.map((e) =>
215
+ `<div style="display:flex;gap:8px;align-items:flex-start;margin:4px 0">
216
+ <span style="color:#ef4444;font-weight:600;flex-shrink:0">${e.count}×</span>
217
+ <span style="color:#94a3b8;font-size:11px;font-family:monospace;word-break:break-all">${e.message.replace(/</g, "&lt;")}</span>
218
+ </div>`
219
+ ).join("")
220
+ : `<div style="color:#475569;font-size:12px">No errors recorded</div>`;
221
+
222
+ return `<!DOCTYPE html>
223
+ <html lang="en">
224
+ <head>
225
+ <meta charset="UTF-8">
226
+ <meta name="viewport" content="width=device-width,initial-scale=1">
227
+ <title>ai-spec Harness Dashboard</title>
228
+ <style>
229
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
230
+ body { background: #0f172a; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.5; }
231
+ .header { background: #1e293b; border-bottom: 1px solid #334155; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
232
+ .header h1 { font-size: 16px; font-weight: 600; color: #f1f5f9; }
233
+ .header .meta { font-size: 11px; color: #475569; }
234
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; padding: 20px 24px; }
235
+ .card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 16px; }
236
+ .card h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin-bottom: 12px; }
237
+ .stat-row { display: flex; gap: 24px; flex-wrap: wrap; }
238
+ .stat { text-align: center; }
239
+ .stat .value { font-size: 28px; font-weight: 700; line-height: 1; }
240
+ .stat .label { font-size: 11px; color: #64748b; margin-top: 4px; }
241
+ table { width: 100%; border-collapse: collapse; }
242
+ th { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: #475569; padding: 4px 8px; text-align: left; border-bottom: 1px solid #334155; }
243
+ td { padding: 6px 8px; border-bottom: 1px solid #1e293b; }
244
+ tr:last-child td { border-bottom: none; }
245
+ .sparkline-wrap { margin: 8px 0; }
246
+ .full { grid-column: 1 / -1; }
247
+ </style>
248
+ </head>
249
+ <body>
250
+ <div class="header">
251
+ <h1>ai-spec · Harness Dashboard</h1>
252
+ <div class="meta">Generated ${generatedAt} · Current prompt: <code style="color:#a5b4fc">${data.currentPromptHash}</code></div>
253
+ </div>
254
+
255
+ <div class="grid">
256
+
257
+ <!-- Overview stats -->
258
+ <div class="card">
259
+ <h2>Overview</h2>
260
+ <div class="stat-row">
261
+ <div class="stat">
262
+ <div class="value" style="color:#e2e8f0">${data.totalRuns}</div>
263
+ <div class="label">Total Runs</div>
264
+ </div>
265
+ <div class="stat">
266
+ <div class="value">${avgDisplay}</div>
267
+ <div class="label">Avg Score</div>
268
+ </div>
269
+ <div class="stat">
270
+ <div class="value">${compileDisplay}</div>
271
+ <div class="label">Compile Pass</div>
272
+ </div>
273
+ <div class="stat">
274
+ <div class="value" style="color:#e2e8f0">${data.scoredRuns}</div>
275
+ <div class="label">Scored Runs</div>
276
+ </div>
277
+ </div>
278
+ </div>
279
+
280
+ <!-- Score trend sparkline -->
281
+ <div class="card">
282
+ <h2>Score Trend (last ${data.recentScores.length} runs)</h2>
283
+ <div class="sparkline-wrap">${sparkline}</div>
284
+ ${data.recentScores.length === 0 ? '<div style="color:#475569;font-size:12px">No scored runs yet</div>' : ""}
285
+ </div>
286
+
287
+ <!-- Prompt version comparison -->
288
+ <div class="card full">
289
+ <h2>Prompt Version Performance</h2>
290
+ ${data.promptGroups.length === 0
291
+ ? '<div style="color:#475569;font-size:12px">No runs with prompt hash yet</div>'
292
+ : `<table>
293
+ <thead><tr>
294
+ <th>Prompt Hash</th>
295
+ <th style="text-align:center">Runs</th>
296
+ <th style="text-align:center">Avg</th>
297
+ <th style="text-align:center">Best</th>
298
+ <th style="text-align:center">Worst</th>
299
+ </tr></thead>
300
+ <tbody>${promptRows}</tbody>
301
+ </table>`}
302
+ </div>
303
+
304
+ <!-- Recent run history -->
305
+ <div class="card">
306
+ <h2>Recent Runs</h2>
307
+ ${data.recentScores.length === 0
308
+ ? '<div style="color:#475569;font-size:12px">No scored runs yet</div>'
309
+ : `<table>
310
+ <thead><tr><th>Date</th><th>Run ID</th><th>Score</th><th style="text-align:right">/10</th></tr></thead>
311
+ <tbody>${recentRows}</tbody>
312
+ </table>`}
313
+ </div>
314
+
315
+ <!-- Stage durations -->
316
+ <div class="card">
317
+ <h2>Avg Stage Duration (seconds)</h2>
318
+ ${data.stageDurations.length === 0
319
+ ? '<div style="color:#475569;font-size:12px">No stage data yet</div>'
320
+ : stageBar}
321
+ </div>
322
+
323
+ <!-- Top errors -->
324
+ <div class="card">
325
+ <h2>Top Errors (last ${Math.min(totalLogsAnalyzed, 20)} runs)</h2>
326
+ ${errorRows}
327
+ </div>
328
+
329
+ </div>
330
+ </body>
331
+ </html>`;
332
+ }
333
+
334
+ // ─── Public API ───────────────────────────────────────────────────────────────
335
+
336
+ export function generateDashboard(logs: RunLog[]): string {
337
+ const data = prepareDashboardData(logs);
338
+ const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";
339
+ return renderHtml(data, generatedAt, logs.length);
340
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * design-dialogue.ts — Pre-spec architectural option proposal.
3
+ *
4
+ * Inspired by Superpowers' brainstorming phase: before writing a full spec,
5
+ * present 2-3 distinct architectural approaches with trade-offs and let the
6
+ * developer choose. The chosen approach is then injected into the spec prompt
7
+ * as a binding architectural decision, preventing mid-spec drift.
8
+ *
9
+ * Skipped in --fast and --auto modes.
10
+ */
11
+
12
+ import { select } from "@inquirer/prompts";
13
+ import chalk from "chalk";
14
+ import { AIProvider } from "./spec-generator";
15
+ import { buildDesignOptionsPrompt, designOptionsSystemPrompt } from "../prompts/design.prompt";
16
+
17
+ export interface DesignChoice {
18
+ /** The full AI-generated options text, displayed to the user */
19
+ optionsText: string;
20
+ /**
21
+ * The selected approach label + description, injected into the spec prompt.
22
+ * e.g. "Option B — Event-driven approach: ..."
23
+ * null = user skipped the dialogue
24
+ */
25
+ selectedApproach: string | null;
26
+ }
27
+
28
+ export class DesignDialogue {
29
+ constructor(private provider: AIProvider) {}
30
+
31
+ async run(
32
+ idea: string,
33
+ contextHints: { techStack: string[]; repoType: string; constitution?: string }
34
+ ): Promise<DesignChoice> {
35
+ console.log(chalk.blue("\n[1.5/6] Design options..."));
36
+ console.log(
37
+ chalk.gray(` Proposing architectural approaches with ${this.provider.providerName}/${this.provider.modelName}...`)
38
+ );
39
+
40
+ const prompt = buildDesignOptionsPrompt(idea, contextHints);
41
+ let optionsText: string;
42
+
43
+ try {
44
+ optionsText = await this.provider.generate(prompt, designOptionsSystemPrompt);
45
+ } catch (err) {
46
+ console.log(chalk.yellow(` ⚠ Design options failed (${(err as Error).message}), skipping.`));
47
+ return { optionsText: "", selectedApproach: null };
48
+ }
49
+
50
+ // Display the options
51
+ console.log(chalk.cyan("\n" + "─".repeat(52)));
52
+ console.log(optionsText);
53
+ console.log(chalk.cyan("─".repeat(52) + "\n"));
54
+
55
+ // Parse option labels from the text (Option A / B / C)
56
+ const optionMatches = [...optionsText.matchAll(/###\s+(Option\s+[A-C][^:\n]*)/gi)];
57
+ const parsedOptions = optionMatches.map((m) => m[1].trim());
58
+
59
+ // Build choices for the select prompt
60
+ const choices: Array<{ name: string; value: string }> = parsedOptions.map((label) => ({
61
+ name: label,
62
+ value: label,
63
+ }));
64
+
65
+ choices.push(
66
+ { name: "🔀 Blend — let AI combine the best of all options", value: "__blend__" },
67
+ { name: "⏭️ Skip — proceed to spec without an architecture decision", value: "__skip__" }
68
+ );
69
+
70
+ const selected = await select({
71
+ message: "Which approach should the spec follow?",
72
+ choices,
73
+ });
74
+
75
+ if (selected === "__skip__") {
76
+ console.log(chalk.gray(" Architecture decision skipped — spec will be generated freely."));
77
+ return { optionsText, selectedApproach: null };
78
+ }
79
+
80
+ if (selected === "__blend__") {
81
+ console.log(chalk.blue(" Blending approaches..."));
82
+ try {
83
+ const blendPrompt = `The developer wants to blend the best aspects of all options below.
84
+ Write a single-paragraph architectural decision that combines their strengths.
85
+ Output ONLY the blended approach description (2-4 sentences, no headers).
86
+
87
+ ${optionsText}`;
88
+ const blended = await this.provider.generate(
89
+ blendPrompt,
90
+ "You are a Senior Architect. Output only the blended architectural approach, 2-4 sentences."
91
+ );
92
+ const blendedApproach = `Blended approach: ${blended.trim()}`;
93
+ console.log(chalk.cyan(`\n Selected: ${blendedApproach.slice(0, 80)}...`));
94
+ return { optionsText, selectedApproach: blendedApproach };
95
+ } catch {
96
+ console.log(chalk.yellow(" Blend failed, proceeding without architecture decision."));
97
+ return { optionsText, selectedApproach: null };
98
+ }
99
+ }
100
+
101
+ // Find the full description of the selected option
102
+ const selectedIdx = parsedOptions.indexOf(selected);
103
+ let selectedApproach = selected;
104
+
105
+ if (selectedIdx !== -1 && selectedIdx < parsedOptions.length - 1) {
106
+ // Extract text between this option header and the next
107
+ const startMarker = `### ${parsedOptions[selectedIdx]}`;
108
+ const endMarker = selectedIdx + 1 < parsedOptions.length
109
+ ? `### ${parsedOptions[selectedIdx + 1]}`
110
+ : "---";
111
+ const start = optionsText.indexOf(startMarker);
112
+ const end = optionsText.indexOf(endMarker, start + 1);
113
+ if (start !== -1) {
114
+ const excerpt = end !== -1
115
+ ? optionsText.slice(start, end).trim()
116
+ : optionsText.slice(start).trim();
117
+ selectedApproach = excerpt.slice(0, 400); // cap to avoid bloating spec prompt
118
+ }
119
+ }
120
+
121
+ console.log(chalk.green(` ✔ Architecture decision locked: ${selected}`));
122
+ return { optionsText, selectedApproach };
123
+ }
124
+ }
@@ -134,6 +134,11 @@ export interface StructuralFinding {
134
134
  * that indicate design-level issues in the Spec/DSL — as opposed to
135
135
  * implementation-level issues that belong in §9 knowledge.
136
136
  *
137
+ * Primary path: parse the structured JSON block emitted by the updated
138
+ * reviewArchitectureSystemPrompt (## 🔍 结构性发现 JSON section).
139
+ * Fallback: legacy regex approach for review texts generated before the
140
+ * structured output format was introduced.
141
+ *
137
142
  * Returns an empty array if no structural issues are found or if the
138
143
  * review score for Pass 1 is high (≥ 8), indicating overall approval.
139
144
  */
@@ -147,9 +152,34 @@ export function extractStructuralFindings(reviewText: string): StructuralFinding
147
152
  const pass1Score = extractPassScore(pass1Text);
148
153
  if (pass1Score !== null && pass1Score >= 8) return [];
149
154
 
155
+ // ── Primary path: parse structured JSON block ─────────────────────────────
156
+ // Look for the JSON block within the "🔍 结构性发现 JSON" section of Pass 1.
157
+ // The block is delimited by ```json ... ``` and always contains a
158
+ // { structuralFindings: [...] } object.
159
+ const jsonBlockMatch = pass1Text.match(/```json\s*(\{[\s\S]*?\})\s*```/);
160
+ if (jsonBlockMatch) {
161
+ try {
162
+ const parsed = JSON.parse(jsonBlockMatch[1]);
163
+ if (Array.isArray(parsed.structuralFindings)) {
164
+ return parsed.structuralFindings.filter(
165
+ (f: unknown): f is StructuralFinding =>
166
+ typeof f === "object" &&
167
+ f !== null &&
168
+ typeof (f as StructuralFinding).category === "string" &&
169
+ typeof (f as StructuralFinding).description === "string"
170
+ );
171
+ }
172
+ } catch {
173
+ // JSON parse failed — fall through to regex fallback
174
+ }
175
+ }
176
+
177
+ // ── Fallback: legacy regex approach ──────────────────────────────────────
178
+ // Used when review text was generated before the structured JSON format
179
+ // was added to reviewArchitectureSystemPrompt.
150
180
  const findings: StructuralFinding[] = [];
151
181
 
152
- // ── Auth / 认证 design issues ──────────────────────────────────────────
182
+ // Auth / 认证 design issues
153
183
  if (
154
184
  /缺少认证|missing auth|auth.*false|未加认证|鉴权.*缺|endpoint.*public.*should/i.test(pass1Text)
155
185
  ) {
@@ -160,7 +190,7 @@ export function extractStructuralFindings(reviewText: string): StructuralFinding
160
190
  });
161
191
  }
162
192
 
163
- // ── API contract / 接口设计 issues ────────────────────────────────────
193
+ // API contract / 接口设计 issues
164
194
  if (
165
195
  /接口设计.*问题|接口.*不合理|API design|response.*missing|request.*missing|接口.*缺少/i.test(pass1Text)
166
196
  ) {
@@ -171,7 +201,7 @@ export function extractStructuralFindings(reviewText: string): StructuralFinding
171
201
  });
172
202
  }
173
203
 
174
- // ── Model / 数据模型 design issues ────────────────────────────────────
204
+ // Model / 数据模型 design issues
175
205
  if (
176
206
  /模型.*缺少字段|model.*missing field|数据结构.*问题|schema.*incomplete|字段.*missing/i.test(pass1Text)
177
207
  ) {
@@ -182,7 +212,7 @@ export function extractStructuralFindings(reviewText: string): StructuralFinding
182
212
  });
183
213
  }
184
214
 
185
- // ── Layer separation / 层级分离 violations ────────────────────────────
215
+ // Layer separation / 层级分离 violations
186
216
  if (
187
217
  /层级.*违反|layer.*violation|business logic.*controller|controller.*service.*混|分层.*问题/i.test(pass1Text)
188
218
  ) {
@@ -22,6 +22,22 @@ interface FixResult {
22
22
  explanation: string;
23
23
  }
24
24
 
25
+ // ─── Budgets ────────────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Maximum characters captured from a single command's output before parsing.
29
+ * ~10K tokens — enough for any realistic error listing; prevents a pathological
30
+ * build output (e.g. 10MB of warnings) from ballooning the AI context.
31
+ */
32
+ const MAX_COMMAND_OUTPUT_CHARS = 50_000;
33
+
34
+ /**
35
+ * Maximum characters of an existing file sent to the AI for auto-fix.
36
+ * ~12K tokens — covers large files; content beyond this is truncated with a
37
+ * notice so the AI knows it may be seeing an incomplete file.
38
+ */
39
+ const MAX_FIX_FILE_CHARS = 60_000;
40
+
25
41
  // ─── Error Detection ────────────────────────────────────────────────────────────
26
42
 
27
43
  function runCommand(cmd: string, cwd: string): { success: boolean; output: string } {
@@ -30,7 +46,13 @@ function runCommand(cmd: string, cwd: string): { success: boolean; output: strin
30
46
  return { success: true, output };
31
47
  } catch (err) {
32
48
  const e = err as { stdout?: string; stderr?: string; message?: string };
33
- return { success: false, output: e.stdout || e.stderr || e.message || "" };
49
+ const raw = e.stdout || e.stderr || e.message || "";
50
+ // Apply output budget: cap before parsing to prevent huge outputs from
51
+ // filling up the AI context on subsequent fix cycles.
52
+ const output = raw.length > MAX_COMMAND_OUTPUT_CHARS
53
+ ? raw.slice(0, MAX_COMMAND_OUTPUT_CHARS) + `\n... [output truncated at ${MAX_COMMAND_OUTPUT_CHARS} chars]`
54
+ : raw;
55
+ return { success: false, output };
34
56
  }
35
57
  }
36
58
 
@@ -328,6 +350,13 @@ async function attemptFix(
328
350
  const dslSection = dsl ? `\n${buildDslContextSection(dsl)}\n` : "";
329
351
  const errorSummary = fileErrors.map((e) => `[${e.source}] ${e.message}`).join("\n");
330
352
 
353
+ // Apply file content budget — very large files are truncated with a notice.
354
+ // The AI still has enough context to fix the errors (which reference specific lines).
355
+ const fileContent = existingContent.length > MAX_FIX_FILE_CHARS
356
+ ? existingContent.slice(0, MAX_FIX_FILE_CHARS) +
357
+ `\n\n// ... [file truncated at ${MAX_FIX_FILE_CHARS} chars — fix only the error lines above]`
358
+ : existingContent;
359
+
331
360
  const prompt = `Fix the following errors in the file.
332
361
 
333
362
  File: ${file}
@@ -336,7 +365,7 @@ ${dslSection}
336
365
  ${errorSummary}
337
366
 
338
367
  === Current File Content ===
339
- ${existingContent}
368
+ ${fileContent}
340
369
 
341
370
  Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
342
371
 
@@ -394,6 +423,8 @@ export async function runErrorFeedback(
394
423
 
395
424
  if (buildCmd) console.log(chalk.gray(` Type-check: ${buildCmd}`));
396
425
 
426
+ let prevErrorCount = Infinity; // circuit-breaker: tracks error count from previous cycle
427
+
397
428
  for (let cycle = 1; cycle <= maxCycles; cycle++) {
398
429
  const allErrors: ErrorEntry[] = [];
399
430
 
@@ -462,6 +493,19 @@ export async function runErrorFeedback(
462
493
  return true;
463
494
  }
464
495
 
496
+ // Circuit breaker: if the fix cycle made no progress (error count did not
497
+ // decrease), stop immediately rather than spending another AI cycle.
498
+ if (allErrors.length >= prevErrorCount) {
499
+ console.log(
500
+ chalk.yellow(
501
+ `\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) before and after). Stopping early.`
502
+ )
503
+ );
504
+ console.log(chalk.gray(" Manual intervention needed."));
505
+ return false;
506
+ }
507
+ prevErrorCount = allErrors.length;
508
+
465
509
  if (cycle < maxCycles) {
466
510
  console.log(chalk.cyan(`\n Attempting auto-fix (${allErrors.length} error(s))...`));
467
511
  await attemptFix(provider, allErrors, workingDir, dsl);