mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
skill: simplify context loading and inline register directive
Replaces load-context.mjs's JSON output with a tight markdown block from the renamed context.mjs. The script now extracts PRODUCT.md's `## Register` field and appends a `NEXT STEP:` directive naming the matching reference (brand.md / product.md), which moved Gemini from skipping the register load entirely to honoring it. Drops the `.impeccable.md` auto-migration; makes IMPECCABLE_CONTEXT_DIR a lazy escape hatch consulted only when the default paths come up empty. Setup is now four bullets in one list. The DESIGN.md nudge is gone; in its place, a "familiarize with the existing design system" step that calls out CSS / tokens / running app as authoritative sources alongside DESIGN.md. The standalone `### Register` H3 stays for the cascade rules (task cue → surface → register field). New LLM-backed test suite at tests/skill-behavior/ runs five scenarios against claude-haiku-4-5, gpt-5.4-mini, and gemini-3.1-flash-lite via Vercel AI SDK. Captures real tool traces, asserts on context.mjs calls, brand.md loads, and teach.md fallback. Skips cleanly when API keys are unset. 13-14/15 pass; only stable failure is the v3.2.0-era gpt-mini S4 "don't re-run" regression. Adds @ai-sdk/google as devDep and the test:skill-behavior npm script. Touches em-dashes in skill/SKILL.md and four reference files so `bun run build:skills` passes its skill-prose validator. teach.md and document.md drop their "re-run the loader to refresh session cache" steps since the agent's own write is now the freshest source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0d32da57bc
commit
33467e5f0f
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
|
||||
* markdown block on stdout, or exits with empty stdout when no PRODUCT.md
|
||||
* is found anywhere. The skill keys off "empty stdout" to branch into the
|
||||
* teach flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
* shape rather than the markdown block.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
};
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeRead(p) {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
* follows it. Returns null when the file is legacy / register-less.
|
||||
*/
|
||||
function extractRegister(product) {
|
||||
if (!product) return null;
|
||||
const lines = product.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^##\s+Register\b/i.test(lines[i].trim())) {
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const next = lines[j].trim();
|
||||
if (!next) continue;
|
||||
const word = next.toLowerCase();
|
||||
if (word === 'brand' || word === 'product') return word;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
if (!ctx.hasProduct) {
|
||||
// Direct stdout message instead of relying on empty output as a signal
|
||||
// — cheap models miss the empty case more often than the explicit one.
|
||||
process.stdout.write(
|
||||
'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
|
||||
'Stop the current task, load reference/teach.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.\n',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
|
||||
parts.push(next);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('context.mjs') || _running?.endsWith('context.mjs/')) {
|
||||
cli();
|
||||
}
|
||||
@@ -21,7 +21,7 @@ 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';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
@@ -187,8 +187,7 @@ function loadBrowserScripts() {
|
||||
function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state. Legacy
|
||||
// .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
|
||||
@@ -21,7 +21,7 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
@@ -80,7 +80,7 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
@@ -102,7 +102,6 @@ The agent should then:
|
||||
hasDesign: ctx.hasDesign,
|
||||
design: ctx.design,
|
||||
designPath: ctx.designPath,
|
||||
migrated: ctx.migrated,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Shared context loader for every impeccable command that needs to know
|
||||
* "who is this for" and "what does this look like".
|
||||
*
|
||||
* Input: project root (process.cwd()).
|
||||
*
|
||||
* Output (JSON to stdout):
|
||||
* {
|
||||
* hasProduct: boolean, // PRODUCT.md found (or auto-migrated)
|
||||
* product: string | null, // PRODUCT.md contents
|
||||
* productPath: string | null, // relative path
|
||||
* hasDesign: boolean, // DESIGN.md found
|
||||
* 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';
|
||||
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 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) in the resolved dir
|
||||
let productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
|
||||
// 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');
|
||||
try {
|
||||
fs.renameSync(legacyPath, newPath);
|
||||
productPath = newPath;
|
||||
migrated = true;
|
||||
} catch {
|
||||
// Rename failed (permissions, etc.) — fall back to reading legacy in place
|
||||
productPath = legacyPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DESIGN.md (case-insensitive)
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
migrated,
|
||||
contextDir,
|
||||
};
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeRead(p) {
|
||||
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI mode — print the context as JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cli() {
|
||||
const result = loadContext(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('load-context.mjs') || _running?.endsWith('load-context.mjs/')) {
|
||||
cli();
|
||||
}
|
||||
Reference in New Issue
Block a user