1/**
2 * Shared context loader for every impeccable command that needs to know
3 * "who is this for" and "what does this look like".
4 *
5 * Input: project root (process.cwd()).
6 *
7 * Output (JSON to stdout):
8 * {
9 * hasProduct: boolean, // PRODUCT.md found (or auto-migrated)
10 * product: string | null, // PRODUCT.md contents
11 * productPath: string | null, // relative path
12 * hasDesign: boolean, // DESIGN.md found
13 * design: string | null, // DESIGN.md contents
14 * designPath: string | null,
15 * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md
16 * contextDir: string, // absolute path of the directory the files were found in
17 * }
18 *
19 * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The
20 * Google DESIGN.md convention is uppercase at repo root; Kiro-style and
21 * lowercase variants are also matched so users don't get punished for case.
22 *
23 * Lookup directory resolution (first match wins):
24 * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd)
25 * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat)
26 * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/
27 * 4. cwd as a default "no context found" location
28 *
29 * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root;
30 * fallback directories are read-only as far as auto-rename is concerned.
31 */
32
33import fs from 'node:fs';
34import path from 'node:path';
35
36const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
37const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
38const LEGACY_NAMES = ['.impeccable.md'];
39const FALLBACK_DIRS = ['.agents/context', 'docs'];
40
41/**
42 * Resolve the directory that holds PRODUCT.md / DESIGN.md for
43 * this project. Exported so other scripts (e.g. live-server.mjs) can read the
44 * design files from the same location the loader uses.
45 */
46export function resolveContextDir(cwd = process.cwd()) {
47 // 1. Explicit override
48 const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
49 if (envDir && envDir.trim()) {
50 const trimmed = envDir.trim();
51 return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
52 }
53
54 // 2. cwd wins if any canonical or legacy file is there. We check legacy too
55 // so the auto-migration path in loadContext stays predictable.
56 if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) {
57 return cwd;
58 }
59
60 // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present;
61 // legacy `.impeccable.md` does not pull the lookup into a fallback dir.
62 for (const rel of FALLBACK_DIRS) {
63 const candidate = path.resolve(cwd, rel);
64 if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
65 return candidate;
66 }
67 }
68
69 // 4. Nothing found — keep the historical "default to cwd" behaviour so the
70 // caller's `hasProduct === false` branch still fires the same way.
71 return cwd;
72}
73
74export function loadContext(cwd = process.cwd()) {
75 let migrated = false;
76 const contextDir = resolveContextDir(cwd);
77
78 // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir
79 let productPath = firstExisting(contextDir, PRODUCT_NAMES);
80
81 // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename
82 // it in place. We only migrate at the root — fallback dirs are read-only
83 // so we don't surprise users by mutating files under docs/ or .agents/.
84 if (!productPath && contextDir === cwd) {
85 const legacyPath = firstExisting(cwd, LEGACY_NAMES);
86 if (legacyPath) {
87 const newPath = path.join(cwd, 'PRODUCT.md');
88 try {
89 fs.renameSync(legacyPath, newPath);
90 productPath = newPath;
91 migrated = true;
92 } catch {
93 // Rename failed (permissions, etc.) — fall back to reading legacy in place
94 productPath = legacyPath;
95 }
96 }
97 }
98
99 // 3. DESIGN.md (case-insensitive)
100 const designPath = firstExisting(contextDir, DESIGN_NAMES);
101
102 const product = productPath ? safeRead(productPath) : null;
103 const design = designPath ? safeRead(designPath) : null;
104
105 return {
106 hasProduct: !!product,
107 product,
108 productPath: productPath ? path.relative(cwd, productPath) : null,
109 hasDesign: !!design,
110 design,
111 designPath: designPath ? path.relative(cwd, designPath) : null,
112 migrated,
113 contextDir,
114 };
115}
116
117function firstExisting(dir, names) {
118 for (const name of names) {
119 const abs = path.join(dir, name);
120 if (fs.existsSync(abs)) return abs;
121 }
122 return null;
123}
124
125function safeRead(p) {
126 try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
127}
128
129// ---------------------------------------------------------------------------
130// CLI mode — print the context as JSON
131// ---------------------------------------------------------------------------
132
133function cli() {
134 const result = loadContext(process.cwd());
135 console.log(JSON.stringify(result, null, 2));
136}
137
138const _running = process.argv[1];
139if (_running?.endsWith('load-context.mjs') || _running?.endsWith('load-context.mjs/')) {
140 cli();
141}