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.
@@ -34,37 +34,166 @@ function buildInstalledPackagesSection(context?: ProjectContext): string {
34
34
  }
35
35
 
36
36
  /**
37
- * Build a context section from files already written in this generation run.
38
- * Injected before generating files that may import from those paths (e.g., route files
39
- * importing from API files generated in an earlier task).
40
- */
41
- /**
42
- * Extract all export declaration lines from a file's content.
43
- * This gives precise signal about every exported name without including
44
- * implementation details, and works regardless of file length.
45
- * Falls back to first 3000 chars for CommonJS or files with no explicit exports.
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 extractExportSignatures(content: string): string {
48
- const exportLines = content
49
- .split("\n")
50
- .filter((line) => line.trimStart().startsWith("export "));
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
- if (exportLines.length > 0) {
53
- return exportLines.join("\n");
158
+ i++;
54
159
  }
55
160
 
56
- // Fallback for CommonJS or files without top-level export keywords
57
- return content.slice(0, 3000) + (content.length > 3000 ? "\n... (truncated)" : "");
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
- lines.push(extractExportSignatures(content));
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
- 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"];
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
- // Snapshot the cache before this layer starts all parallel tasks in the same
484
- // layer see the same (pre-layer) cache, preventing partial-write races.
485
- const generatedFilesSection = buildGeneratedFilesSection(generatedFileCache);
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 taskResultPromises: Promise<TaskResult>[] = layerTasks.map(async (task) => {
627
+ const executeTask = async (task: SpecTask, batchIsParallel: boolean): Promise<TaskResult> => {
498
628
  if (task.filesToTouch.length === 0) {
499
- if (!isParallel) console.log(chalk.gray(" No files specified, skipping."));
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
- (taskText.includes("route") ||
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 + generatedFilesSection,
684
+ constitutionSection + frontendSection + sharedConfigSection + currentGeneratedFilesSection,
546
685
  systemPrompt,
547
- 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
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
- 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
+ }
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
 
@@ -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
  */