ai-spec-dev 0.1.0 → 0.14.1

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 +1211 -146
  3. package/RELEASE_LOG.md +1444 -0
  4. package/cli/index.ts +1961 -0
  5. package/cli/welcome.ts +151 -0
  6. package/core/code-generator.ts +740 -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,602 @@
1
+ import * as fs from "fs-extra";
2
+ import * as path from "path";
3
+ import { glob } from "glob";
4
+
5
+ // ─── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ export type FrontendFramework = "react" | "next" | "vue" | "react-native" | "unknown";
8
+
9
+ export interface FrontendContext {
10
+ framework: FrontendFramework;
11
+ /** e.g. ['zustand', 'redux', 'jotai'] */
12
+ stateManagement: string[];
13
+ /** e.g. 'axios', 'fetch', 'swr', 'react-query' */
14
+ httpClient: string;
15
+ /** e.g. 'tailwind', 'antd', 'shadcn' */
16
+ uiLibrary: string;
17
+ /** e.g. 'react-router', 'next-app-router' */
18
+ routingPattern: string;
19
+ /** Whether tests use React Testing Library */
20
+ testFramework: "rtl" | "cypress" | "jest" | "vitest" | "unknown";
21
+ /** api/ or services/ file paths */
22
+ existingApiFiles: string[];
23
+ /** First 60 lines of up to 2 existing API wrapper files */
24
+ apiWrapperContent: string[];
25
+ /** Custom hook files (use*.ts/tsx) — relative paths */
26
+ hookFiles: string[];
27
+ /** First 30 lines of up to 2 hook files */
28
+ hookPatterns: string[];
29
+ /** State slice / store files — relative paths */
30
+ storeFiles: string[];
31
+ /** First 60 lines of up to 2 existing store files — shows the real store pattern */
32
+ storePatterns: string[];
33
+ /**
34
+ * The exact HTTP client import line found in an existing API file.
35
+ * e.g. "import request from '@/utils/http'"
36
+ * Extracted from real code — use this verbatim, never invent a different import path.
37
+ */
38
+ httpClientImport?: string;
39
+ /**
40
+ * Reusable components found in src/components/ — relative paths.
41
+ * AI must check this list before creating any new component.
42
+ */
43
+ reusableComponents: string[];
44
+ /**
45
+ * First 60 lines of up to 2 existing page/view files.
46
+ * Shows how UI library components and shared components are actually imported and used.
47
+ */
48
+ pageExamples: string[];
49
+ /** Sample component structures (first 40 lines of up to 3 components) */
50
+ componentPatterns: string[];
51
+ /**
52
+ * The exact layout component import line found in an existing route module.
53
+ * e.g. "const Layout = () => import('@/layout/index.vue')"
54
+ * Extracted from real code — must be copied verbatim when creating new route modules.
55
+ */
56
+ layoutImport?: string;
57
+ /**
58
+ * Full content of one existing route module file — use as a copy-paste template.
59
+ * Relative path + content.
60
+ */
61
+ routeModuleExample?: { path: string; content: string };
62
+ /**
63
+ * A real paginated API function extracted from the existing codebase.
64
+ * Shows the exact pagination parameter names (e.g. pageIndex/pageSize vs page/size)
65
+ * and how they are passed (POST body vs GET query params).
66
+ * COPY THIS PATTERN exactly for all new paginated list APIs.
67
+ */
68
+ paginationExample?: string;
69
+ }
70
+
71
+ // ─── Detection Maps ────────────────────────────────────────────────────────────
72
+
73
+ const STATE_MANAGEMENT_LIBS = [
74
+ "zustand",
75
+ "redux",
76
+ "@reduxjs/toolkit",
77
+ "jotai",
78
+ "recoil",
79
+ "mobx",
80
+ "mobx-react",
81
+ "valtio",
82
+ "pinia",
83
+ "vuex",
84
+ ];
85
+
86
+ const HTTP_CLIENT_LIBS: Array<[string, string]> = [
87
+ ["swr", "swr"],
88
+ ["@tanstack/react-query", "react-query"],
89
+ ["react-query", "react-query"],
90
+ ["axios", "axios"],
91
+ ["ky", "ky"],
92
+ ];
93
+
94
+ const UI_LIBRARY_LIBS: Array<[string, string]> = [
95
+ ["antd", "antd"],
96
+ ["@ant-design/pro-components", "antd-pro"],
97
+ ["@mui/material", "mui"],
98
+ ["@chakra-ui/react", "chakra-ui"],
99
+ ["shadcn-ui", "shadcn"],
100
+ ["@radix-ui/react-primitive", "radix-ui"],
101
+ ["element-plus", "element-plus"],
102
+ ["vant", "vant"],
103
+ ["tailwindcss", "tailwind"],
104
+ ["@tailwindcss/vite", "tailwind"],
105
+ ["react-native-paper", "react-native-paper"],
106
+ ];
107
+
108
+ const ROUTING_LIBS: Array<[string, string]> = [
109
+ ["react-router-dom", "react-router"],
110
+ ["react-router", "react-router"],
111
+ ["@tanstack/react-router", "tanstack-router"],
112
+ ["react-navigation", "react-navigation"],
113
+ ["expo-router", "expo-router"],
114
+ ["vue-router", "vue-router"],
115
+ ];
116
+
117
+ // ─── Main function ─────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Load frontend-specific project context (framework, state mgmt, HTTP client, etc.)
121
+ * Never throws — returns partial results on failure.
122
+ */
123
+ export async function loadFrontendContext(
124
+ projectRoot: string
125
+ ): Promise<FrontendContext> {
126
+ const ctx: FrontendContext = {
127
+ framework: "unknown",
128
+ stateManagement: [],
129
+ httpClient: "fetch",
130
+ uiLibrary: "unknown",
131
+ routingPattern: "unknown",
132
+ testFramework: "unknown",
133
+ existingApiFiles: [],
134
+ apiWrapperContent: [],
135
+ hookFiles: [],
136
+ hookPatterns: [],
137
+ storeFiles: [],
138
+ storePatterns: [],
139
+ reusableComponents: [],
140
+ pageExamples: [],
141
+ componentPatterns: [],
142
+ };
143
+
144
+ try {
145
+ const pkgPath = path.join(projectRoot, "package.json");
146
+ if (!(await fs.pathExists(pkgPath))) return ctx;
147
+
148
+ const pkg = await fs.readJson(pkgPath);
149
+ const allDeps: Record<string, string> = {
150
+ ...(pkg.dependencies ?? {}),
151
+ ...(pkg.devDependencies ?? {}),
152
+ };
153
+ const depKeys = Object.keys(allDeps);
154
+ const has = (name: string) => depKeys.includes(name);
155
+
156
+ // Framework
157
+ if (has("react-native") || has("expo")) {
158
+ ctx.framework = "react-native";
159
+ } else if (has("next")) {
160
+ ctx.framework = "next";
161
+ } else if (has("react")) {
162
+ ctx.framework = "react";
163
+ } else if (has("vue")) {
164
+ ctx.framework = "vue";
165
+ }
166
+
167
+ // State management (may have multiple)
168
+ ctx.stateManagement = STATE_MANAGEMENT_LIBS.filter((lib) => has(lib));
169
+
170
+ // HTTP client (first match wins)
171
+ for (const [lib, label] of HTTP_CLIENT_LIBS) {
172
+ if (has(lib)) {
173
+ ctx.httpClient = label;
174
+ break;
175
+ }
176
+ }
177
+
178
+ // UI library (first match wins)
179
+ for (const [lib, label] of UI_LIBRARY_LIBS) {
180
+ if (has(lib)) {
181
+ ctx.uiLibrary = label;
182
+ break;
183
+ }
184
+ }
185
+ if (ctx.uiLibrary === "unknown") {
186
+ ctx.uiLibrary = "none";
187
+ }
188
+
189
+ // Routing
190
+ if (ctx.framework === "next") {
191
+ // Detect app router vs pages router
192
+ const hasAppDir = await fs.pathExists(path.join(projectRoot, "app"));
193
+ ctx.routingPattern = hasAppDir ? "next-app-router" : "next-pages-router";
194
+ } else {
195
+ for (const [lib, label] of ROUTING_LIBS) {
196
+ if (has(lib)) {
197
+ ctx.routingPattern = label;
198
+ break;
199
+ }
200
+ }
201
+ }
202
+
203
+ // Test framework detection
204
+ if (depKeys.includes("@testing-library/react") || depKeys.includes("@testing-library/vue")) {
205
+ ctx.testFramework = "rtl";
206
+ } else if (depKeys.includes("cypress")) {
207
+ ctx.testFramework = "cypress";
208
+ } else if (depKeys.includes("vitest")) {
209
+ ctx.testFramework = "vitest";
210
+ } else if (depKeys.includes("jest") || depKeys.includes("@jest/core")) {
211
+ ctx.testFramework = "jest";
212
+ }
213
+
214
+ // Existing API files
215
+ const apiFilePatterns = [
216
+ "src/api/**/*.{ts,js}",
217
+ "src/apis/**/*.{ts,js}",
218
+ "src/services/**/*.{ts,js}",
219
+ "src/lib/api/**/*.{ts,js}",
220
+ "src/utils/api/**/*.{ts,js}",
221
+ "api/**/*.{ts,js}",
222
+ "services/**/*.{ts,js}",
223
+ ];
224
+ for (const pattern of apiFilePatterns) {
225
+ const files = await glob(pattern, {
226
+ cwd: projectRoot,
227
+ ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
228
+ });
229
+ ctx.existingApiFiles.push(...files);
230
+ }
231
+ ctx.existingApiFiles = [...new Set(ctx.existingApiFiles)].slice(0, 20);
232
+
233
+ // API wrapper content preview — first 60 lines of up to 2 files
234
+ for (const relPath of ctx.existingApiFiles.slice(0, 2)) {
235
+ try {
236
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
237
+ const preview = content.split("\n").slice(0, 60).join("\n");
238
+ ctx.apiWrapperContent.push(`// ${relPath}\n${preview}`);
239
+ } catch {
240
+ // skip
241
+ }
242
+ }
243
+
244
+ // Hook files — use*.ts/tsx
245
+ const hookPatterns = [
246
+ "src/hooks/use*.{ts,tsx}",
247
+ "src/**/hooks/use*.{ts,tsx}",
248
+ "hooks/use*.{ts,tsx}",
249
+ ];
250
+ for (const pattern of hookPatterns) {
251
+ const files = await glob(pattern, {
252
+ cwd: projectRoot,
253
+ ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
254
+ });
255
+ ctx.hookFiles.push(...files);
256
+ }
257
+ ctx.hookFiles = [...new Set(ctx.hookFiles)].slice(0, 15);
258
+
259
+ // Hook content preview — first 30 lines of up to 2 hook files
260
+ for (const relPath of ctx.hookFiles.slice(0, 2)) {
261
+ try {
262
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
263
+ const preview = content.split("\n").slice(0, 30).join("\n");
264
+ ctx.hookPatterns.push(`// ${relPath}\n${preview}`);
265
+ } catch {
266
+ // skip
267
+ }
268
+ }
269
+
270
+ // Store / slice files (Redux slices, Zustand stores, Pinia stores)
271
+ const storeFilePatterns = [
272
+ "src/store/**/*.{ts,js}",
273
+ "src/stores/**/*.{ts,js}",
274
+ "src/**/slice*.{ts,js}",
275
+ "src/**/*slice.{ts,js}",
276
+ "src/**/*store.{ts,js}",
277
+ "src/**/*Store.{ts,js}",
278
+ "store/**/*.{ts,js}",
279
+ "stores/**/*.{ts,js}",
280
+ ];
281
+ for (const pattern of storeFilePatterns) {
282
+ const files = await glob(pattern, {
283
+ cwd: projectRoot,
284
+ ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
285
+ });
286
+ ctx.storeFiles.push(...files);
287
+ }
288
+ ctx.storeFiles = [...new Set(ctx.storeFiles)].slice(0, 10);
289
+
290
+ // Store content preview — first 60 lines of up to 2 store files
291
+ // (shows the AI what stores actually do: state + actions that call API layer, NOT HTTP directly)
292
+ for (const relPath of ctx.storeFiles.slice(0, 2)) {
293
+ try {
294
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
295
+ const preview = content.split("\n").slice(0, 60).join("\n");
296
+ ctx.storePatterns.push(`// ${relPath}\n${preview}`);
297
+ } catch {
298
+ // skip
299
+ }
300
+ }
301
+
302
+ // Extract the exact HTTP client import line from an existing API file
303
+ // e.g. "import request from '@/utils/http'" or "import axios from 'axios'"
304
+ // This is ground truth — prevents the AI from inventing a different import path.
305
+ const httpImportRegex = /^import\s+(?:\w+|\{[^}]+\})\s+from\s+['"](@\/[^'"]+|axios|ky)['"]/m;
306
+ for (const relPath of ctx.existingApiFiles.slice(0, 5)) {
307
+ try {
308
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
309
+ const match = content.match(httpImportRegex);
310
+ if (match) {
311
+ ctx.httpClientImport = match[0].trim();
312
+ break;
313
+ }
314
+ } catch {
315
+ // skip
316
+ }
317
+ }
318
+
319
+ // Pagination pattern extraction — find a real paginated list function from the existing codebase.
320
+ // Scans API files for interfaces/params that contain pagination field names,
321
+ // then extracts the matching function as ground truth for the AI to copy.
322
+ const paginationFieldNames = ["pageIndex", "pageSize", "pageNum", "current", "page", "size", "offset", "limit"];
323
+ const paginationInterfaceRegex = new RegExp(
324
+ `(?:interface|type)\\s+(\\w*(?:Params|Query|Request|Filter|Page)\\w*)\\s*\\{[^}]*\\b(?:${paginationFieldNames.join("|")})\\b[^}]*\\}`,
325
+ "s"
326
+ );
327
+ const apiExportFnRegex = /export\s+(?:async\s+)?function\s+\w+\s*\([^)]*\)[^{]*\{[\s\S]*?\n\}/g;
328
+ for (const relPath of ctx.existingApiFiles) {
329
+ // Skip type-only and index files
330
+ if (/types?\.ts$|index\.ts$/.test(relPath)) continue;
331
+ try {
332
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
333
+ // Only process files that have pagination field names at all
334
+ if (!paginationFieldNames.some((f) => content.includes(f))) continue;
335
+ // Check this file has an interface with pagination fields
336
+ const interfaceMatch = content.match(paginationInterfaceRegex);
337
+ if (!interfaceMatch) continue;
338
+ // Find the first exported function that uses this interface
339
+ const interfaceName = interfaceMatch[1];
340
+ const fnRegex = new RegExp(
341
+ `export\\s+(?:async\\s+)?function\\s+\\w+\\s*\\(\\s*\\w+\\s*:\\s*${interfaceName}[^)]*\\)[\\s\\S]*?\\n\\}`,
342
+ ""
343
+ );
344
+ const fnMatch = content.match(fnRegex);
345
+ if (fnMatch) {
346
+ ctx.paginationExample = `// From ${relPath}\n${interfaceMatch[0]}\n\n${fnMatch[0]}`;
347
+ break;
348
+ }
349
+ } catch {
350
+ // skip
351
+ }
352
+ }
353
+
354
+ // Reusable components — scan src/components/ (the shared component library)
355
+ const sharedComponentDirs = ["src/components", "components"];
356
+ for (const dir of sharedComponentDirs) {
357
+ const absDir = path.join(projectRoot, dir);
358
+ if (!(await fs.pathExists(absDir))) continue;
359
+ const files = await glob("**/*.{vue,tsx,jsx}", {
360
+ cwd: absDir,
361
+ ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
362
+ maxDepth: 4,
363
+ });
364
+ ctx.reusableComponents.push(...files.map((f) => path.join(dir, f)));
365
+ }
366
+ ctx.reusableComponents = [...new Set(ctx.reusableComponents)].slice(0, 40);
367
+
368
+ // Page examples — read 2 existing view/page files to show component usage patterns
369
+ const viewDirs = ["src/views", "src/pages", "views", "pages"];
370
+ const viewFiles: string[] = [];
371
+ for (const dir of viewDirs) {
372
+ const absDir = path.join(projectRoot, dir);
373
+ if (!(await fs.pathExists(absDir))) continue;
374
+ const files = await glob("**/*.{vue,tsx,jsx}", {
375
+ cwd: absDir,
376
+ ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
377
+ maxDepth: 3,
378
+ });
379
+ viewFiles.push(...files.map((f) => path.join(dir, f)));
380
+ if (viewFiles.length >= 6) break;
381
+ }
382
+ for (const relPath of viewFiles.slice(0, 2)) {
383
+ try {
384
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
385
+ // Read up to 80 lines — enough to see the template section with component usage
386
+ const preview = content.split("\n").slice(0, 80).join("\n");
387
+ ctx.pageExamples.push(`// ${relPath}\n${preview}`);
388
+ } catch {
389
+ // skip
390
+ }
391
+ }
392
+
393
+ // Component patterns — sample a few component files (generic structure reference)
394
+ const componentFiles: string[] = [];
395
+ for (const dir of sharedComponentDirs) {
396
+ const absDir = path.join(projectRoot, dir);
397
+ if (!(await fs.pathExists(absDir))) continue;
398
+ const files = await glob("**/*.{tsx,vue,jsx}", {
399
+ cwd: absDir,
400
+ ignore: ["**/*.test.*", "**/*.spec.*", "node_modules/**"],
401
+ maxDepth: 2,
402
+ });
403
+ componentFiles.push(...files.map((f) => path.join(dir, f)));
404
+ if (componentFiles.length >= 5) break;
405
+ }
406
+
407
+ // Read first 40 lines of up to 3 component files
408
+ for (const relPath of componentFiles.slice(0, 3)) {
409
+ try {
410
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
411
+ const preview = content.split("\n").slice(0, 40).join("\n");
412
+ ctx.componentPatterns.push(`// ${relPath}\n${preview}`);
413
+ } catch {
414
+ // skip unreadable files
415
+ }
416
+ }
417
+ // Route module example + layout import extraction
418
+ await extractRouteModuleContext(projectRoot, ctx);
419
+
420
+ } catch {
421
+ // Graceful degradation — return whatever we've collected so far
422
+ }
423
+
424
+ return ctx;
425
+ }
426
+
427
+ /**
428
+ * Scan existing router module files to extract:
429
+ * 1. The exact layout component import line (ground truth, not guessed)
430
+ * 2. A full route module file as a copy-paste template
431
+ */
432
+ async function extractRouteModuleContext(
433
+ projectRoot: string,
434
+ ctx: FrontendContext
435
+ ): Promise<void> {
436
+ const modulePatterns = [
437
+ "src/router/modules/**/*.{ts,js}",
438
+ "src/routes/modules/**/*.{ts,js}",
439
+ "src/router/**/*.{ts,js}",
440
+ ];
441
+
442
+ const moduleFiles: string[] = [];
443
+ for (const pattern of modulePatterns) {
444
+ const files = await glob(pattern, {
445
+ cwd: projectRoot,
446
+ ignore: ["**/index.{ts,js}", "node_modules/**", "**/*.test.*"],
447
+ });
448
+ moduleFiles.push(...files);
449
+ }
450
+
451
+ if (moduleFiles.length === 0) return;
452
+
453
+ // Layout import regex — matches common patterns:
454
+ // const Layout = () => import('@/layout/index.vue')
455
+ // import Layout from '@/layouts/default/index.vue'
456
+ // const Layout = defineAsyncComponent(() => import('@/layout/index.vue'))
457
+ const layoutImportRegex =
458
+ /^(?:const\s+Layout\s*=.*import\(['"][^'"]+['"]\)|import\s+Layout\s+from\s+['"][^'"]+['"])/m;
459
+
460
+ for (const relPath of moduleFiles) {
461
+ try {
462
+ const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
463
+ const match = content.match(layoutImportRegex);
464
+ if (match) {
465
+ ctx.layoutImport = match[0].trim();
466
+ // Use this file as the route module template (cap at 100 lines)
467
+ const preview = content.split("\n").slice(0, 100).join("\n");
468
+ ctx.routeModuleExample = { path: relPath, content: preview };
469
+ break; // first match is enough
470
+ }
471
+ } catch {
472
+ // skip
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Build a concise context section from FrontendContext for prompt injection.
479
+ */
480
+ export function buildFrontendContextSection(ctx: FrontendContext): string {
481
+ const lines: string[] = [
482
+ "=== Frontend Project Context ===",
483
+ `Framework : ${ctx.framework}`,
484
+ `State Management : ${ctx.stateManagement.join(", ") || "none detected"}`,
485
+ `HTTP Client : ${ctx.httpClient}`,
486
+ `UI Library : ${ctx.uiLibrary}`,
487
+ `Routing : ${ctx.routingPattern}`,
488
+ `Test Framework : ${ctx.testFramework}`,
489
+ ];
490
+
491
+ // Layout import — most critical for correct route module generation
492
+ if (ctx.layoutImport) {
493
+ lines.push(
494
+ `\nLayout component import (COPY THIS EXACTLY in every new route module — do NOT invent a different path):`,
495
+ ` ${ctx.layoutImport}`
496
+ );
497
+ }
498
+
499
+ // Route module template — shows exact file structure to replicate
500
+ if (ctx.routeModuleExample) {
501
+ lines.push(
502
+ `\nExisting route module template (${ctx.routeModuleExample.path}) — use this as the structural template for new route modules:`,
503
+ "```",
504
+ ctx.routeModuleExample.content,
505
+ "```"
506
+ );
507
+ }
508
+
509
+ if (ctx.existingApiFiles.length > 0) {
510
+ lines.push(`\nExisting API/service files (${ctx.existingApiFiles.length}):`);
511
+ ctx.existingApiFiles.slice(0, 10).forEach((f) => lines.push(` - ${f}`));
512
+ }
513
+
514
+ // HTTP client import — must be copied verbatim
515
+ if (ctx.httpClientImport) {
516
+ lines.push(
517
+ `\nHTTP client import (COPY THIS EXACTLY in every new API file — do NOT import from any other path):`,
518
+ ` ${ctx.httpClientImport}`
519
+ );
520
+ }
521
+
522
+ // Pagination example — the most critical ground truth for list APIs
523
+ if (ctx.paginationExample) {
524
+ lines.push(
525
+ `\nPagination pattern (COPY THIS EXACTLY for all paginated list APIs — use IDENTICAL parameter names, HTTP method, and call style):`,
526
+ "```typescript",
527
+ ctx.paginationExample,
528
+ "```"
529
+ );
530
+ }
531
+
532
+ if (ctx.apiWrapperContent.length > 0) {
533
+ lines.push(`\nAPI file patterns (new API functions must follow this exact structure):`);
534
+ ctx.apiWrapperContent.forEach((p) => {
535
+ lines.push("```");
536
+ lines.push(p);
537
+ lines.push("```");
538
+ });
539
+ }
540
+
541
+ if (ctx.hookFiles.length > 0) {
542
+ lines.push(`\nExisting custom hooks (${ctx.hookFiles.length}):`);
543
+ ctx.hookFiles.slice(0, 8).forEach((f) => lines.push(` - ${f}`));
544
+ }
545
+
546
+ if (ctx.hookPatterns.length > 0) {
547
+ lines.push(`\nHook patterns (follow same structure):`);
548
+ ctx.hookPatterns.forEach((p) => {
549
+ lines.push("```");
550
+ lines.push(p);
551
+ lines.push("```");
552
+ });
553
+ }
554
+
555
+ if (ctx.storeFiles.length > 0) {
556
+ lines.push(`\nState store files (${ctx.storeFiles.length}):`);
557
+ ctx.storeFiles.slice(0, 8).forEach((f) => lines.push(` - ${f}`));
558
+ }
559
+
560
+ if (ctx.storePatterns.length > 0) {
561
+ lines.push(
562
+ `\nExisting store patterns (CRITICAL — stores in this project call API layer functions, they do NOT make HTTP requests directly):`,
563
+ `Follow this exact structure for new stores:`
564
+ );
565
+ ctx.storePatterns.forEach((p) => {
566
+ lines.push("```");
567
+ lines.push(p);
568
+ lines.push("```");
569
+ });
570
+ }
571
+
572
+ if (ctx.reusableComponents.length > 0) {
573
+ lines.push(
574
+ `\nExisting reusable components in src/components/ (${ctx.reusableComponents.length} files):`,
575
+ `ALWAYS check this list before creating a new component. Import and reuse existing ones instead of reinventing.`
576
+ );
577
+ ctx.reusableComponents.forEach((f) => lines.push(` - ${f}`));
578
+ }
579
+
580
+ if (ctx.pageExamples.length > 0) {
581
+ lines.push(
582
+ `\nExisting page examples (shows which UI library components and shared components are used — follow the same import and usage patterns):`
583
+ );
584
+ ctx.pageExamples.forEach((p) => {
585
+ lines.push("```");
586
+ lines.push(p);
587
+ lines.push("```");
588
+ });
589
+ }
590
+
591
+ if (ctx.componentPatterns.length > 0) {
592
+ lines.push(`\nShared component structure patterns:`);
593
+ ctx.componentPatterns.forEach((p) => {
594
+ lines.push("```");
595
+ lines.push(p.slice(0, 500));
596
+ lines.push("```");
597
+ });
598
+ }
599
+
600
+ lines.push("=== End of Frontend Context ===");
601
+ return lines.join("\n");
602
+ }
@@ -0,0 +1,88 @@
1
+ import * as fs from "fs-extra";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+
5
+ // ─── Constants ────────────────────────────────────────────────────────────────
6
+
7
+ export const GLOBAL_CONSTITUTION_FILE = ".ai-spec-global-constitution.md";
8
+
9
+ /**
10
+ * Search order for global constitution:
11
+ * 1. Workspace root (for monorepo-level shared rules)
12
+ * 2. User home directory (for personal cross-project rules)
13
+ */
14
+ const SEARCH_ROOTS = [
15
+ // Workspace root is injected at runtime — see loadGlobalConstitution()
16
+ os.homedir(),
17
+ ];
18
+
19
+ // ─── Load ─────────────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Search for a global constitution file.
23
+ * @param extraRoots Additional directories to check first (e.g. workspace root).
24
+ * Returns the content string, or null if not found anywhere.
25
+ */
26
+ export async function loadGlobalConstitution(
27
+ extraRoots: string[] = []
28
+ ): Promise<{ content: string; source: string } | null> {
29
+ const roots = [...extraRoots, ...SEARCH_ROOTS];
30
+
31
+ for (const root of roots) {
32
+ const filePath = path.join(root, GLOBAL_CONSTITUTION_FILE);
33
+ if (await fs.pathExists(filePath)) {
34
+ const content = await fs.readFile(filePath, "utf-8");
35
+ return { content, source: filePath };
36
+ }
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ // ─── Merge ────────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Merge global and project constitutions.
46
+ *
47
+ * Injection order (from lowest to highest priority):
48
+ * 1. Global constitution — team/personal baseline rules
49
+ * 2. Project constitution — project-specific overrides (wins on conflict)
50
+ *
51
+ * The merged text is what gets injected into Spec/codegen prompts.
52
+ */
53
+ export function mergeConstitutions(
54
+ globalContent: string,
55
+ projectContent: string | undefined
56
+ ): string {
57
+ const parts: string[] = [
58
+ "<!-- BEGIN GLOBAL CONSTITUTION (team baseline — lower priority) -->",
59
+ globalContent.trim(),
60
+ "<!-- END GLOBAL CONSTITUTION -->",
61
+ ];
62
+
63
+ if (projectContent && projectContent.trim()) {
64
+ parts.push(
65
+ "",
66
+ "<!-- BEGIN PROJECT CONSTITUTION (project-specific — HIGHER priority, overrides global) -->",
67
+ projectContent.trim(),
68
+ "<!-- END PROJECT CONSTITUTION -->"
69
+ );
70
+ }
71
+
72
+ return parts.join("\n");
73
+ }
74
+
75
+ // ─── Save ─────────────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Write a global constitution to disk.
79
+ * @param targetDir Directory to write to. Defaults to user home directory.
80
+ */
81
+ export async function saveGlobalConstitution(
82
+ content: string,
83
+ targetDir: string = os.homedir()
84
+ ): Promise<string> {
85
+ const filePath = path.join(targetDir, GLOBAL_CONSTITUTION_FILE);
86
+ await fs.writeFile(filePath, content, "utf-8");
87
+ return filePath;
88
+ }