ai-spec-dev 0.14.1 → 0.24.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/.claude/settings.local.json +1 -6
- package/README.md +76 -10
- package/RELEASE_LOG.md +471 -0
- package/cli/index.ts +201 -23
- package/core/code-generator.ts +269 -32
- package/core/context-loader.ts +1 -1
- package/core/error-feedback.ts +14 -6
- package/core/knowledge-memory.ts +54 -0
- package/core/mock-server-generator.ts +210 -0
- package/core/spec-assessor.ts +1 -1
- package/core/task-generator.ts +7 -3
- package/dist/cli/index.js +553 -57
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +553 -57
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +214 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +214 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/prompts/codegen.prompt.ts +11 -3
- package/prompts/consolidate.prompt.ts +3 -1
- package/prompts/tasks.prompt.ts +28 -5
- package/prompts/update.prompt.ts +1 -1
- package/purpose.md +174 -101
package/core/code-generator.ts
CHANGED
|
@@ -33,6 +33,142 @@ function buildInstalledPackagesSection(context?: ProjectContext): string {
|
|
|
33
33
|
return `\n=== Installed Packages (ONLY use packages from this list — NEVER import anything not listed here) ===\n${context.dependencies.join(", ")}\n`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Extract a behavioral contract summary from a generated file.
|
|
38
|
+
*
|
|
39
|
+
* Captures:
|
|
40
|
+
* - export interface / type / enum — full multi-line blocks (the actual TS contracts)
|
|
41
|
+
* - export function / const / class — opening signature line
|
|
42
|
+
* - Throw statements — error codes & validation constraints
|
|
43
|
+
*
|
|
44
|
+
* Multi-line blocks (interface, type alias with {}) are captured in full so
|
|
45
|
+
* downstream tasks see complete method signatures and field shapes, not just
|
|
46
|
+
* a single-line "export interface Foo {" that conveys nothing.
|
|
47
|
+
*
|
|
48
|
+
* Falls back to first 3000 chars for CommonJS files with no explicit exports.
|
|
49
|
+
*/
|
|
50
|
+
function extractBehavioralContract(content: string): string {
|
|
51
|
+
const lines = content.split("\n");
|
|
52
|
+
const contractLines: string[] = [];
|
|
53
|
+
const throwLines: string[] = [];
|
|
54
|
+
let i = 0;
|
|
55
|
+
|
|
56
|
+
while (i < lines.length) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
|
|
60
|
+
// ── Multi-line block exports: interface / type X = { / class / enum ──────
|
|
61
|
+
// Capture the full block so downstream tasks see the complete contract.
|
|
62
|
+
if (/^export\s+(interface|type|class|abstract\s+class|enum)\s/.test(trimmed)) {
|
|
63
|
+
contractLines.push(line.trimEnd());
|
|
64
|
+
if (trimmed.includes("{")) {
|
|
65
|
+
let depth =
|
|
66
|
+
(trimmed.match(/\{/g) ?? []).length -
|
|
67
|
+
(trimmed.match(/\}/g) ?? []).length;
|
|
68
|
+
i++;
|
|
69
|
+
while (i < lines.length && depth > 0) {
|
|
70
|
+
const inner = lines[i];
|
|
71
|
+
contractLines.push(inner.trimEnd());
|
|
72
|
+
depth += (inner.match(/\{/g) ?? []).length;
|
|
73
|
+
depth -= (inner.match(/\}/g) ?? []).length;
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── export const X = defineStore(...) — capture full block ───────────────
|
|
83
|
+
// Pinia stores wrap all actions inside defineStore(). Without the full block
|
|
84
|
+
// the consumer only sees "export const useTaskStore = defineStore(" and has
|
|
85
|
+
// to guess every action name — the primary source of fetchTasks→fetchTaskList
|
|
86
|
+
// hallucinations. Capture the complete defineStore(...) call so the return
|
|
87
|
+
// object (public API) is visible.
|
|
88
|
+
if (/^export\s+const\s+\w+\s*=\s*(defineStore|createStore|createSlice)\s*\(/.test(trimmed)) {
|
|
89
|
+
contractLines.push(line.trimEnd());
|
|
90
|
+
let depth = (trimmed.match(/\(/g) ?? []).length - (trimmed.match(/\)/g) ?? []).length;
|
|
91
|
+
i++;
|
|
92
|
+
while (i < lines.length && depth > 0) {
|
|
93
|
+
const inner = lines[i];
|
|
94
|
+
contractLines.push(inner.trimEnd());
|
|
95
|
+
depth += (inner.match(/\(/g) ?? []).length;
|
|
96
|
+
depth -= (inner.match(/\)/g) ?? []).length;
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── return { ... } — composable/store public API surface ─────────────────
|
|
103
|
+
// In Pinia composition-API stores and Vue composables the return object is
|
|
104
|
+
// the definitive list of exposed names. Capture it so consumers see the
|
|
105
|
+
// exact exported identifiers (e.g. "fetchTasks" not "fetchTaskList").
|
|
106
|
+
if (/^return\s*\{/.test(trimmed)) {
|
|
107
|
+
contractLines.push("// public API (return object):");
|
|
108
|
+
contractLines.push(line.trimEnd());
|
|
109
|
+
let depth = (trimmed.match(/\{/g) ?? []).length - (trimmed.match(/\}/g) ?? []).length;
|
|
110
|
+
i++;
|
|
111
|
+
while (i < lines.length && depth > 0) {
|
|
112
|
+
const inner = lines[i];
|
|
113
|
+
contractLines.push(inner.trimEnd());
|
|
114
|
+
depth += (inner.match(/\{/g) ?? []).length;
|
|
115
|
+
depth -= (inner.match(/\}/g) ?? []).length;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── export default function/class — capture full block ───────────────────
|
|
122
|
+
// Needed for React components (export default function Foo()) and Vue
|
|
123
|
+
// composables (export default class Foo {}). Without full-block capture the
|
|
124
|
+
// consumer only sees the opening line and can't know the return shape.
|
|
125
|
+
if (/^export\s+default\s+(async\s+)?(function|class)\b/.test(trimmed)) {
|
|
126
|
+
contractLines.push(line.trimEnd());
|
|
127
|
+
if (trimmed.includes("{")) {
|
|
128
|
+
let depth =
|
|
129
|
+
(trimmed.match(/\{/g) ?? []).length -
|
|
130
|
+
(trimmed.match(/\}/g) ?? []).length;
|
|
131
|
+
i++;
|
|
132
|
+
while (i < lines.length && depth > 0) {
|
|
133
|
+
const inner = lines[i];
|
|
134
|
+
contractLines.push(inner.trimEnd());
|
|
135
|
+
depth += (inner.match(/\{/g) ?? []).length;
|
|
136
|
+
depth -= (inner.match(/\}/g) ?? []).length;
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Single-line export declarations (functions, consts, re-exports) ───────
|
|
146
|
+
if (/^export\s/.test(trimmed)) {
|
|
147
|
+
contractLines.push(line.trimEnd());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Throw patterns — validation constraints and named error codes ─────────
|
|
151
|
+
if (
|
|
152
|
+
/throw\s+(new\s+)?\w*[Ee]rror\b|throw\s+create[A-Z]\w*|@throws/.test(line) &&
|
|
153
|
+
throwLines.length < 20
|
|
154
|
+
) {
|
|
155
|
+
throwLines.push(" // " + trimmed);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (contractLines.length === 0 && throwLines.length === 0) {
|
|
162
|
+
return content.slice(0, 3000);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const parts: string[] = [...contractLines];
|
|
166
|
+
if (throwLines.length > 0) {
|
|
167
|
+
parts.push("", "// Error contracts (throws / validation):", ...throwLines);
|
|
168
|
+
}
|
|
169
|
+
return parts.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
36
172
|
/**
|
|
37
173
|
* Build a context section from files already written in this generation run.
|
|
38
174
|
* Injected before generating files that may import from those paths (e.g., route files
|
|
@@ -42,12 +178,22 @@ function buildGeneratedFilesSection(cache: Map<string, string>): string {
|
|
|
42
178
|
if (cache.size === 0) return "";
|
|
43
179
|
const lines = [
|
|
44
180
|
"\n=== Files Already Generated in This Run — USE EXACT EXPORTS (do not rename or invent alternatives) ===",
|
|
181
|
+
"// CRITICAL: function/action names and file paths below are ground truth. Copy them EXACTLY.",
|
|
182
|
+
"// Do NOT add suffixes (List, Data, All, Info) or change casing.",
|
|
183
|
+
"// For '// exists:' entries: use the EXACT filename shown — do NOT substitute index.vue or other defaults.",
|
|
45
184
|
];
|
|
46
185
|
for (const [filePath, content] of cache) {
|
|
186
|
+
// View/page components: only show the path as a name sentinel.
|
|
187
|
+
// The router needs to know the exact filename (e.g. TaskManagement.vue, NOT index.vue).
|
|
188
|
+
const isViewFile = /src[\\/](views?|pages?)[\\/]/i.test(filePath);
|
|
189
|
+
if (isViewFile) {
|
|
190
|
+
lines.push(`\n// exists: ${filePath}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
47
193
|
lines.push(`\n--- ${filePath} ---`);
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
194
|
+
// Store and composable files: pass full content — the entire file IS the contract
|
|
195
|
+
const isStoreOrComposable = /src[\\/](stores?|composables?)[\\/]/i.test(filePath);
|
|
196
|
+
lines.push(isStoreOrComposable ? content : extractBehavioralContract(content));
|
|
51
197
|
}
|
|
52
198
|
return lines.join("\n") + "\n";
|
|
53
199
|
}
|
|
@@ -308,7 +454,7 @@ export class CodeGenerator {
|
|
|
308
454
|
|
|
309
455
|
const spec = await fs.readFile(specFilePath, "utf-8");
|
|
310
456
|
const constitutionSection = context?.constitution
|
|
311
|
-
? `\n=== Project Constitution (MUST follow) ===\n${context.constitution
|
|
457
|
+
? `\n=== Project Constitution (MUST follow) ===\n${context.constitution}\n`
|
|
312
458
|
: "";
|
|
313
459
|
const contextSummary = context
|
|
314
460
|
? `Tech Stack: ${context.techStack.join(", ")}\nExisting files: ${context.fileStructure.slice(0, 20).join(", ")}`
|
|
@@ -435,7 +581,10 @@ Output ONLY a valid JSON array:
|
|
|
435
581
|
}
|
|
436
582
|
|
|
437
583
|
// ── Group pending tasks by layer in dependency order ──────────────────────
|
|
438
|
-
|
|
584
|
+
// Frontend layer chain:
|
|
585
|
+
// service (api call fns) → api (stores) → view (page components) → route (router files) → test
|
|
586
|
+
// "route" sits after "view" so router files see the exact filenames of view components in cache.
|
|
587
|
+
const LAYER_ORDER = ["data", "infra", "service", "api", "view", "route", "test"];
|
|
439
588
|
const layerGroups: Array<{ layer: string; tasks: SpecTask[] }> = [];
|
|
440
589
|
|
|
441
590
|
for (const layer of LAYER_ORDER) {
|
|
@@ -463,11 +612,9 @@ Output ONLY a valid JSON array:
|
|
|
463
612
|
printTaskProgress(completedTasks, tasks.length, layerTasks[0], "run");
|
|
464
613
|
}
|
|
465
614
|
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
// ── Execute all tasks in this layer concurrently ──────────────────────
|
|
615
|
+
// ── Execute tasks in this layer in topological batch order ─────────────
|
|
616
|
+
// Tasks with intra-layer dependencies run in separate sequential batches;
|
|
617
|
+
// independent tasks within a batch run in parallel.
|
|
471
618
|
interface TaskResult {
|
|
472
619
|
task: SpecTask;
|
|
473
620
|
files: string[];
|
|
@@ -477,9 +624,9 @@ Output ONLY a valid JSON array:
|
|
|
477
624
|
impliesRegistration: boolean;
|
|
478
625
|
}
|
|
479
626
|
|
|
480
|
-
const
|
|
627
|
+
const executeTask = async (task: SpecTask, batchIsParallel: boolean): Promise<TaskResult> => {
|
|
481
628
|
if (task.filesToTouch.length === 0) {
|
|
482
|
-
if (!
|
|
629
|
+
if (!batchIsParallel) console.log(chalk.gray(" No files specified, skipping."));
|
|
483
630
|
return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false };
|
|
484
631
|
}
|
|
485
632
|
|
|
@@ -500,9 +647,15 @@ Output ONLY a valid JSON array:
|
|
|
500
647
|
// Determine if this task creates registerable artifacts (for post-layer shared config update)
|
|
501
648
|
const createsNewFiles = filePlan.some((f) => f.action === "create");
|
|
502
649
|
const taskText = `${task.title} ${task.description}`.toLowerCase();
|
|
650
|
+
// Layer-based check: "route", "view", and "api" layers always imply
|
|
651
|
+
// registration (route index / store index update) when they create new files.
|
|
652
|
+
// Text-keyword check is a fallback for layers not explicitly listed.
|
|
503
653
|
const impliesRegistration =
|
|
504
654
|
createsNewFiles &&
|
|
505
|
-
(
|
|
655
|
+
(task.layer === "route" ||
|
|
656
|
+
task.layer === "view" ||
|
|
657
|
+
task.layer === "api" ||
|
|
658
|
+
taskText.includes("route") ||
|
|
506
659
|
taskText.includes("router") ||
|
|
507
660
|
taskText.includes("page") ||
|
|
508
661
|
taskText.includes("view") ||
|
|
@@ -520,14 +673,17 @@ Output ONLY a valid JSON array:
|
|
|
520
673
|
return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration };
|
|
521
674
|
}
|
|
522
675
|
|
|
676
|
+
// Re-snapshot the cache at task execution time so intra-layer earlier
|
|
677
|
+
// batches' output is visible to later batches.
|
|
678
|
+
const currentGeneratedFilesSection = buildGeneratedFilesSection(generatedFileCache);
|
|
523
679
|
const taskContext = `Task: ${task.id} — ${task.title}\n${task.description}\nAcceptance: ${task.acceptanceCriteria.join("; ")}`;
|
|
524
680
|
const { success, total, files } = await this.generateFiles(
|
|
525
681
|
filePlan,
|
|
526
682
|
`${spec}\n\n=== Current Task ===\n${taskContext}`,
|
|
527
683
|
workingDir,
|
|
528
|
-
constitutionSection + frontendSection + sharedConfigSection +
|
|
684
|
+
constitutionSection + frontendSection + sharedConfigSection + currentGeneratedFilesSection,
|
|
529
685
|
systemPrompt,
|
|
530
|
-
|
|
686
|
+
batchIsParallel ? task.id : undefined // prefix output lines with task ID in parallel mode
|
|
531
687
|
);
|
|
532
688
|
|
|
533
689
|
const createdFiles = filePlan
|
|
@@ -535,9 +691,41 @@ Output ONLY a valid JSON array:
|
|
|
535
691
|
.map((fp) => fp.file);
|
|
536
692
|
|
|
537
693
|
return { task, files, createdFiles, success, total, impliesRegistration };
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// Helper: update generatedFileCache from a completed batch's results.
|
|
697
|
+
// Called after each batch so the next batch sees the prior batch's exports.
|
|
698
|
+
const updateCacheFromBatch = async (results: TaskResult[]) => {
|
|
699
|
+
for (const result of results) {
|
|
700
|
+
for (const writtenFile of result.files) {
|
|
701
|
+
const isCodeFile = /src[\\/](api[s]?|services?|stores?|composables?)[\\/]/i.test(writtenFile);
|
|
702
|
+
// View/page files: cache a sentinel so router layer knows the exact filename.
|
|
703
|
+
const isViewFile = /src[\\/](views?|pages?)[\\/]/i.test(writtenFile);
|
|
704
|
+
if (isCodeFile || isViewFile) {
|
|
705
|
+
try {
|
|
706
|
+
const content = isViewFile
|
|
707
|
+
? `// view component — use this exact path for router imports`
|
|
708
|
+
: await fs.readFile(path.join(workingDir, writtenFile), "utf-8");
|
|
709
|
+
generatedFileCache.set(writtenFile, content);
|
|
710
|
+
} catch { /* ignore */ }
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
// Partition tasks into topological batches (respects dependencies field).
|
|
717
|
+
// Each batch runs in parallel; batches run sequentially.
|
|
718
|
+
const taskBatches = topoSortLayerTasks(layerTasks);
|
|
719
|
+
const layerResults: TaskResult[] = [];
|
|
720
|
+
|
|
721
|
+
for (const batch of taskBatches) {
|
|
722
|
+
const batchIsParallel = batch.length > 1;
|
|
723
|
+
const batchResultPromises = batch.map((task) => executeTask(task, batchIsParallel));
|
|
724
|
+
const batchResults = await Promise.all(batchResultPromises);
|
|
725
|
+
layerResults.push(...batchResults);
|
|
726
|
+
// Update cache after each batch so the next batch sees the exports.
|
|
727
|
+
await updateCacheFromBatch(batchResults);
|
|
728
|
+
}
|
|
541
729
|
|
|
542
730
|
// ── Aggregate layer results ───────────────────────────────────────────
|
|
543
731
|
if (isParallel) {
|
|
@@ -564,20 +752,6 @@ Output ONLY a valid JSON array:
|
|
|
564
752
|
|
|
565
753
|
completedTasks += layerTasks.length;
|
|
566
754
|
|
|
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
755
|
// ── Post-layer: batch shared config update ────────────────────────────
|
|
582
756
|
// If any task in this layer created registerable files, update shared config
|
|
583
757
|
// files once using the complete list of new modules from the whole layer.
|
|
@@ -706,6 +880,67 @@ ${spec}`,
|
|
|
706
880
|
}
|
|
707
881
|
}
|
|
708
882
|
|
|
883
|
+
// ─── Topological Batch Sort ────────────────────────────────────────────────────
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Partition tasks within a layer into ordered batches that respect the
|
|
887
|
+
* `dependencies` field. Tasks in the same batch have no intra-layer
|
|
888
|
+
* dependencies on each other and can run in parallel. Tasks in later batches
|
|
889
|
+
* wait for earlier batches to complete.
|
|
890
|
+
*
|
|
891
|
+
* Only intra-layer dependencies (i.e. deps whose IDs also appear in `tasks`)
|
|
892
|
+
* are considered — cross-layer ordering is already handled by LAYER_ORDER.
|
|
893
|
+
*
|
|
894
|
+
* Returns at least one batch. On circular-dependency detection the remaining
|
|
895
|
+
* tasks are dumped into a final batch so execution always completes.
|
|
896
|
+
*/
|
|
897
|
+
function topoSortLayerTasks(tasks: SpecTask[]): SpecTask[][] {
|
|
898
|
+
if (tasks.length <= 1) return [tasks];
|
|
899
|
+
|
|
900
|
+
const idSet = new Set(tasks.map((t) => t.id));
|
|
901
|
+
const taskById = new Map(tasks.map((t) => [t.id, t]));
|
|
902
|
+
const inDegree = new Map<string, number>();
|
|
903
|
+
const dependents = new Map<string, string[]>(); // dep → tasks that depend on it
|
|
904
|
+
|
|
905
|
+
for (const task of tasks) {
|
|
906
|
+
inDegree.set(task.id, 0);
|
|
907
|
+
dependents.set(task.id, []);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
for (const task of tasks) {
|
|
911
|
+
const intraDeps = task.dependencies.filter((dep) => idSet.has(dep));
|
|
912
|
+
inDegree.set(task.id, intraDeps.length);
|
|
913
|
+
for (const dep of intraDeps) {
|
|
914
|
+
dependents.get(dep)!.push(task.id);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const batches: SpecTask[][] = [];
|
|
919
|
+
const remaining = new Set(tasks.map((t) => t.id));
|
|
920
|
+
|
|
921
|
+
while (remaining.size > 0) {
|
|
922
|
+
const batch = [...remaining]
|
|
923
|
+
.filter((id) => inDegree.get(id) === 0)
|
|
924
|
+
.map((id) => taskById.get(id)!);
|
|
925
|
+
|
|
926
|
+
if (batch.length === 0) {
|
|
927
|
+
// Circular dependency — run all remaining tasks in parallel to avoid deadlock
|
|
928
|
+
batches.push([...remaining].map((id) => taskById.get(id)!));
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
batches.push(batch);
|
|
933
|
+
for (const task of batch) {
|
|
934
|
+
remaining.delete(task.id);
|
|
935
|
+
for (const dependent of dependents.get(task.id)!) {
|
|
936
|
+
inDegree.set(dependent, inDegree.get(dependent)! - 1);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return batches;
|
|
942
|
+
}
|
|
943
|
+
|
|
709
944
|
// ─── Progress Bar Helper ───────────────────────────────────────────────────────
|
|
710
945
|
|
|
711
946
|
const LAYER_ICONS: Record<string, string> = {
|
|
@@ -713,6 +948,8 @@ const LAYER_ICONS: Record<string, string> = {
|
|
|
713
948
|
infra: "⚙️ ",
|
|
714
949
|
service: "🔧",
|
|
715
950
|
api: "🌐",
|
|
951
|
+
view: "🖥️ ",
|
|
952
|
+
route: "🗺️ ",
|
|
716
953
|
test: "🧪",
|
|
717
954
|
};
|
|
718
955
|
|
package/core/context-loader.ts
CHANGED
|
@@ -244,7 +244,7 @@ export class ContextLoader {
|
|
|
244
244
|
for (const f of propFiles.slice(0, 2)) {
|
|
245
245
|
try {
|
|
246
246
|
const content = await fs.readFile(path.join(this.projectRoot, f), "utf-8");
|
|
247
|
-
parts.push(`// ${f}\n${content.slice(0,
|
|
247
|
+
parts.push(`// ${f}\n${content.slice(0, 2000)}`);
|
|
248
248
|
} catch { /* skip */ }
|
|
249
249
|
}
|
|
250
250
|
if (parts.length > 0) context.routeSummary = parts.join("\n\n");
|
package/core/error-feedback.ts
CHANGED
|
@@ -164,8 +164,10 @@ function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[]
|
|
|
164
164
|
const errors: ErrorEntry[] = [];
|
|
165
165
|
if (!output.trim()) return errors;
|
|
166
166
|
|
|
167
|
-
//
|
|
168
|
-
|
|
167
|
+
// Scan the FULL output — actual errors with file:line refs appear early,
|
|
168
|
+
// not at the end. The old slice(-80) approach was discarding the first errors
|
|
169
|
+
// and only keeping the trailing summary, which caused the AI to fix the wrong things.
|
|
170
|
+
const lines = output.split("\n");
|
|
169
171
|
|
|
170
172
|
for (const line of lines) {
|
|
171
173
|
const trimmed = line.trim();
|
|
@@ -176,16 +178,22 @@ function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[]
|
|
|
176
178
|
if (trimmed.startsWith("at ")) continue;
|
|
177
179
|
if (trimmed.startsWith("Node.js ")) continue;
|
|
178
180
|
|
|
179
|
-
//
|
|
181
|
+
// Only capture lines that reference a source file (file:line pattern).
|
|
182
|
+
// This filters out summary lines ("Found 12 errors.") and only keeps
|
|
183
|
+
// actionable entries the AI can actually fix.
|
|
180
184
|
const fileMatch = trimmed.match(/^([^:]+\.(?:ts|js|tsx|jsx|go|py|java|rs|php)):\d+/);
|
|
185
|
+
if (!fileMatch) continue;
|
|
186
|
+
|
|
181
187
|
errors.push({
|
|
182
188
|
source,
|
|
183
|
-
message: trimmed.slice(0,
|
|
184
|
-
file: fileMatch
|
|
189
|
+
message: trimmed.slice(0, 400),
|
|
190
|
+
file: fileMatch[1],
|
|
185
191
|
});
|
|
192
|
+
|
|
193
|
+
if (errors.length >= 20) break; // cap at 20 — first 20 are the most actionable
|
|
186
194
|
}
|
|
187
195
|
|
|
188
|
-
return errors
|
|
196
|
+
return errors;
|
|
189
197
|
}
|
|
190
198
|
|
|
191
199
|
// ─── Auto-Fix ───────────────────────────────────────────────────────────────────
|
package/core/knowledge-memory.ts
CHANGED
|
@@ -146,6 +146,60 @@ export async function appendLessonsToConstitution(
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Directly append a freeform lesson to constitution §9.
|
|
151
|
+
* Zero-friction entry point — no AI call required.
|
|
152
|
+
*/
|
|
153
|
+
export async function appendDirectLesson(
|
|
154
|
+
projectRoot: string,
|
|
155
|
+
lessonText: string
|
|
156
|
+
): Promise<{ appended: boolean; reason?: string }> {
|
|
157
|
+
const constitutionPath = path.join(projectRoot, CONSTITUTION_FILE);
|
|
158
|
+
let content = "";
|
|
159
|
+
try {
|
|
160
|
+
content = await fs.readFile(constitutionPath, "utf-8");
|
|
161
|
+
} catch {
|
|
162
|
+
return { appended: false, reason: "No constitution file found. Run `ai-spec init` first." };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Dedup: check first 60 chars
|
|
166
|
+
const normalized = lessonText.toLowerCase().slice(0, 60);
|
|
167
|
+
if (content.toLowerCase().includes(normalized)) {
|
|
168
|
+
return { appended: false, reason: "Similar lesson already exists in the constitution." };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
172
|
+
const entry = `- 📝 **[${date}]** ${lessonText.trim()}`;
|
|
173
|
+
const hasMemorySection = content.includes(MEMORY_SECTION_MARKER);
|
|
174
|
+
|
|
175
|
+
let updatedContent: string;
|
|
176
|
+
if (hasMemorySection) {
|
|
177
|
+
const sectionStart = content.indexOf(MEMORY_SECTION_MARKER);
|
|
178
|
+
const afterHeader = sectionStart + MEMORY_SECTION_HEADER.length;
|
|
179
|
+
const nextSectionMatch = content.slice(afterHeader).match(/\n## \d/);
|
|
180
|
+
const insertPos = nextSectionMatch
|
|
181
|
+
? afterHeader + nextSectionMatch.index!
|
|
182
|
+
: content.length;
|
|
183
|
+
updatedContent =
|
|
184
|
+
content.slice(0, insertPos) + entry + "\n" + content.slice(insertPos);
|
|
185
|
+
} else {
|
|
186
|
+
updatedContent = content + MEMORY_SECTION_HEADER + entry + "\n";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await fs.writeFile(constitutionPath, updatedContent, "utf-8");
|
|
190
|
+
|
|
191
|
+
const stats = parseConstitutionStats(updatedContent);
|
|
192
|
+
if (stats.lessonCount >= 8) {
|
|
193
|
+
console.log(
|
|
194
|
+
chalk.yellow(
|
|
195
|
+
` ⚠ §9 now has ${stats.lessonCount} lessons. Run \`ai-spec init --consolidate\` to prune and rebase.`
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { appended: true };
|
|
201
|
+
}
|
|
202
|
+
|
|
149
203
|
/**
|
|
150
204
|
* Full knowledge memory flow: extract issues from review → append to constitution.
|
|
151
205
|
*/
|