ai-spec-dev 0.1.0 → 0.17.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.
Files changed (60) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/README.md +1215 -146
  3. package/RELEASE_LOG.md +1489 -0
  4. package/cli/index.ts +1981 -0
  5. package/cli/welcome.ts +151 -0
  6. package/core/code-generator.ts +757 -0
  7. package/core/combined-generator.ts +63 -0
  8. package/core/constitution-consolidator.ts +141 -0
  9. package/core/constitution-generator.ts +89 -0
  10. package/core/context-loader.ts +453 -0
  11. package/core/contract-bridge.ts +217 -0
  12. package/core/dsl-extractor.ts +337 -0
  13. package/core/dsl-types.ts +166 -0
  14. package/core/dsl-validator.ts +450 -0
  15. package/core/error-feedback.ts +354 -0
  16. package/core/frontend-context-loader.ts +602 -0
  17. package/core/global-constitution.ts +88 -0
  18. package/core/key-store.ts +49 -0
  19. package/core/knowledge-memory.ts +171 -0
  20. package/core/mock-server-generator.ts +571 -0
  21. package/core/openapi-exporter.ts +361 -0
  22. package/core/requirement-decomposer.ts +198 -0
  23. package/core/reviewer.ts +259 -0
  24. package/core/spec-assessor.ts +99 -0
  25. package/core/spec-generator.ts +428 -0
  26. package/core/spec-refiner.ts +89 -0
  27. package/core/spec-updater.ts +227 -0
  28. package/core/spec-versioning.ts +213 -0
  29. package/core/task-generator.ts +174 -0
  30. package/core/test-generator.ts +273 -0
  31. package/core/workspace-loader.ts +256 -0
  32. package/dist/cli/index.js +6717 -672
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/index.mjs +6717 -670
  35. package/dist/cli/index.mjs.map +1 -1
  36. package/dist/index.d.mts +147 -27
  37. package/dist/index.d.ts +147 -27
  38. package/dist/index.js +2337 -286
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2329 -285
  41. package/dist/index.mjs.map +1 -1
  42. package/git/worktree.ts +109 -0
  43. package/index.ts +9 -0
  44. package/package.json +4 -28
  45. package/prompts/codegen.prompt.ts +259 -0
  46. package/prompts/consolidate.prompt.ts +73 -0
  47. package/prompts/constitution.prompt.ts +63 -0
  48. package/prompts/decompose.prompt.ts +168 -0
  49. package/prompts/dsl.prompt.ts +203 -0
  50. package/prompts/frontend-spec.prompt.ts +191 -0
  51. package/prompts/global-constitution.prompt.ts +61 -0
  52. package/prompts/spec-assess.prompt.ts +53 -0
  53. package/prompts/spec.prompt.ts +102 -0
  54. package/prompts/tasks.prompt.ts +35 -0
  55. package/prompts/testgen.prompt.ts +84 -0
  56. package/prompts/update.prompt.ts +131 -0
  57. package/purpose.docx +0 -0
  58. package/purpose.md +444 -0
  59. package/tsconfig.json +14 -0
  60. package/tsup.config.ts +10 -0
@@ -0,0 +1,227 @@
1
+ import chalk from "chalk";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import { AIProvider } from "./spec-generator";
5
+ import { ProjectContext, FRONTEND_FRAMEWORKS } from "./context-loader";
6
+ import { SpecDSL } from "./dsl-types";
7
+ import { DslExtractor } from "./dsl-extractor";
8
+ import { nextVersionPath } from "./spec-versioning";
9
+ import { findLatestDslFile } from "./mock-server-generator";
10
+ import {
11
+ specUpdateSystemPrompt,
12
+ dslUpdateSystemPrompt,
13
+ buildSpecUpdatePrompt,
14
+ buildDslUpdatePrompt,
15
+ buildAffectedFilesPrompt,
16
+ } from "../prompts/update.prompt";
17
+ import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
18
+
19
+ // ─── Types ────────────────────────────────────────────────────────────────────
20
+
21
+ export interface SpecUpdateResult {
22
+ /** Path of the new spec version written to disk */
23
+ newSpecPath: string;
24
+ /** New version number */
25
+ newVersion: number;
26
+ /** Path of the updated DSL, or null if extraction failed */
27
+ newDslPath: string | null;
28
+ /** Files identified as needing updates */
29
+ affectedFiles: AffectedFile[];
30
+ /** Updated DSL, or null */
31
+ updatedDsl: SpecDSL | null;
32
+ }
33
+
34
+ export interface AffectedFile {
35
+ file: string;
36
+ action: "create" | "modify";
37
+ description: string;
38
+ }
39
+
40
+ export interface SpecUpdaterOptions {
41
+ /** Skip generating the affected-files list */
42
+ skipAffectedFiles?: boolean;
43
+ /** Repo language type — for DSL extraction front detection */
44
+ repoType?: string;
45
+ }
46
+
47
+ // ─── JSON parser (same pattern as requirement-decomposer.ts) ─────────────────
48
+
49
+ function parseJsonFromOutput(raw: string): unknown {
50
+ const trimmed = raw.trim();
51
+ if (trimmed.startsWith("{")) return JSON.parse(trimmed);
52
+ const fenceStart = trimmed.indexOf("```");
53
+ if (fenceStart !== -1) {
54
+ const afterFence = trimmed.slice(fenceStart + 3);
55
+ const newlinePos = afterFence.indexOf("\n");
56
+ const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
57
+ const fenceEnd = afterFence.lastIndexOf("```");
58
+ if (fenceEnd > jsonStart) return JSON.parse(afterFence.slice(jsonStart, fenceEnd).trim());
59
+ }
60
+ const objStart = trimmed.indexOf("{");
61
+ const arrStart = trimmed.indexOf("[");
62
+ const start = objStart !== -1 && (arrStart === -1 || objStart < arrStart) ? objStart : arrStart;
63
+ const isObj = start === objStart && objStart !== -1;
64
+ const end = isObj ? trimmed.lastIndexOf("}") : trimmed.lastIndexOf("]");
65
+ if (start !== -1 && end > start) return JSON.parse(trimmed.slice(start, end + 1));
66
+ throw new SyntaxError("No JSON found in output");
67
+ }
68
+
69
+ function parseAffectedFiles(raw: string): AffectedFile[] {
70
+ try {
71
+ const parsed = parseJsonFromOutput(raw);
72
+ if (Array.isArray(parsed)) return parsed as AffectedFile[];
73
+ } catch { /* ignore */ }
74
+ return [];
75
+ }
76
+
77
+ // ─── Spec Updater ─────────────────────────────────────────────────────────────
78
+
79
+ export class SpecUpdater {
80
+ private extractor: DslExtractor;
81
+
82
+ constructor(private provider: AIProvider) {
83
+ this.extractor = new DslExtractor(provider);
84
+ }
85
+
86
+ /**
87
+ * Find the latest spec version for a given specs directory.
88
+ * Returns all .md spec files sorted newest-first.
89
+ */
90
+ static async findLatestSpec(specsDir: string): Promise<{
91
+ filePath: string;
92
+ version: number;
93
+ slug: string;
94
+ content: string;
95
+ } | null> {
96
+ if (!(await fs.pathExists(specsDir))) return null;
97
+
98
+ const files = await fs.readdir(specsDir);
99
+ const pattern = /^feature-(.+)-v(\d+)\.md$/;
100
+
101
+ let latest: { filePath: string; version: number; slug: string; content: string } | null = null;
102
+
103
+ for (const file of files) {
104
+ const m = file.match(pattern);
105
+ if (!m) continue;
106
+ const version = parseInt(m[2], 10);
107
+ if (!latest || version > latest.version) {
108
+ const filePath = path.join(specsDir, file);
109
+ const content = await fs.readFile(filePath, "utf-8");
110
+ latest = { filePath, version, slug: m[1], content };
111
+ }
112
+ }
113
+
114
+ return latest;
115
+ }
116
+
117
+ /**
118
+ * Update an existing spec with a change request.
119
+ * Generates a new version of the spec, re-extracts the DSL, and identifies affected files.
120
+ */
121
+ async update(
122
+ changeRequest: string,
123
+ existingSpecPath: string,
124
+ projectDir: string,
125
+ context?: ProjectContext,
126
+ opts: SpecUpdaterOptions = {}
127
+ ): Promise<SpecUpdateResult> {
128
+ // ── Load existing spec ──────────────────────────────────────────────────
129
+ const existingSpec = await fs.readFile(existingSpecPath, "utf-8");
130
+
131
+ // ── Load existing DSL (may be absent) ──────────────────────────────────
132
+ let existingDsl: SpecDSL | null = null;
133
+ const dslFile = await findLatestDslFile(projectDir);
134
+ if (dslFile) {
135
+ try {
136
+ existingDsl = await fs.readJson(dslFile);
137
+ } catch { /* ignore */ }
138
+ }
139
+
140
+ // ── Step 1: Generate updated spec ──────────────────────────────────────
141
+ console.log(chalk.blue(" [1/3] Generating updated spec..."));
142
+ const updatePrompt = buildSpecUpdatePrompt(changeRequest, existingSpec, existingDsl, context);
143
+
144
+ let updatedSpecContent: string;
145
+ try {
146
+ updatedSpecContent = await this.provider.generate(updatePrompt, specUpdateSystemPrompt);
147
+ // Strip markdown fences if present
148
+ updatedSpecContent = updatedSpecContent.replace(/^```(?:markdown|md)?\n?/im, "").replace(/\n?```\s*$/im, "").trim();
149
+ } catch (err) {
150
+ throw new Error(`Spec update generation failed: ${(err as Error).message}`);
151
+ }
152
+
153
+ // ── Step 2: Write new spec version ─────────────────────────────────────
154
+ // Extract slug from existing spec path: feature-<slug>-v<N>.md
155
+ const specBasename = path.basename(existingSpecPath);
156
+ const slugMatch = specBasename.match(/^feature-(.+)-v\d+\.md$/);
157
+ const slug = slugMatch ? slugMatch[1] : "feature";
158
+
159
+ const specsDir = path.dirname(existingSpecPath);
160
+ const { filePath: newSpecPath, version: newVersion } = await nextVersionPath(specsDir, slug);
161
+
162
+ await fs.ensureDir(specsDir);
163
+ await fs.writeFile(newSpecPath, updatedSpecContent, "utf-8");
164
+ console.log(chalk.green(` ✔ New spec written: ${path.relative(projectDir, newSpecPath)}`));
165
+
166
+ // ── Step 3: Update DSL ─────────────────────────────────────────────────
167
+ console.log(chalk.blue(" [2/3] Updating DSL..."));
168
+ let updatedDsl: SpecDSL | null = null;
169
+ let newDslPath: string | null = null;
170
+
171
+ if (existingDsl) {
172
+ // Use targeted DSL update prompt
173
+ const dslUpdatePrompt = buildDslUpdatePrompt(changeRequest, existingDsl, updatedSpecContent);
174
+ try {
175
+ const rawDsl = await this.provider.generate(dslUpdatePrompt, dslUpdateSystemPrompt);
176
+ const parsed = parseJsonFromOutput(rawDsl) as SpecDSL;
177
+ if (parsed && parsed.endpoints && parsed.models) {
178
+ updatedDsl = parsed;
179
+ }
180
+ } catch {
181
+ // Fall back to full extraction
182
+ console.log(chalk.gray(" Targeted DSL update failed — falling back to full extraction."));
183
+ }
184
+ }
185
+
186
+ if (!updatedDsl) {
187
+ // Full extraction from updated spec
188
+ const isFrontend = opts.repoType
189
+ ? (FRONTEND_FRAMEWORKS as readonly string[]).includes(opts.repoType)
190
+ : false;
191
+ updatedDsl = await this.extractor.extract(updatedSpecContent, { auto: true, isFrontend });
192
+ }
193
+
194
+ if (updatedDsl) {
195
+ // Save DSL alongside spec
196
+ const dslPath = newSpecPath.replace(/\.md$/, ".dsl.json");
197
+ await fs.writeJson(dslPath, updatedDsl, { spaces: 2 });
198
+ newDslPath = dslPath;
199
+ console.log(chalk.green(` ✔ DSL updated: ${path.relative(projectDir, dslPath)}`));
200
+ } else {
201
+ console.log(chalk.yellow(" ⚠ DSL update failed — continuing without DSL."));
202
+ }
203
+
204
+ // ── Step 4: Identify affected files ────────────────────────────────────
205
+ let affectedFiles: AffectedFile[] = [];
206
+
207
+ if (!opts.skipAffectedFiles && updatedDsl && existingDsl && context) {
208
+ console.log(chalk.blue(" [3/3] Identifying affected files..."));
209
+ const systemPrompt = getCodeGenSystemPrompt(opts.repoType);
210
+ const affectedPrompt = buildAffectedFilesPrompt(
211
+ changeRequest,
212
+ existingDsl,
213
+ updatedDsl,
214
+ context.fileStructure
215
+ );
216
+ try {
217
+ const affectedRaw = await this.provider.generate(affectedPrompt, systemPrompt);
218
+ affectedFiles = parseAffectedFiles(affectedRaw);
219
+ console.log(chalk.green(` ✔ ${affectedFiles.length} file(s) identified for update`));
220
+ } catch {
221
+ console.log(chalk.gray(" Could not identify affected files — use manual selection."));
222
+ }
223
+ }
224
+
225
+ return { newSpecPath, newVersion, newDslPath, affectedFiles, updatedDsl };
226
+ }
227
+ }
@@ -0,0 +1,213 @@
1
+ import chalk from "chalk";
2
+ import * as fs from "fs-extra";
3
+ import * as path from "path";
4
+
5
+ // ─── Slug ────────────────────────────────────────────────────────────────────
6
+
7
+ /**
8
+ * Convert a free-form idea string into a safe, concise filename slug.
9
+ * e.g. "用户登录 with OAuth2" → "user-login-with-oauth2"
10
+ */
11
+ export function slugify(idea: string): string {
12
+ return idea
13
+ .toLowerCase()
14
+ .replace(/[\u4e00-\u9fa5]+/g, (m) => pinyinFallback(m)) // CJK → strip or placeholder
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/^-+|-+$/g, "")
17
+ .slice(0, 48) || "feature";
18
+ }
19
+
20
+ /** Best-effort: just strip CJK and use surrounding ascii context. */
21
+ function pinyinFallback(cjk: string): string {
22
+ // We don't have a pinyin lib — use empty string so the surrounding ascii words still form a slug
23
+ void cjk;
24
+ return "-";
25
+ }
26
+
27
+ // ─── Version Detection ───────────────────────────────────────────────────────
28
+
29
+ export interface SpecVersion {
30
+ filePath: string;
31
+ version: number;
32
+ content: string;
33
+ }
34
+
35
+ /**
36
+ * Scan `specsDir` for files matching `feature-<slug>-v<N>.md` and return the latest.
37
+ */
38
+ export async function findLatestVersion(
39
+ specsDir: string,
40
+ slug: string
41
+ ): Promise<SpecVersion | null> {
42
+ if (!(await fs.pathExists(specsDir))) return null;
43
+
44
+ const files = await fs.readdir(specsDir);
45
+ const pattern = new RegExp(`^feature-${escapeRegex(slug)}-v(\\d+)\\.md$`);
46
+ let latest: SpecVersion | null = null;
47
+
48
+ for (const file of files) {
49
+ const m = file.match(pattern);
50
+ if (!m) continue;
51
+ const version = parseInt(m[1], 10);
52
+ if (!latest || version > latest.version) {
53
+ const filePath = path.join(specsDir, file);
54
+ const content = await fs.readFile(filePath, "utf-8");
55
+ latest = { filePath, version, content };
56
+ }
57
+ }
58
+
59
+ return latest;
60
+ }
61
+
62
+ /**
63
+ * Return the path and version number for the NEXT spec file.
64
+ * If `feature-<slug>-v1.md` exists, returns `feature-<slug>-v2.md`, etc.
65
+ */
66
+ export async function nextVersionPath(
67
+ specsDir: string,
68
+ slug: string
69
+ ): Promise<{ filePath: string; version: number }> {
70
+ const latest = await findLatestVersion(specsDir, slug);
71
+ const version = latest ? latest.version + 1 : 1;
72
+ const filePath = path.join(specsDir, `feature-${slug}-v${version}.md`);
73
+ return { filePath, version };
74
+ }
75
+
76
+ function escapeRegex(s: string): string {
77
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ }
79
+
80
+ // ─── Diff Engine ─────────────────────────────────────────────────────────────
81
+
82
+ export interface DiffLine {
83
+ type: "added" | "removed" | "unchanged";
84
+ content: string;
85
+ lineNo: number;
86
+ }
87
+
88
+ export interface DiffResult {
89
+ added: number;
90
+ removed: number;
91
+ unchanged: number;
92
+ lines: DiffLine[];
93
+ }
94
+
95
+ /**
96
+ * Line-level diff between two text strings.
97
+ * Uses a simple LCS-based greedy diff (no external deps required).
98
+ */
99
+ export function computeDiff(oldText: string, newText: string): DiffResult {
100
+ const oldLines = oldText.split("\n");
101
+ const newLines = newText.split("\n");
102
+
103
+ // Build LCS table
104
+ const m = oldLines.length;
105
+ const n = newLines.length;
106
+
107
+ // For large files, limit to avoid quadratic cost
108
+ const MAX = 800;
109
+ if (m > MAX || n > MAX) {
110
+ return computeSimpleDiff(oldLines, newLines);
111
+ }
112
+
113
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
114
+ for (let i = m - 1; i >= 0; i--) {
115
+ for (let j = n - 1; j >= 0; j--) {
116
+ if (oldLines[i] === newLines[j]) {
117
+ dp[i][j] = dp[i + 1][j + 1] + 1;
118
+ } else {
119
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
120
+ }
121
+ }
122
+ }
123
+
124
+ const lines: DiffLine[] = [];
125
+ let i = 0, j = 0, lineNo = 1;
126
+
127
+ while (i < m || j < n) {
128
+ if (i < m && j < n && oldLines[i] === newLines[j]) {
129
+ lines.push({ type: "unchanged", content: oldLines[i], lineNo: lineNo++ });
130
+ i++; j++;
131
+ } else if (j < n && (i >= m || dp[i + 1][j] <= dp[i][j + 1])) {
132
+ lines.push({ type: "added", content: newLines[j], lineNo: lineNo++ });
133
+ j++;
134
+ } else {
135
+ lines.push({ type: "removed", content: oldLines[i], lineNo: lineNo });
136
+ i++;
137
+ }
138
+ }
139
+
140
+ const added = lines.filter((l) => l.type === "added").length;
141
+ const removed = lines.filter((l) => l.type === "removed").length;
142
+ const unchanged = lines.filter((l) => l.type === "unchanged").length;
143
+
144
+ return { added, removed, unchanged, lines };
145
+ }
146
+
147
+ /** Fast O(n) diff for large files — just mark all old as removed, all new as added. */
148
+ function computeSimpleDiff(oldLines: string[], newLines: string[]): DiffResult {
149
+ const lines: DiffLine[] = [
150
+ ...oldLines.map((c, i) => ({ type: "removed" as const, content: c, lineNo: i + 1 })),
151
+ ...newLines.map((c, i) => ({ type: "added" as const, content: c, lineNo: i + 1 })),
152
+ ];
153
+ return { added: newLines.length, removed: oldLines.length, unchanged: 0, lines };
154
+ }
155
+
156
+ // ─── Diff Printer ─────────────────────────────────────────────────────────────
157
+
158
+ const CONTEXT_LINES = 3; // unchanged lines to show around each hunk
159
+
160
+ /**
161
+ * Print a compact, colored unified-style diff to the console.
162
+ * Only shows changed hunks with `CONTEXT_LINES` lines of context.
163
+ */
164
+ export function printDiff(diff: DiffResult): void {
165
+ if (diff.added === 0 && diff.removed === 0) {
166
+ console.log(chalk.gray(" (no changes)"));
167
+ return;
168
+ }
169
+
170
+ const { lines } = diff;
171
+ const changedIdxs = new Set(
172
+ lines
173
+ .map((l, i) => (l.type !== "unchanged" ? i : -1))
174
+ .filter((i) => i !== -1)
175
+ );
176
+
177
+ // Build set of indices to display (changed ± context)
178
+ const toShow = new Set<number>();
179
+ for (const idx of changedIdxs) {
180
+ for (let k = Math.max(0, idx - CONTEXT_LINES); k <= Math.min(lines.length - 1, idx + CONTEXT_LINES); k++) {
181
+ toShow.add(k);
182
+ }
183
+ }
184
+
185
+ const sorted = [...toShow].sort((a, b) => a - b);
186
+ let prevIdx = -2;
187
+
188
+ for (const idx of sorted) {
189
+ if (idx > prevIdx + 1 && prevIdx !== -2) {
190
+ console.log(chalk.cyan(" @@"));
191
+ }
192
+ const l = lines[idx];
193
+ if (l.type === "added") {
194
+ console.log(chalk.green(` + ${l.content}`));
195
+ } else if (l.type === "removed") {
196
+ console.log(chalk.red(` - ${l.content}`));
197
+ } else {
198
+ console.log(chalk.gray(` ${l.content}`));
199
+ }
200
+ prevIdx = idx;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Print a one-line diff summary banner.
206
+ */
207
+ export function printDiffSummary(diff: DiffResult, label: string): void {
208
+ const parts: string[] = [];
209
+ if (diff.added > 0) parts.push(chalk.green(`+${diff.added}`));
210
+ if (diff.removed > 0) parts.push(chalk.red(`-${diff.removed}`));
211
+ if (parts.length === 0) parts.push(chalk.gray("no change"));
212
+ console.log(chalk.bold(` ${label}: `) + parts.join(" ") + chalk.gray(` lines`));
213
+ }
@@ -0,0 +1,174 @@
1
+ import chalk from "chalk";
2
+ import * as fs from "fs-extra";
3
+ import * as path from "path";
4
+ import { AIProvider } from "./spec-generator";
5
+ import { ProjectContext } from "./context-loader";
6
+ import { tasksSystemPrompt } from "../prompts/tasks.prompt";
7
+
8
+ // ─── Verified File Inventory ──────────────────────────────────────────────────
9
+ // Builds a structured, cross-referenced file list so the AI can pick
10
+ // real paths for filesToTouch without hallucinating.
11
+
12
+ function buildVerifiedInventory(context: ProjectContext): string {
13
+ const lines: string[] = ["=== Verified File Inventory (filesToTouch MUST use paths from here) ===\n"];
14
+
15
+ // 1. Shared config files first — highest priority, most likely to be hallucinated
16
+ if (context.sharedConfigFiles && context.sharedConfigFiles.length > 0) {
17
+ lines.push("-- Shared Config Files (APPEND-ONLY — never create a parallel file) --");
18
+ for (const f of context.sharedConfigFiles) {
19
+ lines.push(` [${f.category}] ${f.path}`);
20
+ }
21
+ lines.push("");
22
+ }
23
+
24
+ // 2. API / route / controller files (often need new siblings)
25
+ if (context.apiStructure.length > 0) {
26
+ lines.push("-- API / Route / Controller Files --");
27
+ for (const f of context.apiStructure.slice(0, 20)) {
28
+ lines.push(` ${f}`);
29
+ }
30
+ lines.push("");
31
+ }
32
+
33
+ // 3. Full file tree (for deriving sibling naming patterns)
34
+ if (context.fileStructure.length > 0) {
35
+ lines.push("-- Project File Tree (first 60 entries) --");
36
+ for (const f of context.fileStructure.slice(0, 60)) {
37
+ lines.push(` ${f}`);
38
+ }
39
+ lines.push("");
40
+ }
41
+
42
+ lines.push(
43
+ "REMINDER: If a needed file does not appear above and is NOT a new file, verify its path.\n" +
44
+ "For i18n/locale files, constants, enums, or route indexes — use EXACTLY the path shown above.\n"
45
+ );
46
+
47
+ return lines.join("\n");
48
+ }
49
+
50
+ export function buildTaskPrompt(spec: string, context?: ProjectContext): string {
51
+ if (!context) return spec;
52
+
53
+ const parts: string[] = [spec];
54
+
55
+ if (context.constitution) {
56
+ parts.push(`\n=== Project Constitution (rules to follow) ===\n${context.constitution}`);
57
+ }
58
+
59
+ if (context.techStack.length > 0) {
60
+ parts.push(`\n=== Tech Stack ===\n${context.techStack.join(", ")}`);
61
+ }
62
+
63
+ parts.push("\n" + buildVerifiedInventory(context));
64
+
65
+ return parts.join("\n");
66
+ }
67
+
68
+ export type TaskLayer = "data" | "infra" | "service" | "api" | "test";
69
+ export type TaskPriority = "high" | "medium" | "low";
70
+ export type TaskStatus = "pending" | "done" | "failed";
71
+
72
+ export interface SpecTask {
73
+ id: string;
74
+ title: string;
75
+ description: string;
76
+ layer: TaskLayer;
77
+ filesToTouch: string[];
78
+ acceptanceCriteria: string[];
79
+ dependencies: string[];
80
+ priority: TaskPriority;
81
+ /** Runtime checkpoint — set by code generator, persisted to tasks file */
82
+ status?: TaskStatus;
83
+ }
84
+
85
+ const LAYER_ORDER: Record<TaskLayer, number> = {
86
+ data: 0,
87
+ infra: 1,
88
+ service: 2,
89
+ api: 3,
90
+ test: 4,
91
+ };
92
+
93
+ export class TaskGenerator {
94
+ constructor(private provider: AIProvider) {}
95
+
96
+ async generateTasks(spec: string, context?: ProjectContext): Promise<SpecTask[]> {
97
+ const prompt = buildTaskPrompt(spec, context);
98
+ const raw = await this.provider.generate(prompt, tasksSystemPrompt);
99
+ return parseTasks(raw);
100
+ }
101
+
102
+ async saveTasks(tasks: SpecTask[], specFilePath: string): Promise<string> {
103
+ const dir = path.dirname(specFilePath);
104
+ const base = path.basename(specFilePath, ".md");
105
+ const tasksFile = path.join(dir, `${base}-tasks.json`);
106
+ await fs.writeJson(tasksFile, tasks, { spaces: 2 });
107
+ return tasksFile;
108
+ }
109
+
110
+ sortByLayer(tasks: SpecTask[]): SpecTask[] {
111
+ return [...tasks].sort((a, b) => {
112
+ const layerDiff = (LAYER_ORDER[a.layer] ?? 99) - (LAYER_ORDER[b.layer] ?? 99);
113
+ if (layerDiff !== 0) return layerDiff;
114
+ return a.id.localeCompare(b.id);
115
+ });
116
+ }
117
+ }
118
+
119
+ function parseTasks(raw: string): SpecTask[] {
120
+ // Try JSON code fence first
121
+ const fenced = raw.match(/```(?:json)?\n(\[[\s\S]*?\])\n```/);
122
+ const jsonStr = fenced ? fenced[1] : (raw.match(/\[[\s\S]*\]/)?.[0] ?? "");
123
+ try {
124
+ const parsed = JSON.parse(jsonStr);
125
+ if (Array.isArray(parsed)) return parsed as SpecTask[];
126
+ } catch {
127
+ // fall through
128
+ }
129
+ return [];
130
+ }
131
+
132
+ export function printTasks(tasks: SpecTask[]): void {
133
+ const layerColors: Record<TaskLayer, chalk.Chalk> = {
134
+ data: chalk.magenta,
135
+ infra: chalk.gray,
136
+ service: chalk.blue,
137
+ api: chalk.cyan,
138
+ test: chalk.green,
139
+ };
140
+
141
+ console.log(chalk.bold(`\n Tasks (${tasks.length}):`));
142
+ for (const task of tasks) {
143
+ const color = layerColors[task.layer] ?? chalk.white;
144
+ const badge = color(`[${task.layer}]`);
145
+ const prio = task.priority === "high" ? chalk.red("●") : task.priority === "medium" ? chalk.yellow("●") : chalk.gray("●");
146
+ console.log(` ${prio} ${chalk.bold(task.id)} ${badge} ${task.title}`);
147
+ }
148
+ }
149
+
150
+ export async function loadTasksForSpec(specFilePath: string): Promise<SpecTask[] | null> {
151
+ const base = path.basename(specFilePath, ".md");
152
+ const dir = path.dirname(specFilePath);
153
+ const tasksFile = path.join(dir, `${base}-tasks.json`);
154
+ if (await fs.pathExists(tasksFile)) {
155
+ return fs.readJson(tasksFile);
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /** Persist a single task's status to the tasks JSON file (checkpoint). */
161
+ export async function updateTaskStatus(
162
+ specFilePath: string,
163
+ taskId: string,
164
+ status: TaskStatus
165
+ ): Promise<void> {
166
+ const tasks = await loadTasksForSpec(specFilePath);
167
+ if (!tasks) return;
168
+ const task = tasks.find((t) => t.id === taskId);
169
+ if (!task) return;
170
+ task.status = status;
171
+ const base = path.basename(specFilePath, ".md");
172
+ const dir = path.dirname(specFilePath);
173
+ await fs.writeJson(path.join(dir, `${base}-tasks.json`), tasks, { spaces: 2 });
174
+ }