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.
- package/.ai-spec-workspace.json +17 -0
- package/.ai-spec.json +7 -0
- package/cli/commands/create.ts +9 -1176
- package/cli/commands/dashboard.ts +1 -1
- package/cli/pipeline/helpers.ts +34 -0
- package/cli/pipeline/multi-repo.ts +483 -0
- package/cli/pipeline/single-repo.ts +764 -0
- package/cli/utils.ts +2 -0
- package/core/cli-ui.ts +136 -0
- package/core/code-generator.ts +56 -343
- package/core/codegen/helpers.ts +219 -0
- package/core/codegen/topo-sort.ts +98 -0
- package/core/constitution-consolidator.ts +2 -2
- package/core/dsl-coverage-checker.ts +298 -0
- package/core/dsl-extractor.ts +19 -46
- package/core/dsl-feedback.ts +1 -1
- package/core/dsl-validator.ts +74 -0
- package/core/error-feedback.ts +99 -13
- package/core/frontend-context-loader.ts +27 -5
- package/core/knowledge-memory.ts +52 -0
- package/core/mock/fixtures.ts +89 -0
- package/core/mock/proxy.ts +380 -0
- package/core/mock-server-generator.ts +12 -460
- package/core/provider-utils.ts +8 -7
- package/core/requirement-decomposer.ts +4 -28
- package/core/reviewer.ts +1 -1
- package/core/safe-json.ts +76 -0
- package/core/spec-updater.ts +5 -21
- package/core/token-budget.ts +124 -0
- package/core/vcr.ts +20 -1
- package/demo-backend/.ai-spec-constitution.md +65 -0
- package/demo-backend/package.json +21 -0
- package/demo-backend/prisma/schema.prisma +22 -0
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
- package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
- package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
- package/demo-backend/src/index.ts +17 -0
- package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
- package/demo-backend/src/routes/bookmark.routes.ts +11 -0
- package/demo-backend/src/routes/index.ts +8 -0
- package/demo-backend/src/services/bookmark.service.test.ts +433 -0
- package/demo-backend/src/services/bookmark.service.ts +261 -0
- package/demo-backend/tsconfig.json +12 -0
- package/demo-frontend/.ai-spec-constitution.md +95 -0
- package/demo-frontend/package.json +23 -0
- package/demo-frontend/src/App.tsx +12 -0
- package/demo-frontend/src/main.tsx +9 -0
- package/demo-frontend/tsconfig.json +13 -0
- package/dist/cli/index.js +4351 -3666
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3997 -3312
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +18 -16
- package/dist/index.d.ts +18 -16
- package/dist/index.js +388 -188
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +386 -186
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/tests/auto-consolidation.test.ts +109 -0
- package/tests/combined-generator.test.ts +81 -0
- package/tests/constitution-consolidator.test.ts +161 -0
- package/tests/constitution-generator.test.ts +94 -0
- package/tests/contract-bridge.test.ts +201 -0
- package/tests/design-dialogue.test.ts +108 -0
- package/tests/dsl-coverage-checker.test.ts +230 -0
- package/tests/dsl-feedback.test.ts +45 -0
- package/tests/dsl-validator-xref.test.ts +99 -0
- package/tests/error-feedback-repair.test.ts +319 -0
- package/tests/error-feedback-validation.test.ts +91 -0
- package/tests/frontend-context-loader.test.ts +609 -0
- package/tests/global-constitution.test.ts +110 -0
- package/tests/key-store.test.ts +73 -0
- package/tests/knowledge-memory.test.ts +327 -0
- package/tests/project-index.test.ts +206 -0
- package/tests/prompt-hasher.test.ts +19 -0
- package/tests/requirement-decomposer.test.ts +171 -0
- package/tests/reviewer.test.ts +4 -1
- package/tests/run-logger.test.ts +289 -0
- package/tests/run-snapshot.test.ts +113 -0
- package/tests/safe-json.test.ts +63 -0
- package/tests/spec-updater.test.ts +161 -0
- package/tests/test-generator.test.ts +146 -0
- package/tests/token-budget.test.ts +124 -0
- package/tests/vcr-hash.test.ts +101 -0
- package/tests/workspace-loader.test.ts +277 -0
- package/RELEASE_LOG.md +0 -2731
- 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
|
+
}
|
package/core/code-generator.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|