/** * Context loader: prints PRODUCT.md, DESIGN.md when present, the matching * persisted surface brief when one can be resolved, and native-platform * guidance selected from PRODUCT.md. It prints a * `NO_PRODUCT_MD:` message when no * PRODUCT.md is found anywhere. The skill keys off that message to branch: * from-scratch build requests (plus init / teach / shape) and clear * build/shape intent divert into the init flow, while scoped commands proceed * using the existing code as context. * * Path resolution (first match wins): * 1. Active project root, if PRODUCT.md or DESIGN.md is there. An explicit * --target selects the active project: the workspace child in a * monorepo, or the nearest directory around the target carrying * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ * 3. Repo root context, using the same order, as a per-file fallback * whenever the active project is nested below it (a repo counts as a * monorepo when a package manager declares workspaces, or * `.impeccable/config.json` declares `projectRoots`) * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty * 5. Active project root 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 os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseTargetOptions } from './lib/target-args.mjs'; import { IMPECCABLE_COMMAND, IMPECCABLE_PROVIDER_ID } from './lib/provider.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const SKILL_REFERENCE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'reference'); const FALLBACK_DIRS = ['.agents/context', 'docs']; const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json']; const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages']; const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', 'coverage', 'vendor', 'vendors', ]); const VISUAL_SOURCE_DIRS = ['src', 'app', 'pages', 'components', 'site', 'public', 'styles']; const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less', '.styl']); const UI_EXTENSIONS = new Set(['.html', '.htm', '.jsx', '.tsx', '.vue', '.svelte', '.astro']); const VISUAL_SCAN_FILE_LIMIT = 250; const VISUAL_SCAN_DEPTH_LIMIT = 4; // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. // When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent // can offer `npx impeccable update`. Everything here is best-effort and // silent on failure: a network problem, sandbox, or missing cache must never // block context output or print an error. const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, ''); const UPDATE_CACHE_PATH = process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json'); const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week const FETCH_TIMEOUT_MS = 1200; export function resolveContextDir(cwd = process.cwd(), options = {}) { return resolveContext(cwd, options).contextDir; } export function loadContext(cwd = process.cwd(), options = {}) { const resolved = resolveContext(cwd, options); const absCwd = path.resolve(cwd); const productPath = resolved.productPath; const designPath = resolved.designPath; const product = productPath ? safeRead(productPath) : null; const design = designPath ? safeRead(designPath) : null; const platform = extractPlatform(product); const surfaceResolution = resolveSurfaceBrief( resolved.projectRoot, hasTargetOption(options) ? options.targetPath : null, ); const surfaceBrief = surfaceResolution.brief; return { hasProduct: !!product, product, productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, designPath: designPath ? path.relative(absCwd, designPath) : null, contextDir: resolved.contextDir, productContextDir: productPath ? path.dirname(productPath) : null, designContextDir: designPath ? path.dirname(designPath) : null, hasSurfaceBrief: !!surfaceBrief, surfaceBrief: surfaceBrief?.text ?? null, surfaceBriefPath: surfaceBrief?.path ? path.relative(absCwd, surfaceBrief.path) : null, surfaceBriefReason: surfaceResolution.reason, surfaceBriefCandidates: surfaceResolution.candidates.map((brief) => ({ slug: brief.slug, path: path.relative(absCwd, brief.path), primaryTarget: brief.primaryTarget, relatedTargets: brief.relatedTargets, })), hasVisualImplementation: hasVisualImplementation(resolved.projectRoot), platform, projectRoot: resolved.projectRoot, repoRoot: resolved.repoRoot, isMonorepo: resolved.isMonorepo, }; } function resolveContext(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const project = resolveProject(absCwd, options); const projectContextDir = resolveLocalContextDir(project.projectRoot); // Per-file inheritance from the repo root whenever the active project is // nested below it: monorepo workspace children and explicit-target nested // products in ordinary repos behave the same way. const rootContextDir = project.repoRoot !== project.projectRoot ? resolveLocalContextDir(project.repoRoot) : null; let productPath = (projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null) || (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null); let designPath = (projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null) || (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null); let envContextDir = null; if (!productPath && !designPath) { envContextDir = resolveEnvContextDir(absCwd); if (envContextDir) { productPath = firstExisting(envContextDir, PRODUCT_NAMES); designPath = firstExisting(envContextDir, DESIGN_NAMES); } } return { contextDir: productPath ? path.dirname(productPath) : designPath ? path.dirname(designPath) : envContextDir || project.projectRoot, productPath, designPath, projectRoot: project.projectRoot, repoRoot: project.repoRoot, isMonorepo: project.isMonorepo, targetDir: project.targetDir, }; } export function resolveProjectRoot(cwd = process.cwd(), options = {}) { return resolveProject(cwd, options).projectRoot; } export function resolveTargetSelection(cwd = process.cwd(), options = {}) { if (hasTargetOption(options)) return null; const project = resolveProject(cwd); if ( !project.isMonorepo || !project.projectRoot || !project.repoRoot || path.resolve(project.projectRoot) !== path.resolve(project.repoRoot) ) { return null; } const targetCandidates = discoverTargetCandidates(project.repoRoot); // No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace, // or a marker file with no apps/packages children): there is nothing to choose, // so treat the repo root as the active project rather than blocking on an empty // selection prompt that the user cannot answer. if (targetCandidates.length === 0) return null; return { targetPath: null, projectRoot: project.projectRoot, repoRoot: project.repoRoot, targetCandidates, }; } function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); let repoRoot = findMonorepoRoot(targetDir); if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { repoRoot = cwdRepoRoot; } } if (!repoRoot) { return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, repoRoot: absCwd, isMonorepo: false, }; } return { targetDir, projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot, repoRoot, isMonorepo: true, }; } function isPathInside(candidate, root) { const rel = path.relative(root, candidate); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); } function resolveLocalContextDir(root) { if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { return root; } for (const rel of FALLBACK_DIRS) { const candidate = path.resolve(root, rel); if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { return candidate; } } return null; } function resolveEnvContextDir(cwd) { const envDir = process.env.IMPECCABLE_CONTEXT_DIR; if (!envDir || !envDir.trim()) return null; const trimmed = envDir.trim(); return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); } function resolveTargetDir(cwd, options = {}) { const targetPath = options && typeof options === 'object' ? options.targetPath : null; if (!targetPath || !String(targetPath).trim()) return cwd; const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); try { const stat = fs.statSync(abs); return stat.isDirectory() ? abs : path.dirname(abs); } catch { return path.extname(abs) ? path.dirname(abs) : abs; } } function findMonorepoRoot(startDir) { let dir = path.resolve(startDir); const homeDir = path.resolve(os.homedir()); while (true) { if (dir === homeDir) return null; // isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace // root that also carries its own .git is still recognized. The trade-off is // deliberate — a directory with a monorepo *marker* but no workspace patterns // and no apps/packages children is not a monorepo root, so its .git stops // traversal and a further-up root is not searched. The nested .git is treated // as an independent project boundary, which is the intended isolation. if (isMonorepoRoot(dir)) return dir; if (hasGitBoundary(dir)) return null; const parent = path.dirname(dir); if (parent === dir) return null; dir = parent; } } function isMonorepoRoot(dir) { if (readProjectPatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; return hasFallbackWorkspaceChildren(dir); } function hasGitBoundary(dir) { return fs.existsSync(path.join(dir, '.git')); } function hasFallbackWorkspaceChildren(dir) { for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) { const base = path.join(dir, name); let entries; try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { continue; } if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true; } return false; } function discoverTargetCandidates(repoRoot) { const roots = new Map(); const patternGroups = readProjectPatternGroups(repoRoot); for (const patterns of patternGroups) { for (const pattern of patterns) { for (const root of discoverRootsForPattern(repoRoot, pattern)) { roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); } } } if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) { const base = path.join(repoRoot, name); let entries; try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; const root = path.join(base, entry.name); roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); } } } return [...roots.entries()] .filter(([rel]) => rel && !rel.startsWith('..')) .filter(([rel]) => isSelectableCandidate(repoRoot, rel, patternGroups)) .sort(([a], [b]) => a.localeCompare(b)) .map(([rel, root]) => { const targetExample = findTargetExample(repoRoot, root); return { name: path.basename(root), path: rel, targetExample, ...resolveCandidateContextSummary(repoRoot, root, targetExample), }; }); } function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) { const ctx = resolveContext(repoRoot, { targetPath }); return { productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot), productPath: contextSourcePath(ctx.productPath, repoRoot), designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot), designPath: contextSourcePath(ctx.designPath, repoRoot), }; } // Selection candidates surface one of four statuses: 'child' (a canonical // PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the // repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback' // intentionally covers two non-canonical locations: a file inside the project // root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file // outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override). function contextSourceStatus(filePath, repoRoot, projectRoot) { if (!filePath) return 'missing'; const absPath = path.resolve(filePath); const absProjectRoot = path.resolve(projectRoot); const absRepoRoot = path.resolve(repoRoot); if (isPathInsideOrEqual(absPath, absProjectRoot)) { return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback'; } if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) { return 'inherited'; } return 'fallback'; } function contextSourcePath(filePath, repoRoot) { if (!filePath) return null; const rel = path.relative(repoRoot, filePath); if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { return rel.split(path.sep).join('/'); } return filePath; } function discoverRootsForPattern(repoRoot, rawPattern) { const pattern = normalizeWorkspacePattern(rawPattern); if (!pattern || pattern.startsWith('!')) return []; const segments = pattern.split('/').filter(Boolean); if (!segments.length) return []; const firstGlobIndex = segments.findIndex((segment) => segment.includes('*')); const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex); const base = path.join(repoRoot, ...literalPrefix); if (!fs.existsSync(base)) return []; if (segments.includes('**')) { const packageRoots = []; walkDirs(base, (dir) => { if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir); }); if (packageRoots.length) return packageRoots; return directChildDirs(base); } return expandSimplePattern(repoRoot, segments); } function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) { if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : []; const segment = patternSegments[index]; if (!segment.includes('*')) { return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment)); } let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return []; } const roots = []; for (const entry of entries) { if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; if (!segmentMatches(segment, entry.name)) continue; roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name))); } return roots; } function directChildDirs(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name)) .map((entry) => path.join(dir, entry.name)); } catch { return []; } } function walkDirs(root, visit) { let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; const dir = path.join(root, entry.name); visit(dir); walkDirs(dir, visit); } } function isCandidateProjectRoot(dir) { return !!( fs.existsSync(path.join(dir, 'package.json')) || firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES]) || fs.existsSync(path.join(dir, 'src')) || fs.existsSync(path.join(dir, 'app')) || fs.existsSync(path.join(dir, 'pages')) || fs.existsSync(path.join(dir, 'public')) ); } function isIgnoredWorkspaceDiscoveryDir(name) { return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name); } function findTargetExample(repoRoot, projectRoot) { const examples = [ 'src/App.jsx', 'src/App.tsx', 'src/main.jsx', 'src/main.tsx', 'src/index.jsx', 'src/index.ts', 'app/page.tsx', 'pages/index.tsx', 'public/index.html', ]; for (const rel of examples) { const abs = path.join(projectRoot, rel); if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/'); } return path.relative(repoRoot, projectRoot).split(path.sep).join('/'); } function resolveWorkspaceProjectRoot(repoRoot, targetDir) { const rel = path.relative(repoRoot, targetDir); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; const relSegments = rel.split(path.sep).filter(Boolean); for (const patterns of readProjectPatternGroups(repoRoot)) { if (isExcludedByWorkspacePattern(relSegments, patterns)) return repoRoot; for (const pattern of patterns) { const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (projectRoot) return projectRoot; } } if ( relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) ) { return path.join(repoRoot, relSegments[0], relSegments[1]); } const nearest = nearestProjectLikeRoot(repoRoot, targetDir); if (nearest) return nearest; return repoRoot; } // A discovered folder is only selectable when picking it would resolve back to // itself. Impeccable `projectRoots` patterns govern every path they match: // a negation drops the candidate (resolveWorkspaceProjectRoot would send it to // the repo root), and a positive match with a different boundary drops it too, // because the boundary root is already its own candidate and choosing the // deeper folder would silently resolve there. Paths the Impeccable group does // not match fall through to the package-manager negations, which is the // pre-existing behavior for package workspaces and marker-dir fallbacks. function isSelectableCandidate(repoRoot, rel, patternGroups) { const relSegments = rel.split('/').filter(Boolean); const [impeccablePatterns, packagePatterns] = patternGroups; if (isExcludedByWorkspacePattern(relSegments, impeccablePatterns)) return false; for (const pattern of impeccablePatterns) { const boundary = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); if (boundary) return path.resolve(boundary) === path.resolve(path.join(repoRoot, ...relSegments)); } return !isExcludedByWorkspacePattern(relSegments, packagePatterns); } function isExcludedByWorkspacePattern(relSegments, patterns) { return patterns.some((rawPattern) => { const pattern = normalizeWorkspacePattern(rawPattern); if (!pattern.startsWith('!')) return false; return workspacePatternMatchesRel(pattern.slice(1), relSegments); }); } // An explicit --target in an ordinary (non-monorepo) repository must still // select a nested product's own context (issue #376). Walk from the target up // to — but not including — the invocation root and return the nearest // directory carrying context files, in the canonical spot or a fallback dir // (resolveLocalContextDir covers both). Context files only, not package.json: // without the monorepo root-context fallback, a package.json marker would // strand targets inside plain subpackages away from the root PRODUCT.md. The // cwd's own fallback context dirs (.agents/context, docs) hold the root // project's context, not a nested product, so they never count. // Returns null when nothing nested is found, keeping the cwd default. function nearestTargetContextRoot(absCwd, targetDir) { if (!isPathInside(targetDir, absCwd)) return null; const rootFallbackDirs = FALLBACK_DIRS.map((rel) => path.resolve(absCwd, rel)); let dir = path.resolve(targetDir); while (dir && dir !== absCwd) { if (!rootFallbackDirs.includes(dir) && resolveLocalContextDir(dir)) { return dir; } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } function nearestProjectLikeRoot(repoRoot, targetDir) { let dir = path.resolve(targetDir); const stop = path.resolve(repoRoot); while (dir && dir !== stop) { if ( firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES]) || fs.existsSync(path.join(dir, 'package.json')) ) { return dir; } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } function nearestPackageRootBetween(repoRoot, targetDir, stopDir) { let dir = path.resolve(targetDir); const stop = path.resolve(stopDir || repoRoot); const root = path.resolve(repoRoot); while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) { if (fs.existsSync(path.join(dir, 'package.json'))) return dir; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } function isPathInsideOrEqual(candidate, root) { return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root); } function workspacePatternMatchesRel(pattern, relSegments) { const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); if (!patternSegments.length) return false; if (patternSegments.includes('**')) { const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*')); const literalPrefix = firstGlobIndex === -1 ? patternSegments : patternSegments.slice(0, firstGlobIndex); if (relSegments.length < literalPrefix.length + 1) return false; for (let i = 0; i < literalPrefix.length; i++) { if (!segmentMatches(literalPrefix[i], relSegments[i])) return false; } return true; } if (relSegments.length < patternSegments.length) return false; for (let i = 0; i < patternSegments.length; i++) { if (!segmentMatches(patternSegments[i], relSegments[i])) return false; } return true; } // Project boundaries come from two sources, in precedence order: explicit // `projectRoots` globs in .impeccable config, then package-manager workspace // declarations. A path matched by any Impeccable pattern — positive or // negated — is governed by the Impeccable group alone; package-manager // patterns only apply to paths the Impeccable group does not match. Within a // group, negations win over positives. function readProjectPatternGroups(repoRoot) { return [ readImpeccableProjectRoots(repoRoot), [ ...readPackageWorkspaces(repoRoot), ...readPnpmWorkspaces(repoRoot), ...readLernaWorkspaces(repoRoot), ].filter(Boolean), ]; } function readProjectPatterns(repoRoot) { return readProjectPatternGroups(repoRoot).flat(); } function readImpeccableProjectRoots(repoRoot) { const patterns = []; for (const name of ['config.json', 'config.local.json']) { const cfg = readJson(path.join(repoRoot, '.impeccable', name)); if (!Array.isArray(cfg?.projectRoots)) continue; for (const entry of cfg.projectRoots) { if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim()); } } return patterns; } function readPackageWorkspaces(repoRoot) { const pkg = readJson(path.join(repoRoot, 'package.json')); const workspaces = pkg?.workspaces; if (Array.isArray(workspaces)) return workspaces; if (Array.isArray(workspaces?.packages)) return workspaces.packages; return []; } function readLernaWorkspaces(repoRoot) { const lerna = readJson(path.join(repoRoot, 'lerna.json')); return Array.isArray(lerna?.packages) ? lerna.packages : []; } function readPnpmWorkspaces(repoRoot) { try { const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8'); const patterns = []; let inPackages = false; for (const line of body.split(/\r?\n/)) { const trimmed = stripYamlInlineComment(line).trim(); if (!trimmed || trimmed.startsWith('#')) continue; const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); if (flowMatch) { patterns.push(...parseYamlFlowList(flowMatch[1])); inPackages = false; continue; } if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; } if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; if (inPackages) { const match = trimmed.match(/^-\s*(.+)$/); if (match) patterns.push(unquoteYamlValue(match[1])); } } return patterns; } catch { return []; } } function stripYamlInlineComment(line) { let quote = null; for (let i = 0; i < line.length; i++) { const ch = line[i]; if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') { quote = quote === ch ? null : quote || ch; continue; } if (ch === '#' && !quote) return line.slice(0, i); } return line; } function parseYamlFlowList(body) { const items = []; let quote = null; let current = ''; for (let i = 0; i < body.length; i++) { const ch = body[i]; if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') { quote = quote === ch ? null : quote || ch; current += ch; continue; } if (ch === ',' && !quote) { const value = unquoteYamlValue(current); if (value) items.push(value); current = ''; continue; } current += ch; } const value = unquoteYamlValue(current); if (value) items.push(value); return items; } function unquoteYamlValue(value) { return String(value || '') .trim() .replace(/^['"]|['"]$/g, ''); } function readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return null; } } function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) { const pattern = normalizeWorkspacePattern(rawPattern); if (!pattern || pattern.startsWith('!')) return null; const patternSegments = pattern.split('/').filter(Boolean); if (!patternSegments.length) return null; if (patternSegments.includes('**')) { return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments); } if (relSegments.length < patternSegments.length) return null; for (let i = 0; i < patternSegments.length; i++) { if (!segmentMatches(patternSegments[i], relSegments[i])) return null; } return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length)); } function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) { const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*')); const literalPrefix = firstGlobIndex === -1 ? patternSegments : patternSegments.slice(0, firstGlobIndex); if (relSegments.length < literalPrefix.length + 1) return null; for (let i = 0; i < literalPrefix.length; i++) { if (!segmentMatches(literalPrefix[i], relSegments[i])) return null; } const prefixDir = path.join(repoRoot, ...literalPrefix); const targetDir = path.join(repoRoot, ...relSegments); const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir); if (packageRoot) return packageRoot; return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1)); } function normalizeWorkspacePattern(pattern) { return String(pattern || '') .trim() .replace(/^['"]|['"]$/g, '') .replace(/^\.\//, '') .replace(/\/+$/, ''); } function segmentMatches(patternSegment, relSegment) { if (patternSegment === '*') return true; if (!patternSegment.includes('*')) return patternSegment === relSegment; const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`); return re.test(relSegment); } 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; } } function loadNativePlatformReferences(platform) { const names = platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : []; return names.flatMap((name) => { const filePath = path.join(SKILL_REFERENCE_DIR, `${name}.md`); const content = safeRead(filePath); return content ? [{ name, filePath, content }] : []; }); } /** * Best-effort evidence that the project already has an incumbent visual * implementation. DESIGN.md is documentation, not the only source of design * authority: real tokens, chosen type, and a component system in code must not * be mistaken for a greenfield identity merely because the document is absent. * * The scan is deliberately bounded and conservative. A package.json or one * empty scaffold component is not enough; a tokenized stylesheet, an authored * HTML surface, or several styled UI components is. */ export function hasVisualImplementation(projectRoot) { if (!projectRoot) return false; const root = path.resolve(projectRoot); const queue = []; for (const rel of VISUAL_SOURCE_DIRS) { const dir = path.join(root, rel); if (fs.existsSync(dir)) queue.push({ dir, depth: 0 }); } let scannedFiles = 0; let styledComponents = 0; const inspectFile = (filePath) => { const ext = path.extname(filePath).toLowerCase(); if (!STYLE_EXTENSIONS.has(ext) && !UI_EXTENSIONS.has(ext)) return false; const base = path.basename(filePath).toLowerCase(); if (/\.min\.[a-z]+$/.test(base)) return false; if (scannedFiles++ >= VISUAL_SCAN_FILE_LIMIT) return false; let body; try { body = fs.readFileSync(filePath, 'utf-8').slice(0, 64 * 1024); } catch { return false; } const evidence = body .replace(/\/\*[\s\S]*?\*\//g, '') .replace(//g, '') .replace(/^\s*\/\/.*$/gm, ''); if (STYLE_EXTENSIONS.has(ext)) { const customProperties = evidence.match(/--[a-z0-9_-]+\s*:/gi)?.length ?? 0; const visualDeclarations = evidence.match(/\b(?:color|background(?:-color)?|border(?:-color)?|font-family)\s*:/gi)?.length ?? 0; if (/\b(?:tokens?|theme|design-system)\b/.test(base) && evidence.trim().length > 80) return true; if (customProperties >= 3 || visualDeclarations >= 5) return true; } if ((ext === '.html' || ext === '.htm') && evidence.length > 600 && /]+stylesheet/i.test(evidence)) { return true; } if (!['.html', '.htm'].includes(ext) && evidence.length > 300) { const embeddedCustomProperties = evidence.match(/--[a-z0-9_-]+\s*:/gi)?.length ?? 0; const embeddedVisualDeclarations = evidence.match(/\b(?:color|background(?:-color)?|border(?:-color)?|font-family)\s*:/gi)?.length ?? 0; const classTokens = [...evidence.matchAll(/class(?:Name)?\s*=\s*["'`]([^"'`]+)["'`]/gi)] .reduce((count, match) => count + match[1].trim().split(/\s+/).length, 0); if ((embeddedCustomProperties >= 3 && embeddedVisualDeclarations >= 3) || embeddedVisualDeclarations >= 5 || classTokens >= 12) return true; } if (!['.html', '.htm'].includes(ext) && evidence.length > 300 && /class(?:Name)?\s*=|style\s*=|styled\(|css`/i.test(evidence)) { styledComponents += 1; if (styledComponents >= 3) return true; } return false; }; // Root-level authored surfaces and styles are common in small projects. try { for (const entry of fs.readdirSync(root, { withFileTypes: true })) { if (entry.isFile() && inspectFile(path.join(root, entry.name))) return true; } } catch { /* unreadable root: no evidence */ } while (queue.length && scannedFiles < VISUAL_SCAN_FILE_LIMIT) { const { dir, depth } = queue.shift(); let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (entry.isDirectory()) { if (depth >= VISUAL_SCAN_DEPTH_LIMIT || entry.name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(entry.name)) continue; queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); } else if (entry.isFile() && inspectFile(path.join(dir, entry.name))) { return true; } if (scannedFiles >= VISUAL_SCAN_FILE_LIMIT) break; } } return styledComponents >= 3; } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Read the first non-empty line under a bare `## ` section of * PRODUCT.md (for example `## Platform`). Returns null when the * section is absent. The heading match is exact (`\s*$`) so near-miss * near-miss headings don't shadow the real field. */ export function extractSectionValue(product, heading) { if (!product) return null; const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i'); const lines = product.split('\n'); for (let i = 0; i < lines.length; i++) { if (headingRe.test(lines[i].trim())) { for (let j = i + 1; j < lines.length; j++) { const next = lines[j].trim(); // A new heading before any value means the section is empty. if (/^#{1,6}\s/.test(next)) return null; if (next) return next; } } } return null; } /** * Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md * by looking for a `## Platform` section and reading the first non-empty line * that follows it. `adaptive` is for cross-platform apps (Flutter, React * Native) that ship both iOS and Android from one codebase; a line that names * both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null * when the file is legacy / platform-less, which the skill treats as `web` * (the default the general rules already assume). */ export function extractPlatform(product) { const value = (extractSectionValue(product, 'Platform') || '').toLowerCase(); if (!value) return null; if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value; // A short list naming both native targets (`ios, android`, `ios and // android`) = adaptive. Only list separators and the two platform words may // appear; anything else (prose, negations) is unrecognized and falls // through to the CLI's WARNING path. const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and'); if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android') && tokens.includes('ios') && tokens.includes('android')) { return 'adaptive'; } return null; } /** * Read the installed skill's own version from the sibling SKILL.md frontmatter * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); const match = content.match(/^version:\s*(.+)$/m); return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; } catch { return null; } } function readUpdateCache() { try { return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8')); } catch { return {}; } } function writeUpdateCache(cache) { try { fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true }); fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache)); } catch { // Best-effort: a read-only home dir just means we re-poll next session. } } /** Compare dotted numeric versions. Returns >0 when a is newer than b. */ function compareSemver(a, b) { const pa = String(a).split('.').map(n => parseInt(n, 10) || 0); const pb = String(b).split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const diff = (pa[i] || 0) - (pb[i] || 0); if (diff !== 0) return diff; } return 0; } async function fetchLatestSkillVersion() { try { const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) return null; const data = await res.json(); return typeof data?.skills === 'string' ? data.skills : null; } catch { return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal } } function buildUpdateDirective(localVersion, latestVersion) { return ( `UPDATE_AVAILABLE: A newer Impeccable skill is available ` + `(installed v${localVersion}, latest v${latestVersion}). ` + `Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` + `Update now? It runs \`npx impeccable update\`." ` + `If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` + `Either way, continue the current task without waiting, and do not raise this again.` ); } /** * Best-effort update directive for the boot output. Returns a string to append * or null. Polls the version endpoint at most once per day (cached globally in * the user's home dir) and re-surfaces a given version at most once per week so * the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1. */ // Read the unified config's top-level `updateCheck` (local overrides shared). // Inlined rather than importing hook-lib so the boot path stays lightweight. function updateCheckDisabledByConfig(cwd = process.cwd()) { let value; for (const name of ['config.json', 'config.local.json']) { try { const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8')); if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck; } catch { /* missing or malformed: ignore */ } } return value === false; } async function computeUpdateDirective(now = Date.now()) { try { if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null; if (updateCheckDisabledByConfig()) return null; const localVersion = readLocalSkillVersion(); if (!localVersion) return null; const cache = readUpdateCache(); // Poll the network only when the throttle window has elapsed. Stamp // lastCheck even on failure so an offline machine doesn't poll every boot. if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) { const latest = await fetchLatestSkillVersion(); cache.lastCheck = now; if (latest) cache.latestVersion = latest; writeUpdateCache(cache); } const latest = cache.latestVersion; if (!latest || compareSemver(latest, localVersion) <= 0) return null; // Anti-nag: surface a given version at most once per RENOTIFY window. if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) { return null; } cache.notifiedVersion = latest; cache.notifiedAt = now; writeUpdateCache(cache); return buildUpdateDirective(localVersion, latest); } catch { return null; } } async function cli() { let cliOptions; try { cliOptions = parseCliOptions(process.argv.slice(2)); } catch (err) { if (err?.name === 'TargetArgError') { process.stderr.write(`${err.message}\n`); process.exit(1); } throw err; } const targetProvided = hasTargetOption(cliOptions); const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null; const selection = resolveTargetSelection(process.cwd(), cliOptions); if (selection) { process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.exit(0); } const ctx = loadContext(process.cwd(), cliOptions); const updateDirective = await computeUpdateDirective(); 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. const parts = ctx.hasVisualImplementation ? [ 'NO_PRODUCT_MD: This project has no PRODUCT.md yet, but it does have an incumbent visual implementation. ' + 'For `init`, `teach`, `shape`, or any request to create a new surface or replacement visual world, load reference/init.md and create PRODUCT.md with the user first. ' + 'After init writes PRODUCT.md, reference/new-work.md preserves and documents the incumbent system for an ' + 'extension or replaces it with the user for a redesign/rebrand. Other ' + 'narrow refinement commands may read the CSS, tokens, components, and assets and proceed without blocking, then ' + `offer \`${IMPECCABLE_COMMAND} init\` as a follow-up.`, 'BUILD_INIT_REQUIRED: Before shape or any new-surface/redesign flow, init must capture PRODUCT.md with the human or structured ' + 'simulated user. Init writes product truth only; reference/new-work.md owns every visual decision.', 'SCOPED_EXISTING_ALLOWED: Narrow refinement commands may use the incumbent implementation as authority without ' + 'blocking on context setup; they must preserve it and offer init afterward.', 'EXISTING_VISUAL_SYSTEM: For refinement or extension, code and assets are incumbent design authority and missing ' + 'DESIGN.md is a documentation gap. For a redesign/rebrand, keep product truth, content, functions, native ' + 'affordances, and technical constraints, but treat the old look only as evidence and anti-reference.', ] : [ 'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' + 'For `init`, `teach`, `shape`, ' + 'or wording that clearly maps to a from-scratch build/shape flow, load ' + 'reference/init.md, complete its human or structured simulated-user interview, and write PRODUCT.md before ' + 'designing. If no answer mechanism truly exists, init may infer only from the explicit brief and must label its ' + 'assumptions. It never writes DESIGN.md. For any other ' + '(scoped) command against existing code, proceed using the code as ' + `context and offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`, 'PRODUCT_INIT_REQUIRED: No product context or visual authority was found. New builds and redesigns ' + 'must finish reference/init.md for PRODUCT.md, then reference/new-work.md establishes the world and surface. Scoped ' + 'fixes to existing code do not need the new-surface flow.', ]; appendSurfaceBriefContext(parts, ctx); parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); appendDetectorFallback(parts, ctx); if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { parts.push(buildMissingTargetDirective()); } if (updateDirective) parts.push(updateDirective); process.stdout.write(parts.join('\n\n---\n\n') + '\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()}`); } appendSurfaceBriefContext(parts, ctx); parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); appendDetectorFallback(parts, ctx); if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { parts.push(buildMissingTargetDirective()); } if (!ctx.hasDesign) { parts.push(ctx.hasVisualImplementation ? 'INCUMBENT_WORLD_UNDOCUMENTED: PRODUCT.md exists and DESIGN.md is missing, but code contains incumbent visual decisions. ' + 'For shape or a new-surface/redesign request, load reference/new-work.md: an extension documents and preserves the code-defined world; ' + 'a redesign replaces it with the user and uses the old look only as evidence and anti-reference. Narrow refinement ' + 'commands may proceed using the implementation directly.' : 'WORLD_DISCOVERY_REQUIRED: PRODUCT.md exists but no DESIGN.md or incumbent visual implementation was found. ' + 'For a new build or redesign, load reference/new-work.md and establish the visual world with the human or structured ' + 'simulated user before developing the task concept. Scoped fixes to existing code do not need this flow.'); } const platformReferences = loadNativePlatformReferences(ctx.platform); for (const reference of platformReferences) { parts.push( `# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`, ); } if (!ctx.platform) { // A `## Platform` section that names something we don't recognize (a // toolchain like `flutter`, a typo) would otherwise silently fall back to // web — the wrong default exactly when the user tried to say "native". const rawPlatform = extractSectionValue(ctx.product, 'Platform'); if (rawPlatform) { parts.push( `WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`, ); } } if (updateDirective) parts.push(updateDirective); process.stdout.write(parts.join('\n\n---\n\n') + '\n'); } function parseCliOptions(args) { return parseTargetOptions(args, { strict: true }); } function hasTargetOption(options) { return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); } function pathExistsForTarget(cwd, targetPath) { const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); return fs.existsSync(abs); } const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({ 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], codex: ['.codex/hooks.json'], agents: ['.codex/hooks.json'], cursor: ['.cursor/hooks.json'], github: ['.github/hooks/impeccable.json'], }); function truthyEnv(value) { return typeof value === 'string' && /^(1|true|yes|on)$/i.test(value.trim()); } function valueHasHookMarker(value) { if (typeof value === 'string') { return value.includes('skills/impeccable/scripts/hook.mjs') || value.includes('skills/impeccable/scripts/hook-before-edit.mjs'); } if (Array.isArray(value)) return value.some(valueHasHookMarker); if (value && typeof value === 'object') return Object.values(value).some(valueHasHookMarker); return false; } function hookEnabledAt(root) { if (truthyEnv(process.env.IMPECCABLE_HOOK_DISABLED)) return false; let enabled = true; for (const name of ['.impeccable/config.json', '.impeccable/config.local.json']) { const raw = readJson(path.join(root, name)); if (raw?.hook && Object.prototype.hasOwnProperty.call(raw.hook, 'enabled')) { enabled = raw.hook.enabled !== false; } } return enabled; } const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents']); function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; } const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; for (const root of roots) { for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { return STOP_REVIEW_PROVIDERS.has(IMPECCABLE_PROVIDER_ID) ? 'stop' : 'per-edit'; } } } return 'none'; } // reference/craft-floor.md carries the detector-blind reflexes on every build, // so the only gap left here is the mechanical pass. A hook covers it, per-edit // or Stop; a session without one has to run the detector by hand. The detector // reads HTML and CSS, so native projects get nothing. function appendDetectorFallback(parts, ctx) { if (automaticHookMode(ctx) !== 'none') return; if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') return; const scriptsPath = path.dirname(fileURLToPath(import.meta.url)); parts.push([ 'MANUAL_DETECTOR_REQUIRED: No automatic Impeccable design hook is active this session.', `Once the changed web UI is finished, run the mechanical detector over it: \`node ${scriptsPath}/detect.mjs --json \`.`, 'Run it once, and not earlier during concept selection.', ].join(' ')); } function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) { const targetPath = hasTargetOption(options) ? options.targetPath : null; return `RESOLVED_CONTEXT:\n${JSON.stringify({ targetPath, ...(targetPath ? { targetExists } : {}), projectRoot: ctx.projectRoot, repoRoot: ctx.repoRoot, productPath: ctx.productPath, designPath: ctx.designPath, surfaceBriefPath: ctx.surfaceBriefPath, surfaceBriefReason: ctx.surfaceBriefReason, surfaceBriefCandidates: ctx.surfaceBriefCandidates, hasVisualImplementation: ctx.hasVisualImplementation, platform: ctx.platform, }, null, 2)}`; } function appendSurfaceBriefContext(parts, ctx) { if (ctx.hasSurfaceBrief && ctx.surfaceBrief) { parts.push(`# SURFACE BRIEF (${ctx.surfaceBriefPath})\n\n${ctx.surfaceBrief.trim()}`); return; } if (!ctx.surfaceBriefCandidates?.length) return; const helper = path.join(path.dirname(fileURLToPath(import.meta.url)), 'surface-brief.mjs'); parts.push( 'SURFACE_CONTEXT_AVAILABLE: Persisted surface briefs exist, but none was selected unambiguously for this invocation. ' + 'Resolve the requested surface to its concrete primary or related source path, then run ' + `\`node ${helper} read \` once before changing that surface. Candidates:\n` + JSON.stringify(ctx.surfaceBriefCandidates, null, 2), ); } function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) { if (ctx.isMonorepo && targetProvided && targetExists === false) return true; return !!( ctx.isMonorepo && (!targetProvided || targetExists === false) && ctx.projectRoot && ctx.repoRoot && path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot) ); } function buildMissingTargetDirective() { const script = process.argv[1] || 'context.mjs'; return ( 'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' + 'If the user named a file, route, or child app, do not answer from this output. ' + `Rerun \`node ${script} --target \` and answer from that run's RESOLVED_CONTEXT fields.` ); } function buildTargetSelectionDirective(selection) { return ( `TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` + 'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' + 'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' + 'Use `--target ` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.' ); } // Run cli() only when this module is the entry point. Compare realpaths // rather than endsWith(): a loose suffix match also fires for unrelated // scripts like `load-context.mjs`, and realpath tolerates symlinked // invocation (the test harness symlinks the skill dir). function invokedAsScript() { const arg = process.argv[1]; if (!arg) return false; try { return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } } if (invokedAsScript()) { cli(); }