| 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 | |
| 33 | import fs from 'node:fs'; |
| 34 | import path from 'node:path'; |
| 35 | |
| 36 | const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; |
| 37 | const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; |
| 38 | const LEGACY_NAMES = ['.impeccable.md']; |
| 39 | const 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 | */ |
| 46 | export 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 | |
| 74 | export 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 | |
| 117 | function 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 | |
| 125 | function 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 | |
| 133 | function cli() { |
| 134 | const result = loadContext(process.cwd()); |
| 135 | console.log(JSON.stringify(result, null, 2)); |
| 136 | } |
| 137 | |
| 138 | const _running = process.argv[1]; |
| 139 | if (_running?.endsWith('load-context.mjs') || _running?.endsWith('load-context.mjs/')) { |
| 140 | cli(); |
| 141 | } |
| 142 |