ai-spec-dev 0.17.0 → 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 +79 -17
- package/RELEASE_LOG.md +426 -0
- package/cli/index.ts +200 -42
- package/core/code-generator.ts +266 -46
- package/core/error-feedback.ts +14 -6
- package/core/knowledge-memory.ts +54 -0
- package/core/mock-server-generator.ts +210 -0
- package/core/task-generator.ts +6 -2
- 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/purpose.md +174 -101
package/core/code-generator.ts
CHANGED
|
@@ -34,37 +34,166 @@ function buildInstalledPackagesSection(context?: ProjectContext): string {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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.
|
|
46
49
|
*/
|
|
47
|
-
function
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
51
157
|
|
|
52
|
-
|
|
53
|
-
return exportLines.join("\n");
|
|
158
|
+
i++;
|
|
54
159
|
}
|
|
55
160
|
|
|
56
|
-
|
|
57
|
-
|
|
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");
|
|
58
170
|
}
|
|
59
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Build a context section from files already written in this generation run.
|
|
174
|
+
* Injected before generating files that may import from those paths (e.g., route files
|
|
175
|
+
* importing from API files generated in an earlier task).
|
|
176
|
+
*/
|
|
60
177
|
function buildGeneratedFilesSection(cache: Map<string, string>): string {
|
|
61
178
|
if (cache.size === 0) return "";
|
|
62
179
|
const lines = [
|
|
63
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.",
|
|
64
184
|
];
|
|
65
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
|
+
}
|
|
66
193
|
lines.push(`\n--- ${filePath} ---`);
|
|
67
|
-
|
|
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));
|
|
68
197
|
}
|
|
69
198
|
return lines.join("\n") + "\n";
|
|
70
199
|
}
|
|
@@ -452,7 +581,10 @@ Output ONLY a valid JSON array:
|
|
|
452
581
|
}
|
|
453
582
|
|
|
454
583
|
// ── Group pending tasks by layer in dependency order ──────────────────────
|
|
455
|
-
|
|
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"];
|
|
456
588
|
const layerGroups: Array<{ layer: string; tasks: SpecTask[] }> = [];
|
|
457
589
|
|
|
458
590
|
for (const layer of LAYER_ORDER) {
|
|
@@ -480,11 +612,9 @@ Output ONLY a valid JSON array:
|
|
|
480
612
|
printTaskProgress(completedTasks, tasks.length, layerTasks[0], "run");
|
|
481
613
|
}
|
|
482
614
|
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
// ── 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.
|
|
488
618
|
interface TaskResult {
|
|
489
619
|
task: SpecTask;
|
|
490
620
|
files: string[];
|
|
@@ -494,9 +624,9 @@ Output ONLY a valid JSON array:
|
|
|
494
624
|
impliesRegistration: boolean;
|
|
495
625
|
}
|
|
496
626
|
|
|
497
|
-
const
|
|
627
|
+
const executeTask = async (task: SpecTask, batchIsParallel: boolean): Promise<TaskResult> => {
|
|
498
628
|
if (task.filesToTouch.length === 0) {
|
|
499
|
-
if (!
|
|
629
|
+
if (!batchIsParallel) console.log(chalk.gray(" No files specified, skipping."));
|
|
500
630
|
return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false };
|
|
501
631
|
}
|
|
502
632
|
|
|
@@ -517,9 +647,15 @@ Output ONLY a valid JSON array:
|
|
|
517
647
|
// Determine if this task creates registerable artifacts (for post-layer shared config update)
|
|
518
648
|
const createsNewFiles = filePlan.some((f) => f.action === "create");
|
|
519
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.
|
|
520
653
|
const impliesRegistration =
|
|
521
654
|
createsNewFiles &&
|
|
522
|
-
(
|
|
655
|
+
(task.layer === "route" ||
|
|
656
|
+
task.layer === "view" ||
|
|
657
|
+
task.layer === "api" ||
|
|
658
|
+
taskText.includes("route") ||
|
|
523
659
|
taskText.includes("router") ||
|
|
524
660
|
taskText.includes("page") ||
|
|
525
661
|
taskText.includes("view") ||
|
|
@@ -537,14 +673,17 @@ Output ONLY a valid JSON array:
|
|
|
537
673
|
return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration };
|
|
538
674
|
}
|
|
539
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);
|
|
540
679
|
const taskContext = `Task: ${task.id} — ${task.title}\n${task.description}\nAcceptance: ${task.acceptanceCriteria.join("; ")}`;
|
|
541
680
|
const { success, total, files } = await this.generateFiles(
|
|
542
681
|
filePlan,
|
|
543
682
|
`${spec}\n\n=== Current Task ===\n${taskContext}`,
|
|
544
683
|
workingDir,
|
|
545
|
-
constitutionSection + frontendSection + sharedConfigSection +
|
|
684
|
+
constitutionSection + frontendSection + sharedConfigSection + currentGeneratedFilesSection,
|
|
546
685
|
systemPrompt,
|
|
547
|
-
|
|
686
|
+
batchIsParallel ? task.id : undefined // prefix output lines with task ID in parallel mode
|
|
548
687
|
);
|
|
549
688
|
|
|
550
689
|
const createdFiles = filePlan
|
|
@@ -552,9 +691,41 @@ Output ONLY a valid JSON array:
|
|
|
552
691
|
.map((fp) => fp.file);
|
|
553
692
|
|
|
554
693
|
return { task, files, createdFiles, success, total, impliesRegistration };
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}
|
|
558
729
|
|
|
559
730
|
// ── Aggregate layer results ───────────────────────────────────────────
|
|
560
731
|
if (isParallel) {
|
|
@@ -581,20 +752,6 @@ Output ONLY a valid JSON array:
|
|
|
581
752
|
|
|
582
753
|
completedTasks += layerTasks.length;
|
|
583
754
|
|
|
584
|
-
// ── Update generatedFileCache with all files written in this layer ────
|
|
585
|
-
// Done after all parallel tasks complete — ensures the next layer sees
|
|
586
|
-
// the full set of exports from this layer, not a partial view.
|
|
587
|
-
for (const result of layerResults) {
|
|
588
|
-
for (const writtenFile of result.files) {
|
|
589
|
-
if (/src[\\/](api[s]?|services?|stores?|composables?)[\\/]/.test(writtenFile)) {
|
|
590
|
-
try {
|
|
591
|
-
const content = await fs.readFile(path.join(workingDir, writtenFile), "utf-8");
|
|
592
|
-
generatedFileCache.set(writtenFile, content);
|
|
593
|
-
} catch { /* ignore */ }
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
755
|
// ── Post-layer: batch shared config update ────────────────────────────
|
|
599
756
|
// If any task in this layer created registerable files, update shared config
|
|
600
757
|
// files once using the complete list of new modules from the whole layer.
|
|
@@ -723,6 +880,67 @@ ${spec}`,
|
|
|
723
880
|
}
|
|
724
881
|
}
|
|
725
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
|
+
|
|
726
944
|
// ─── Progress Bar Helper ───────────────────────────────────────────────────────
|
|
727
945
|
|
|
728
946
|
const LAYER_ICONS: Record<string, string> = {
|
|
@@ -730,6 +948,8 @@ const LAYER_ICONS: Record<string, string> = {
|
|
|
730
948
|
infra: "⚙️ ",
|
|
731
949
|
service: "🔧",
|
|
732
950
|
api: "🌐",
|
|
951
|
+
view: "🖥️ ",
|
|
952
|
+
route: "🗺️ ",
|
|
733
953
|
test: "🧪",
|
|
734
954
|
};
|
|
735
955
|
|
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
|
*/
|