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.
@@ -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
- // Include enough to see all export names (first 800 chars covers most API files)
49
- lines.push(content.slice(0, 800));
50
- if (content.length > 800) lines.push("... (truncated)");
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.slice(0, 2000)}\n`
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
- const LAYER_ORDER = ["data", "infra", "service", "api", "test"];
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
- // Snapshot the cache before this layer starts all parallel tasks in the same
467
- // layer see the same (pre-layer) cache, preventing partial-write races.
468
- const generatedFilesSection = buildGeneratedFilesSection(generatedFileCache);
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 taskResultPromises: Promise<TaskResult>[] = layerTasks.map(async (task) => {
627
+ const executeTask = async (task: SpecTask, batchIsParallel: boolean): Promise<TaskResult> => {
481
628
  if (task.filesToTouch.length === 0) {
482
- if (!isParallel) console.log(chalk.gray(" No files specified, skipping."));
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
- (taskText.includes("route") ||
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 + generatedFilesSection,
684
+ constitutionSection + frontendSection + sharedConfigSection + currentGeneratedFilesSection,
529
685
  systemPrompt,
530
- isParallel ? task.id : undefined // prefix output lines with task ID in parallel mode
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
- const layerResults = await Promise.all(taskResultPromises);
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
 
@@ -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, 800)}`);
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");
@@ -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
- // Take the last 80 lines most relevant error output
168
- const lines = output.split("\n").slice(-80);
167
+ // Scan the FULL outputactual 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
- // Try to extract file path (supports TS/JS, Go, Python, Java, Rust, PHP)
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, 300),
184
- file: fileMatch?.[1],
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.slice(0, 20); // cap at 20 errors
196
+ return errors;
189
197
  }
190
198
 
191
199
  // ─── Auto-Fix ───────────────────────────────────────────────────────────────────
@@ -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
  */