feat(load-context): resolve context dir outside repo root (#119) (#123)

Adds a configurable lookup path for PRODUCT.md / DESIGN.md / DESIGN.json so
they don't have to live at the project root. Resolution order (first match
wins):

  1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd)
  2. cwd, when canonical or legacy files are at the root (back-compat)
  3. Auto-fallback subdirs of cwd: .agents/context/ then docs/
  4. cwd as a default "no context found" location

Existing layouts (PRODUCT.md / DESIGN.md at repo root) keep working unchanged
- step 2 preserves the current behaviour. The auto-fallback covers the two
most common conventions seen in the wild (.agents/context/ for AGENTS.md
auto-import setups, docs/ for the request in the issue) without needing any
configuration.

Changes:
  - load-context.mjs: export resolveContextDir() and use it inside
    loadContext(); add contextDir to the JSON output
  - live-server.mjs: import resolveContextDir and read PRODUCT.md /
    DESIGN.md / DESIGN.json from the resolved dir instead of process.cwd()
  - SKILL.md: short note on the env var and fallback dirs in Setup -> Context
  - tests/load-context.test.mjs: 19 cases covering env var, fallbacks,
    legacy migration scope, and back-compat

Legacy .impeccable.md -> PRODUCT.md auto-migration stays scoped to cwd root;
fallback dirs are read-only as far as auto-rename is concerned.

Closes #119
This commit is contained in:
Dan Doca
2026-04-29 08:52:40 -07:00
committed by GitHub
parent 9a5d0e71a9
commit c332c7aa91
43 changed files with 1107 additions and 168 deletions
+2 -2
View File
@@ -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.
@@ -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);
@@ -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;