load-context.mjs

  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}