diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index f5045658b..e48b98b79 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -30,7 +30,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -41,7 +41,7 @@ Load both in one call: node .agents/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `$impeccable teach` or `$impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.agents/skills/impeccable/scripts/load-context.mjs b/.agents/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.agents/skills/impeccable/scripts/load-context.mjs +++ b/.agents/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.claude/skills/impeccable/SKILL.md b/.claude/skills/impeccable/SKILL.md index 99d653a76..b55eab544 100644 --- a/.claude/skills/impeccable/SKILL.md +++ b/.claude/skills/impeccable/SKILL.md @@ -36,7 +36,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -47,7 +47,7 @@ Load both in one call: node .claude/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.claude/skills/impeccable/scripts/load-context.mjs b/.claude/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.claude/skills/impeccable/scripts/load-context.mjs +++ b/.claude/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.cursor/skills/impeccable/SKILL.md b/.cursor/skills/impeccable/SKILL.md index 36657cd6b..66899a7f1 100644 --- a/.cursor/skills/impeccable/SKILL.md +++ b/.cursor/skills/impeccable/SKILL.md @@ -32,7 +32,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -43,7 +43,7 @@ Load both in one call: node .cursor/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.cursor/skills/impeccable/scripts/load-context.mjs b/.cursor/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.cursor/skills/impeccable/scripts/load-context.mjs +++ b/.cursor/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.gemini/skills/impeccable/SKILL.md b/.gemini/skills/impeccable/SKILL.md index 452b28f36..40020bd77 100644 --- a/.gemini/skills/impeccable/SKILL.md +++ b/.gemini/skills/impeccable/SKILL.md @@ -31,7 +31,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -42,7 +42,7 @@ Load both in one call: node .gemini/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.gemini/skills/impeccable/scripts/load-context.mjs b/.gemini/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.gemini/skills/impeccable/scripts/load-context.mjs +++ b/.gemini/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.github/skills/impeccable/SKILL.md b/.github/skills/impeccable/SKILL.md index 2ebd5d375..5722bdd9e 100644 --- a/.github/skills/impeccable/SKILL.md +++ b/.github/skills/impeccable/SKILL.md @@ -34,7 +34,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -45,7 +45,7 @@ Load both in one call: node .github/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.github/skills/impeccable/scripts/load-context.mjs b/.github/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.github/skills/impeccable/scripts/load-context.mjs +++ b/.github/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.kiro/skills/impeccable/SKILL.md b/.kiro/skills/impeccable/SKILL.md index 94c63efd7..3767a25d2 100644 --- a/.kiro/skills/impeccable/SKILL.md +++ b/.kiro/skills/impeccable/SKILL.md @@ -32,7 +32,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -43,7 +43,7 @@ Load both in one call: node .kiro/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.kiro/skills/impeccable/scripts/load-context.mjs b/.kiro/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.kiro/skills/impeccable/scripts/load-context.mjs +++ b/.kiro/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.opencode/skills/impeccable/SKILL.md b/.opencode/skills/impeccable/SKILL.md index 0eaf83734..6d02ad961 100644 --- a/.opencode/skills/impeccable/SKILL.md +++ b/.opencode/skills/impeccable/SKILL.md @@ -36,7 +36,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -47,7 +47,7 @@ Load both in one call: node .opencode/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.opencode/skills/impeccable/scripts/load-context.mjs b/.opencode/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.opencode/skills/impeccable/scripts/load-context.mjs +++ b/.opencode/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.pi/skills/impeccable/SKILL.md b/.pi/skills/impeccable/SKILL.md index aa640b4b2..f62375281 100644 --- a/.pi/skills/impeccable/SKILL.md +++ b/.pi/skills/impeccable/SKILL.md @@ -34,7 +34,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -45,7 +45,7 @@ Load both in one call: node .pi/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.pi/skills/impeccable/scripts/load-context.mjs b/.pi/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.pi/skills/impeccable/scripts/load-context.mjs +++ b/.pi/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.qoder/skills/impeccable/SKILL.md b/.qoder/skills/impeccable/SKILL.md index 4f0fb18c1..45a0da2bb 100644 --- a/.qoder/skills/impeccable/SKILL.md +++ b/.qoder/skills/impeccable/SKILL.md @@ -36,7 +36,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -47,7 +47,7 @@ Load both in one call: node .qoder/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.qoder/skills/impeccable/scripts/load-context.mjs b/.qoder/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.qoder/skills/impeccable/scripts/load-context.mjs +++ b/.qoder/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.rovodev/skills/impeccable/SKILL.md b/.rovodev/skills/impeccable/SKILL.md index 8b65fa38b..7097ca176 100644 --- a/.rovodev/skills/impeccable/SKILL.md +++ b/.rovodev/skills/impeccable/SKILL.md @@ -36,7 +36,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -47,7 +47,7 @@ Load both in one call: node .rovodev/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.rovodev/skills/impeccable/scripts/load-context.mjs b/.rovodev/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.rovodev/skills/impeccable/scripts/load-context.mjs +++ b/.rovodev/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.trae-cn/skills/impeccable/SKILL.md b/.trae-cn/skills/impeccable/SKILL.md index 19e38aa6e..405389a70 100644 --- a/.trae-cn/skills/impeccable/SKILL.md +++ b/.trae-cn/skills/impeccable/SKILL.md @@ -34,7 +34,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -45,7 +45,7 @@ Load both in one call: node .trae-cn/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.trae-cn/skills/impeccable/scripts/load-context.mjs b/.trae-cn/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.trae-cn/skills/impeccable/scripts/load-context.mjs +++ b/.trae-cn/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/.trae/skills/impeccable/SKILL.md b/.trae/skills/impeccable/SKILL.md index 82ac68e7b..45382c962 100644 --- a/.trae/skills/impeccable/SKILL.md +++ b/.trae/skills/impeccable/SKILL.md @@ -34,7 +34,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -45,7 +45,7 @@ Load both in one call: node .trae/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.trae/skills/impeccable/scripts/load-context.mjs b/.trae/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/.trae/skills/impeccable/scripts/load-context.mjs +++ b/.trae/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/plugin/skills/impeccable/SKILL.md b/plugin/skills/impeccable/SKILL.md index 99d653a76..b55eab544 100644 --- a/plugin/skills/impeccable/SKILL.md +++ b/plugin/skills/impeccable/SKILL.md @@ -36,7 +36,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -47,7 +47,7 @@ Load both in one call: node .claude/skills/impeccable/scripts/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/plugin/skills/impeccable/scripts/load-context.mjs b/plugin/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/plugin/skills/impeccable/scripts/load-context.mjs +++ b/plugin/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/source/skills/impeccable/SKILL.md b/source/skills/impeccable/SKILL.md index 9828f73bb..28c5f1d56 100644 --- a/source/skills/impeccable/SKILL.md +++ b/source/skills/impeccable/SKILL.md @@ -35,7 +35,7 @@ Other harnesses should follow the same checklist when they can expose this state ### 1. Context gathering -Two files at the project root, case-insensitive: +Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd). - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. @@ -46,7 +46,7 @@ Load both in one call: node {{scripts_path}}/load-context.mjs ``` -Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from. If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `{{command_prefix}}impeccable teach` or `{{command_prefix}}impeccable document` (they rewrite the files), or the user manually edited one. diff --git a/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs index f36ad28d2..90614fbf7 100644 --- a/source/skills/impeccable/scripts/live-server.mjs +++ b/source/skills/impeccable/scripts/live-server.mjs @@ -21,11 +21,16 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; +import { resolveContextDir } from './load-context.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PID file in the project root so both the server and agent can find it // predictably (os.tmpdir() varies across platforms). const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. +// Keeps live-server in sync with the loader when users keep the docs in +// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -113,7 +118,7 @@ function hasProjectContext() { // concern, surfaced by the design panel's own empty state. Legacy // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } } @@ -325,8 +330,8 @@ function createRequestHandler({ detectScript, livePath }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(process.cwd(), 'DESIGN.md'); - const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); + const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/source/skills/impeccable/scripts/load-context.mjs b/source/skills/impeccable/scripts/load-context.mjs index ab3be08f1..dca23c1f8 100644 --- a/source/skills/impeccable/scripts/load-context.mjs +++ b/source/skills/impeccable/scripts/load-context.mjs @@ -13,11 +13,21 @@ * design: string | null, // DESIGN.md contents * designPath: string | null, * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md + * contextDir: string, // absolute path of the directory the files were found in * } * * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The * Google DESIGN.md convention is uppercase at repo root; Kiro-style and * lowercase variants are also matched so users don't get punished for case. + * + * Lookup directory resolution (first match wins): + * 1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd) + * 2. cwd, if PRODUCT.md / DESIGN.md / .impeccable.md is there (back-compat) + * 3. Auto-fallback subdirectories of cwd: .agents/context/, then docs/ + * 4. cwd as a default "no context found" location + * + * Legacy `.impeccable.md` -> PRODUCT.md migration only fires at cwd root; + * fallback directories are read-only as far as auto-rename is concerned. */ import fs from 'node:fs'; @@ -26,15 +36,52 @@ import path from 'node:path'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const LEGACY_NAMES = ['.impeccable.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; + +/** + * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * this project. Exported so other scripts (e.g. live-server.mjs) can read the + * design files from the same location the loader uses. + */ +export function resolveContextDir(cwd = process.cwd()) { + // 1. Explicit override + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (envDir && envDir.trim()) { + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); + } + + // 2. cwd wins if any canonical or legacy file is there. We check legacy too + // so the auto-migration path in loadContext stays predictable. + if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES, ...LEGACY_NAMES])) { + return cwd; + } + + // 3. Auto-fallback subdirs. Match if PRODUCT.md or DESIGN.md is present; + // legacy `.impeccable.md` does not pull the lookup into a fallback dir. + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(cwd, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + + // 4. Nothing found — keep the historical "default to cwd" behaviour so the + // caller's `hasProduct === false` branch still fires the same way. + return cwd; +} export function loadContext(cwd = process.cwd()) { let migrated = false; + const contextDir = resolveContextDir(cwd); - // 1. Look for PRODUCT.md (case-insensitive) - let productPath = firstExisting(cwd, PRODUCT_NAMES); + // 1. Look for PRODUCT.md (case-insensitive) in the resolved dir + let productPath = firstExisting(contextDir, PRODUCT_NAMES); - // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place - if (!productPath) { + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists at cwd root, rename + // it in place. We only migrate at the root — fallback dirs are read-only + // so we don't surprise users by mutating files under docs/ or .agents/. + if (!productPath && contextDir === cwd) { const legacyPath = firstExisting(cwd, LEGACY_NAMES); if (legacyPath) { const newPath = path.join(cwd, 'PRODUCT.md'); @@ -50,7 +97,7 @@ export function loadContext(cwd = process.cwd()) { } // 3. DESIGN.md (case-insensitive) - const designPath = firstExisting(cwd, DESIGN_NAMES); + const designPath = firstExisting(contextDir, DESIGN_NAMES); const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; @@ -63,12 +110,13 @@ export function loadContext(cwd = process.cwd()) { design, designPath: designPath ? path.relative(cwd, designPath) : null, migrated, + contextDir, }; } -function firstExisting(cwd, names) { +function firstExisting(dir, names) { for (const name of names) { - const abs = path.join(cwd, name); + const abs = path.join(dir, name); if (fs.existsSync(abs)) return abs; } return null; diff --git a/tests/load-context.test.mjs b/tests/load-context.test.mjs new file mode 100644 index 000000000..1bae7f192 --- /dev/null +++ b/tests/load-context.test.mjs @@ -0,0 +1,197 @@ +/** + * Tests for the shared context loader (PRODUCT.md / DESIGN.md resolver). + * Run with: node --test tests/load-context.test.mjs + * + * Covers the resolution order added for issue #119: + * 1. IMPECCABLE_CONTEXT_DIR env var (absolute or relative) + * 2. cwd, when canonical or legacy files are at the root (back-compat) + * 3. Auto-fallback to .agents/context/ then docs/ + * 4. Default to cwd when nothing is found + * + * Each test runs in its own scratch dir under os.tmpdir() so the suite stays + * independent of the project root and parallel-safe. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import { loadContext, resolveContextDir } from '../source/skills/impeccable/scripts/load-context.mjs'; + +let scratch; +let savedEnv; + +beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-loadctx-')); + savedEnv = process.env.IMPECCABLE_CONTEXT_DIR; + delete process.env.IMPECCABLE_CONTEXT_DIR; +}); + +afterEach(() => { + if (savedEnv === undefined) delete process.env.IMPECCABLE_CONTEXT_DIR; + else process.env.IMPECCABLE_CONTEXT_DIR = savedEnv; + fs.rmSync(scratch, { recursive: true, force: true }); +}); + +function write(rel, body = '# placeholder\n') { + const abs = path.join(scratch, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + return abs; +} + +describe('resolveContextDir', () => { + it('returns cwd when PRODUCT.md is at the root', () => { + write('PRODUCT.md'); + assert.equal(resolveContextDir(scratch), scratch); + }); + + it('returns cwd when DESIGN.md is at the root', () => { + write('DESIGN.md'); + assert.equal(resolveContextDir(scratch), scratch); + }); + + it('returns cwd when only legacy .impeccable.md is at the root', () => { + write('.impeccable.md'); + assert.equal(resolveContextDir(scratch), scratch); + }); + + it('falls back to .agents/context/ when root is clean', () => { + write('.agents/context/PRODUCT.md'); + assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context')); + }); + + it('falls back to docs/ when root is clean and .agents/context/ is empty', () => { + write('docs/PRODUCT.md'); + assert.equal(resolveContextDir(scratch), path.join(scratch, 'docs')); + }); + + it('prefers .agents/context/ over docs/ when both exist', () => { + write('.agents/context/PRODUCT.md'); + write('docs/PRODUCT.md'); + assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context')); + }); + + it('prefers cwd over fallback dirs when canonical files are at the root', () => { + write('PRODUCT.md'); + write('.agents/context/PRODUCT.md'); + assert.equal(resolveContextDir(scratch), scratch); + }); + + it('honors IMPECCABLE_CONTEXT_DIR with a relative path', () => { + write('design/PRODUCT.md'); + process.env.IMPECCABLE_CONTEXT_DIR = 'design'; + assert.equal(resolveContextDir(scratch), path.join(scratch, 'design')); + }); + + it('honors IMPECCABLE_CONTEXT_DIR with an absolute path', () => { + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-elsewhere-')); + try { + process.env.IMPECCABLE_CONTEXT_DIR = elsewhere; + assert.equal(resolveContextDir(scratch), elsewhere); + } finally { + fs.rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + it('IMPECCABLE_CONTEXT_DIR wins even when files exist at the root', () => { + write('PRODUCT.md', 'root'); + write('design/PRODUCT.md', 'overridden'); + process.env.IMPECCABLE_CONTEXT_DIR = 'design'; + assert.equal(resolveContextDir(scratch), path.join(scratch, 'design')); + }); + + it('ignores empty IMPECCABLE_CONTEXT_DIR', () => { + write('PRODUCT.md'); + process.env.IMPECCABLE_CONTEXT_DIR = ' '; + assert.equal(resolveContextDir(scratch), scratch); + }); + + it('returns cwd when nothing is found anywhere', () => { + assert.equal(resolveContextDir(scratch), scratch); + }); +}); + +describe('loadContext (backward compatibility)', () => { + it('reads PRODUCT.md and DESIGN.md from the root the same way as before', () => { + write('PRODUCT.md', '# product content\n'); + write('DESIGN.md', '# design content\n'); + const ctx = loadContext(scratch); + assert.equal(ctx.hasProduct, true); + assert.equal(ctx.hasDesign, true); + assert.match(ctx.product, /product content/); + assert.match(ctx.design, /design content/); + assert.equal(ctx.productPath, 'PRODUCT.md'); + assert.equal(ctx.designPath, 'DESIGN.md'); + assert.equal(ctx.contextDir, scratch); + }); + + it('migrates legacy .impeccable.md -> PRODUCT.md at root', () => { + write('.impeccable.md', '# legacy body\n'); + const ctx = loadContext(scratch); + assert.equal(ctx.migrated, true); + assert.equal(ctx.hasProduct, true); + assert.match(ctx.product, /legacy body/); + assert.ok(fs.existsSync(path.join(scratch, 'PRODUCT.md'))); + assert.ok(!fs.existsSync(path.join(scratch, '.impeccable.md'))); + }); +}); + +describe('loadContext (fallback dirs)', () => { + it('reads from .agents/context/ when the root is clean', () => { + write('.agents/context/PRODUCT.md', '# product in agents\n'); + write('.agents/context/DESIGN.md', '# design in agents\n'); + const ctx = loadContext(scratch); + assert.equal(ctx.hasProduct, true); + assert.equal(ctx.hasDesign, true); + assert.match(ctx.product, /product in agents/); + assert.equal(ctx.contextDir, path.join(scratch, '.agents', 'context')); + // productPath/designPath are relative to cwd, not contextDir + assert.equal(ctx.productPath, path.join('.agents', 'context', 'PRODUCT.md')); + assert.equal(ctx.designPath, path.join('.agents', 'context', 'DESIGN.md')); + assert.equal(ctx.migrated, false); + }); + + it('reads from docs/ when .agents/context/ is empty', () => { + write('docs/PRODUCT.md', '# product in docs\n'); + const ctx = loadContext(scratch); + assert.equal(ctx.hasProduct, true); + assert.equal(ctx.contextDir, path.join(scratch, 'docs')); + assert.equal(ctx.productPath, path.join('docs', 'PRODUCT.md')); + }); + + it('does not auto-migrate .impeccable.md inside fallback dirs', () => { + write('docs/.impeccable.md', '# legacy in docs\n'); + const ctx = loadContext(scratch); + // .impeccable.md inside a fallback dir doesn't pull the lookup there, + // and we never auto-rename outside the cwd root. + assert.equal(ctx.hasProduct, false); + assert.equal(ctx.migrated, false); + assert.ok(fs.existsSync(path.join(scratch, 'docs', '.impeccable.md'))); + }); +}); + +describe('loadContext (IMPECCABLE_CONTEXT_DIR override)', () => { + it('reads from the override path when set', () => { + write('design/PRODUCT.md', '# overridden product\n'); + write('design/DESIGN.md', '# overridden design\n'); + process.env.IMPECCABLE_CONTEXT_DIR = 'design'; + const ctx = loadContext(scratch); + assert.equal(ctx.hasProduct, true); + assert.equal(ctx.hasDesign, true); + assert.match(ctx.product, /overridden product/); + assert.equal(ctx.contextDir, path.join(scratch, 'design')); + }); + + it('reports a missing override directory as no-context, not as a crash', () => { + process.env.IMPECCABLE_CONTEXT_DIR = 'no/such/dir'; + const ctx = loadContext(scratch); + assert.equal(ctx.hasProduct, false); + assert.equal(ctx.hasDesign, false); + assert.equal(ctx.product, null); + assert.equal(ctx.design, null); + assert.equal(ctx.contextDir, path.resolve(scratch, 'no/such/dir')); + }); +});