Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/context.mjs
1/**2* Context loader: prints PRODUCT.md (and DESIGN.md if present) as one3* markdown block on stdout, or exits with empty stdout when no PRODUCT.md4* is found anywhere. The skill keys off "empty stdout" to branch into the5* init flow.6*7* Path resolution (first match wins):8* 1. Active project root, if PRODUCT.md or DESIGN.md is there9* 2. Active project .agents/context/ then docs/10* 3. Monorepo root context, using the same order, as a per-file fallback11* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user12* escape hatch, only consulted when defaults are empty13* 5. Active project root as a "nothing found" default14*15* `resolveContextDir()` and `loadContext()` are also exported for the16* server-side scripts (live.mjs, live-server.mjs) that need the structured17* shape rather than the markdown block.18*/19import fs from 'node:fs';20import os from 'node:os';21import path from 'node:path';22import { fileURLToPath } from 'node:url';23import { parseTargetOptions } from './lib/target-args.mjs';2425const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];26const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];27const FALLBACK_DIRS = ['.agents/context', 'docs'];28const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];29const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];30const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([31'node_modules',32'.git',33'dist',34'build',35'.next',36'.nuxt',37'.svelte-kit',38'.turbo',39'.cache',40'coverage',41]);4243// ─── Update check ──────────────────────────────────────────────────────────44// Piggyback a lightweight skill-version check on the once-per-session boot.45// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent46// can offer `npx impeccable update`. Everything here is best-effort and47// silent on failure: a network problem, sandbox, or missing cache must never48// block context output or print an error.4950const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, '');51const UPDATE_CACHE_PATH =52process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json');53const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day54const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week55const FETCH_TIMEOUT_MS = 1200;5657export function resolveContextDir(cwd = process.cwd(), options = {}) {58return resolveContext(cwd, options).contextDir;59}6061export function loadContext(cwd = process.cwd(), options = {}) {62const resolved = resolveContext(cwd, options);63const absCwd = path.resolve(cwd);64const productPath = resolved.productPath;65const designPath = resolved.designPath;66const product = productPath ? safeRead(productPath) : null;67const design = designPath ? safeRead(designPath) : null;68return {69hasProduct: !!product,70product,71productPath: productPath ? path.relative(absCwd, productPath) : null,72hasDesign: !!design,73design,74designPath: designPath ? path.relative(absCwd, designPath) : null,75contextDir: resolved.contextDir,76productContextDir: productPath ? path.dirname(productPath) : null,77designContextDir: designPath ? path.dirname(designPath) : null,78projectRoot: resolved.projectRoot,79repoRoot: resolved.repoRoot,80isMonorepo: resolved.isMonorepo,81};82}8384function resolveContext(cwd = process.cwd(), options = {}) {85const absCwd = path.resolve(cwd);86const project = resolveProject(absCwd, options);87const projectContextDir = resolveLocalContextDir(project.projectRoot);88const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot89? resolveLocalContextDir(project.repoRoot)90: null;9192let productPath =93(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)94|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);95let designPath =96(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)97|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);9899let envContextDir = null;100if (!productPath && !designPath) {101envContextDir = resolveEnvContextDir(absCwd);102if (envContextDir) {103productPath = firstExisting(envContextDir, PRODUCT_NAMES);104designPath = firstExisting(envContextDir, DESIGN_NAMES);105}106}107108return {109contextDir: productPath110? path.dirname(productPath)111: designPath112? path.dirname(designPath)113: envContextDir || project.projectRoot,114productPath,115designPath,116projectRoot: project.projectRoot,117repoRoot: project.repoRoot,118isMonorepo: project.isMonorepo,119targetDir: project.targetDir,120};121}122123export function resolveProjectRoot(cwd = process.cwd(), options = {}) {124return resolveProject(cwd, options).projectRoot;125}126127export function resolveTargetSelection(cwd = process.cwd(), options = {}) {128if (hasTargetOption(options)) return null;129const project = resolveProject(cwd);130if (131!project.isMonorepo132|| !project.projectRoot133|| !project.repoRoot134|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)135) {136return null;137}138const targetCandidates = discoverTargetCandidates(project.repoRoot);139// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,140// or a marker file with no apps/packages children): there is nothing to choose,141// so treat the repo root as the active project rather than blocking on an empty142// selection prompt that the user cannot answer.143if (targetCandidates.length === 0) return null;144return {145targetPath: null,146projectRoot: project.projectRoot,147repoRoot: project.repoRoot,148targetCandidates,149};150}151152function resolveProject(cwd = process.cwd(), options = {}) {153const absCwd = path.resolve(cwd);154const targetDir = resolveTargetDir(absCwd, options);155let repoRoot = findMonorepoRoot(targetDir);156if (!repoRoot && targetDir !== absCwd) {157const cwdRepoRoot = findMonorepoRoot(absCwd);158if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {159repoRoot = cwdRepoRoot;160}161}162if (!repoRoot) {163return {164targetDir,165projectRoot: absCwd,166repoRoot: absCwd,167isMonorepo: false,168};169}170return {171targetDir,172projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,173repoRoot,174isMonorepo: true,175};176}177178function isPathInside(candidate, root) {179const rel = path.relative(root, candidate);180return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);181}182183function resolveLocalContextDir(root) {184if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {185return root;186}187for (const rel of FALLBACK_DIRS) {188const candidate = path.resolve(root, rel);189if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {190return candidate;191}192}193return null;194}195196function resolveEnvContextDir(cwd) {197const envDir = process.env.IMPECCABLE_CONTEXT_DIR;198if (!envDir || !envDir.trim()) return null;199const trimmed = envDir.trim();200return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);201}202203function resolveTargetDir(cwd, options = {}) {204const targetPath = options && typeof options === 'object' ? options.targetPath : null;205if (!targetPath || !String(targetPath).trim()) return cwd;206const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);207try {208const stat = fs.statSync(abs);209return stat.isDirectory() ? abs : path.dirname(abs);210} catch {211return path.extname(abs) ? path.dirname(abs) : abs;212}213}214215function findMonorepoRoot(startDir) {216let dir = path.resolve(startDir);217const homeDir = path.resolve(os.homedir());218while (true) {219if (dir === homeDir) return null;220// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace221// root that also carries its own .git is still recognized. The trade-off is222// deliberate — a directory with a monorepo *marker* but no workspace patterns223// and no apps/packages children is not a monorepo root, so its .git stops224// traversal and a further-up root is not searched. The nested .git is treated225// as an independent project boundary, which is the intended isolation.226if (isMonorepoRoot(dir)) return dir;227if (hasGitBoundary(dir)) return null;228const parent = path.dirname(dir);229if (parent === dir) return null;230dir = parent;231}232}233234function isMonorepoRoot(dir) {235if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;236if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;237return hasFallbackWorkspaceChildren(dir);238}239240function hasGitBoundary(dir) {241return fs.existsSync(path.join(dir, '.git'));242}243244function hasFallbackWorkspaceChildren(dir) {245for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {246const base = path.join(dir, name);247let entries;248try {249entries = fs.readdirSync(base, { withFileTypes: true });250} catch {251continue;252}253if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;254}255return false;256}257258function discoverTargetCandidates(repoRoot) {259const roots = new Map();260const patterns = readWorkspacePatterns(repoRoot);261for (const pattern of patterns) {262for (const root of discoverRootsForPattern(repoRoot, pattern)) {263roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);264}265}266if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {267for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {268const base = path.join(repoRoot, name);269let entries;270try {271entries = fs.readdirSync(base, { withFileTypes: true });272} catch {273continue;274}275for (const entry of entries) {276if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;277const root = path.join(base, entry.name);278roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);279}280}281}282return [...roots.entries()]283.filter(([rel]) => rel && !rel.startsWith('..'))284// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot285// sends an excluded package back to the repo root, so an excluded folder must not appear as a286// selectable target — choosing it would silently resolve to the root instead.287.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))288.sort(([a], [b]) => a.localeCompare(b))289.map(([rel, root]) => {290const targetExample = findTargetExample(repoRoot, root);291return {292name: path.basename(root),293path: rel,294targetExample,295...resolveCandidateContextSummary(repoRoot, root, targetExample),296};297});298}299300function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {301const ctx = resolveContext(repoRoot, { targetPath });302return {303productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),304productPath: contextSourcePath(ctx.productPath, repoRoot),305designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),306designPath: contextSourcePath(ctx.designPath, repoRoot),307};308}309310// Selection candidates surface one of four statuses: 'child' (a canonical311// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the312// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'313// intentionally covers two non-canonical locations: a file inside the project314// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file315// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).316function contextSourceStatus(filePath, repoRoot, projectRoot) {317if (!filePath) return 'missing';318const absPath = path.resolve(filePath);319const absProjectRoot = path.resolve(projectRoot);320const absRepoRoot = path.resolve(repoRoot);321if (isPathInsideOrEqual(absPath, absProjectRoot)) {322return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';323}324if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {325return 'inherited';326}327return 'fallback';328}329330function contextSourcePath(filePath, repoRoot) {331if (!filePath) return null;332const rel = path.relative(repoRoot, filePath);333if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {334return rel.split(path.sep).join('/');335}336return filePath;337}338339function discoverRootsForPattern(repoRoot, rawPattern) {340const pattern = normalizeWorkspacePattern(rawPattern);341if (!pattern || pattern.startsWith('!')) return [];342const segments = pattern.split('/').filter(Boolean);343if (!segments.length) return [];344const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));345const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);346const base = path.join(repoRoot, ...literalPrefix);347if (!fs.existsSync(base)) return [];348if (segments.includes('**')) {349const packageRoots = [];350walkDirs(base, (dir) => {351if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);352});353if (packageRoots.length) return packageRoots;354return directChildDirs(base);355}356return expandSimplePattern(repoRoot, segments);357}358359function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {360if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];361const segment = patternSegments[index];362if (!segment.includes('*')) {363return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));364}365let entries;366try {367entries = fs.readdirSync(current, { withFileTypes: true });368} catch {369return [];370}371const roots = [];372for (const entry of entries) {373if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;374if (!segmentMatches(segment, entry.name)) continue;375roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));376}377return roots;378}379380function directChildDirs(dir) {381try {382return fs.readdirSync(dir, { withFileTypes: true })383.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))384.map((entry) => path.join(dir, entry.name));385} catch {386return [];387}388}389390function walkDirs(root, visit) {391let entries;392try {393entries = fs.readdirSync(root, { withFileTypes: true });394} catch {395return;396}397for (const entry of entries) {398if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;399const dir = path.join(root, entry.name);400visit(dir);401walkDirs(dir, visit);402}403}404405function isCandidateProjectRoot(dir) {406return !!(407fs.existsSync(path.join(dir, 'package.json'))408|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])409|| fs.existsSync(path.join(dir, 'src'))410|| fs.existsSync(path.join(dir, 'app'))411|| fs.existsSync(path.join(dir, 'pages'))412|| fs.existsSync(path.join(dir, 'public'))413);414}415416function isIgnoredWorkspaceDiscoveryDir(name) {417return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);418}419420function findTargetExample(repoRoot, projectRoot) {421const examples = [422'src/App.jsx',423'src/App.tsx',424'src/main.jsx',425'src/main.tsx',426'src/index.jsx',427'src/index.ts',428'app/page.tsx',429'pages/index.tsx',430'public/index.html',431];432for (const rel of examples) {433const abs = path.join(projectRoot, rel);434if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');435}436return path.relative(repoRoot, projectRoot).split(path.sep).join('/');437}438439function resolveWorkspaceProjectRoot(repoRoot, targetDir) {440const rel = path.relative(repoRoot, targetDir);441if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;442const relSegments = rel.split(path.sep).filter(Boolean);443const patterns = readWorkspacePatterns(repoRoot);444const excluded = isExcludedByWorkspacePattern(relSegments, patterns);445if (!excluded) {446for (const pattern of patterns) {447const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);448if (projectRoot) return projectRoot;449}450}451if (excluded) return repoRoot;452if (453relSegments.length >= 2454&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])455) {456return path.join(repoRoot, relSegments[0], relSegments[1]);457}458const nearest = nearestProjectLikeRoot(repoRoot, targetDir);459if (nearest) return nearest;460return repoRoot;461}462463function isExcludedByWorkspacePattern(relSegments, patterns) {464return patterns.some((rawPattern) => {465const pattern = normalizeWorkspacePattern(rawPattern);466if (!pattern.startsWith('!')) return false;467return workspacePatternMatchesRel(pattern.slice(1), relSegments);468});469}470471function nearestProjectLikeRoot(repoRoot, targetDir) {472let dir = path.resolve(targetDir);473const stop = path.resolve(repoRoot);474while (dir && dir !== stop) {475if (476firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])477|| fs.existsSync(path.join(dir, 'package.json'))478) {479return dir;480}481const parent = path.dirname(dir);482if (parent === dir) break;483dir = parent;484}485return null;486}487488function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {489let dir = path.resolve(targetDir);490const stop = path.resolve(stopDir || repoRoot);491const root = path.resolve(repoRoot);492while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {493if (fs.existsSync(path.join(dir, 'package.json'))) return dir;494const parent = path.dirname(dir);495if (parent === dir) break;496dir = parent;497}498return null;499}500501function isPathInsideOrEqual(candidate, root) {502return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);503}504505function workspacePatternMatchesRel(pattern, relSegments) {506const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);507if (!patternSegments.length) return false;508if (patternSegments.includes('**')) {509const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));510const literalPrefix = firstGlobIndex === -1511? patternSegments512: patternSegments.slice(0, firstGlobIndex);513if (relSegments.length < literalPrefix.length + 1) return false;514for (let i = 0; i < literalPrefix.length; i++) {515if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;516}517return true;518}519if (relSegments.length < patternSegments.length) return false;520for (let i = 0; i < patternSegments.length; i++) {521if (!segmentMatches(patternSegments[i], relSegments[i])) return false;522}523return true;524}525526function readWorkspacePatterns(repoRoot) {527return [528...readPackageWorkspaces(repoRoot),529...readPnpmWorkspaces(repoRoot),530...readLernaWorkspaces(repoRoot),531].filter(Boolean);532}533534function readPackageWorkspaces(repoRoot) {535const pkg = readJson(path.join(repoRoot, 'package.json'));536const workspaces = pkg?.workspaces;537if (Array.isArray(workspaces)) return workspaces;538if (Array.isArray(workspaces?.packages)) return workspaces.packages;539return [];540}541542function readLernaWorkspaces(repoRoot) {543const lerna = readJson(path.join(repoRoot, 'lerna.json'));544return Array.isArray(lerna?.packages) ? lerna.packages : [];545}546547function readPnpmWorkspaces(repoRoot) {548try {549const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');550const patterns = [];551let inPackages = false;552for (const line of body.split(/\r?\n/)) {553const trimmed = stripYamlInlineComment(line).trim();554if (!trimmed || trimmed.startsWith('#')) continue;555const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);556if (flowMatch) {557patterns.push(...parseYamlFlowList(flowMatch[1]));558inPackages = false;559continue;560}561if (/^packages:\s*$/.test(trimmed)) {562inPackages = true;563continue;564}565if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;566if (inPackages) {567const match = trimmed.match(/^-\s*(.+)$/);568if (match) patterns.push(unquoteYamlValue(match[1]));569}570}571return patterns;572} catch {573return [];574}575}576577function stripYamlInlineComment(line) {578let quote = null;579for (let i = 0; i < line.length; i++) {580const ch = line[i];581if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {582quote = quote === ch ? null : quote || ch;583continue;584}585if (ch === '#' && !quote) return line.slice(0, i);586}587return line;588}589590function parseYamlFlowList(body) {591const items = [];592let quote = null;593let current = '';594for (let i = 0; i < body.length; i++) {595const ch = body[i];596if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {597quote = quote === ch ? null : quote || ch;598current += ch;599continue;600}601if (ch === ',' && !quote) {602const value = unquoteYamlValue(current);603if (value) items.push(value);604current = '';605continue;606}607current += ch;608}609const value = unquoteYamlValue(current);610if (value) items.push(value);611return items;612}613614function unquoteYamlValue(value) {615return String(value || '')616.trim()617.replace(/^['"]|['"]$/g, '');618}619620function readJson(filePath) {621try {622return JSON.parse(fs.readFileSync(filePath, 'utf-8'));623} catch {624return null;625}626}627628function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {629const pattern = normalizeWorkspacePattern(rawPattern);630if (!pattern || pattern.startsWith('!')) return null;631const patternSegments = pattern.split('/').filter(Boolean);632if (!patternSegments.length) return null;633if (patternSegments.includes('**')) {634return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);635}636if (relSegments.length < patternSegments.length) return null;637for (let i = 0; i < patternSegments.length; i++) {638if (!segmentMatches(patternSegments[i], relSegments[i])) return null;639}640return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));641}642643function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {644const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));645const literalPrefix = firstGlobIndex === -1646? patternSegments647: patternSegments.slice(0, firstGlobIndex);648if (relSegments.length < literalPrefix.length + 1) return null;649for (let i = 0; i < literalPrefix.length; i++) {650if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;651}652const prefixDir = path.join(repoRoot, ...literalPrefix);653const targetDir = path.join(repoRoot, ...relSegments);654const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);655if (packageRoot) return packageRoot;656return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));657}658659function normalizeWorkspacePattern(pattern) {660return String(pattern || '')661.trim()662.replace(/^['"]|['"]$/g, '')663.replace(/^\.\//, '')664.replace(/\/+$/, '');665}666667function segmentMatches(patternSegment, relSegment) {668if (patternSegment === '*') return true;669if (!patternSegment.includes('*')) return patternSegment === relSegment;670const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);671return re.test(relSegment);672}673674function firstExisting(dir, names) {675for (const name of names) {676const abs = path.join(dir, name);677if (fs.existsSync(abs)) return abs;678}679return null;680}681682function safeRead(p) {683try {684return fs.readFileSync(p, 'utf-8');685} catch {686return null;687}688}689690function escapeRegExp(value) {691return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');692}693694/**695* Pull the register (`brand` or `product`) out of PRODUCT.md by looking696* for a `## Register` section and reading the first non-empty line that697* follows it. Returns null when the file is legacy / register-less.698*/699export function extractRegister(product) {700if (!product) return null;701const lines = product.split('\n');702for (let i = 0; i < lines.length; i++) {703if (/^##\s+Register\b/i.test(lines[i].trim())) {704for (let j = i + 1; j < lines.length; j++) {705const next = lines[j].trim();706if (!next) continue;707const word = next.toLowerCase();708if (word === 'brand' || word === 'product') return word;709return null;710}711}712}713return null;714}715716/**717* Read the installed skill's own version from the sibling SKILL.md frontmatter718* (this file lives at `<skill>/scripts/context.mjs`). Returns null when the719* frontmatter is missing or unreadable.720*/721function readLocalSkillVersion() {722try {723const here = path.dirname(fileURLToPath(import.meta.url));724const skillMd = path.join(here, '..', 'SKILL.md');725const content = fs.readFileSync(skillMd, 'utf-8');726const match = content.match(/^version:\s*(.+)$/m);727return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;728} catch {729return null;730}731}732733function readUpdateCache() {734try {735return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));736} catch {737return {};738}739}740741function writeUpdateCache(cache) {742try {743fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });744fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));745} catch {746// Best-effort: a read-only home dir just means we re-poll next session.747}748}749750/** Compare dotted numeric versions. Returns >0 when a is newer than b. */751function compareSemver(a, b) {752const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);753const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);754for (let i = 0; i < Math.max(pa.length, pb.length); i++) {755const diff = (pa[i] || 0) - (pb[i] || 0);756if (diff !== 0) return diff;757}758return 0;759}760761async function fetchLatestSkillVersion() {762try {763const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });764if (!res.ok) return null;765const data = await res.json();766return typeof data?.skills === 'string' ? data.skills : null;767} catch {768return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal769}770}771772function buildUpdateDirective(localVersion, latestVersion) {773return (774`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +775`(installed v${localVersion}, latest v${latestVersion}). ` +776`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +777`Update now? It runs \`npx impeccable update\`." ` +778`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +779`Either way, continue the current task without waiting, and do not raise this again.`780);781}782783/**784* Best-effort update directive for the boot output. Returns a string to append785* or null. Polls the version endpoint at most once per day (cached globally in786* the user's home dir) and re-surfaces a given version at most once per week so787* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.788*/789// Read the unified config's top-level `updateCheck` (local overrides shared).790// Inlined rather than importing hook-lib so the boot path stays lightweight.791function updateCheckDisabledByConfig(cwd = process.cwd()) {792let value;793for (const name of ['config.json', 'config.local.json']) {794try {795const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));796if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;797} catch { /* missing or malformed: ignore */ }798}799return value === false;800}801802async function computeUpdateDirective(now = Date.now()) {803try {804if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;805if (updateCheckDisabledByConfig()) return null;806const localVersion = readLocalSkillVersion();807if (!localVersion) return null;808809const cache = readUpdateCache();810811// Poll the network only when the throttle window has elapsed. Stamp812// lastCheck even on failure so an offline machine doesn't poll every boot.813if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {814const latest = await fetchLatestSkillVersion();815cache.lastCheck = now;816if (latest) cache.latestVersion = latest;817writeUpdateCache(cache);818}819820const latest = cache.latestVersion;821if (!latest || compareSemver(latest, localVersion) <= 0) return null;822823// Anti-nag: surface a given version at most once per RENOTIFY window.824if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {825return null;826}827cache.notifiedVersion = latest;828cache.notifiedAt = now;829writeUpdateCache(cache);830831return buildUpdateDirective(localVersion, latest);832} catch {833return null;834}835}836837async function cli() {838let cliOptions;839try {840cliOptions = parseCliOptions(process.argv.slice(2));841} catch (err) {842if (err?.name === 'TargetArgError') {843process.stderr.write(`${err.message}\n`);844process.exit(1);845}846throw err;847}848const targetProvided = hasTargetOption(cliOptions);849const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;850const selection = resolveTargetSelection(process.cwd(), cliOptions);851if (selection) {852process.stdout.write(buildTargetSelectionDirective(selection) + '\n');853process.exit(0);854}855const ctx = loadContext(process.cwd(), cliOptions);856const updateDirective = await computeUpdateDirective();857858if (!ctx.hasProduct) {859// Direct stdout message instead of relying on empty output as a signal860// — cheap models miss the empty case more often than the explicit one.861const parts = [862'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +863'Stop the current task, load reference/init.md, and follow its ' +864'instructions to write PRODUCT.md before resuming.',865];866parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));867if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {868parts.push(buildMissingTargetDirective());869}870if (updateDirective) parts.push(updateDirective);871process.stdout.write(parts.join('\n\n---\n\n') + '\n');872process.exit(0);873}874const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];875if (ctx.hasDesign) {876parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);877}878parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));879if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {880parts.push(buildMissingTargetDirective());881}882const register = extractRegister(ctx.product);883const next = register884? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`885: `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.`;886parts.push(next);887if (updateDirective) parts.push(updateDirective);888process.stdout.write(parts.join('\n\n---\n\n') + '\n');889}890891function parseCliOptions(args) {892return parseTargetOptions(args, { strict: true });893}894895function hasTargetOption(options) {896return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());897}898899function pathExistsForTarget(cwd, targetPath) {900const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);901return fs.existsSync(abs);902}903904function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {905const targetPath = hasTargetOption(options) ? options.targetPath : null;906return `RESOLVED_CONTEXT:\n${JSON.stringify({907targetPath,908...(targetPath ? { targetExists } : {}),909projectRoot: ctx.projectRoot,910repoRoot: ctx.repoRoot,911productPath: ctx.productPath,912designPath: ctx.designPath,913}, null, 2)}`;914}915916function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {917if (ctx.isMonorepo && targetProvided && targetExists === false) return true;918return !!(919ctx.isMonorepo920&& (!targetProvided || targetExists === false)921&& ctx.projectRoot922&& ctx.repoRoot923&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)924);925}926927function buildMissingTargetDirective() {928const script = process.argv[1] || 'context.mjs';929return (930'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +931'If the user named a file, route, or child app, do not answer from this output. ' +932`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`933);934}935936function buildTargetSelectionDirective(selection) {937return (938`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +939'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. ' +940'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +941'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'942);943}944945// Run cli() only when this module is the entry point. Compare realpaths946// rather than endsWith(): a loose suffix match also fires for unrelated947// scripts like `load-context.mjs`, and realpath tolerates symlinked948// invocation (the test harness symlinks the skill dir).949function invokedAsScript() {950const arg = process.argv[1];951if (!arg) return false;952try {953return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));954} catch {955return false;956}957}958959if (invokedAsScript()) {960cli();961}962