diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index 5daefac54..837c87287 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -10,7 +10,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index bbb4ef8a2..bc1e3e9ea 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -10,7 +10,7 @@ Codex: run live helper commands, the app dev server, and any dependency-installi Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.agents/skills/impeccable/scripts/context.mjs b/.agents/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.agents/skills/impeccable/scripts/context.mjs +++ b/.agents/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.agents/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.agents/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.agents/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.agents/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.agents/skills/impeccable/scripts/lib/target-args.mjs b/.agents/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.agents/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.agents/skills/impeccable/scripts/live-target.mjs b/.agents/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.agents/skills/impeccable/scripts/live.mjs +++ b/.agents/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.claude/skills/impeccable/SKILL.md b/.claude/skills/impeccable/SKILL.md index 84651167e..f6e4a70c1 100644 --- a/.claude/skills/impeccable/SKILL.md +++ b/.claude/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.claude/skills/impeccable/reference/live.md b/.claude/skills/impeccable/reference/live.md index c9e83202e..8c2cccd0a 100644 --- a/.claude/skills/impeccable/reference/live.md +++ b/.claude/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.claude/skills/impeccable/scripts/context.mjs b/.claude/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.claude/skills/impeccable/scripts/context.mjs +++ b/.claude/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.claude/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.claude/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.claude/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.claude/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.claude/skills/impeccable/scripts/lib/target-args.mjs b/.claude/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.claude/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.claude/skills/impeccable/scripts/live-target.mjs b/.claude/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.claude/skills/impeccable/scripts/live.mjs +++ b/.claude/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.cursor/skills/impeccable/SKILL.md b/.cursor/skills/impeccable/SKILL.md index cb6f945f1..8becf719d 100644 --- a/.cursor/skills/impeccable/SKILL.md +++ b/.cursor/skills/impeccable/SKILL.md @@ -11,7 +11,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .cursor/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.cursor/skills/impeccable/reference/live.md b/.cursor/skills/impeccable/reference/live.md index ece6ac4f1..7867eaa03 100644 --- a/.cursor/skills/impeccable/reference/live.md +++ b/.cursor/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .cursor/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.cursor/skills/impeccable/scripts/context.mjs b/.cursor/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.cursor/skills/impeccable/scripts/context.mjs +++ b/.cursor/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.cursor/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.cursor/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.cursor/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.cursor/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.cursor/skills/impeccable/scripts/lib/target-args.mjs b/.cursor/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.cursor/skills/impeccable/scripts/live-target.mjs b/.cursor/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.cursor/skills/impeccable/scripts/live.mjs +++ b/.cursor/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.gemini/skills/impeccable/SKILL.md b/.gemini/skills/impeccable/SKILL.md index f304e3f65..4a6d4f589 100644 --- a/.gemini/skills/impeccable/SKILL.md +++ b/.gemini/skills/impeccable/SKILL.md @@ -10,7 +10,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .gemini/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.gemini/skills/impeccable/reference/live.md b/.gemini/skills/impeccable/reference/live.md index c87b4c21b..c405f2833 100644 --- a/.gemini/skills/impeccable/reference/live.md +++ b/.gemini/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .gemini/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.gemini/skills/impeccable/scripts/context.mjs b/.gemini/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.gemini/skills/impeccable/scripts/context.mjs +++ b/.gemini/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.gemini/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.gemini/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.gemini/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.gemini/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.gemini/skills/impeccable/scripts/lib/target-args.mjs b/.gemini/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.gemini/skills/impeccable/scripts/live-target.mjs b/.gemini/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.gemini/skills/impeccable/scripts/live.mjs +++ b/.gemini/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.github/skills/impeccable/SKILL.md b/.github/skills/impeccable/SKILL.md index 13c06212c..c3288b585 100644 --- a/.github/skills/impeccable/SKILL.md +++ b/.github/skills/impeccable/SKILL.md @@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .github/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.github/skills/impeccable/reference/live.md b/.github/skills/impeccable/reference/live.md index fd9d72e71..854ff85da 100644 --- a/.github/skills/impeccable/reference/live.md +++ b/.github/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .github/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.github/skills/impeccable/scripts/context.mjs b/.github/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.github/skills/impeccable/scripts/context.mjs +++ b/.github/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.github/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.github/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.github/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.github/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.github/skills/impeccable/scripts/lib/target-args.mjs b/.github/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.github/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.github/skills/impeccable/scripts/live-target.mjs b/.github/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.github/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.github/skills/impeccable/scripts/live.mjs +++ b/.github/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.kiro/skills/impeccable/SKILL.md b/.kiro/skills/impeccable/SKILL.md index 5dfc5338a..853b42c2d 100644 --- a/.kiro/skills/impeccable/SKILL.md +++ b/.kiro/skills/impeccable/SKILL.md @@ -11,7 +11,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .kiro/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .kiro/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .kiro/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.kiro/skills/impeccable/reference/live.md b/.kiro/skills/impeccable/reference/live.md index 134ac045f..00d1812c4 100644 --- a/.kiro/skills/impeccable/reference/live.md +++ b/.kiro/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .kiro/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.kiro/skills/impeccable/scripts/context.mjs b/.kiro/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.kiro/skills/impeccable/scripts/context.mjs +++ b/.kiro/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.kiro/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.kiro/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.kiro/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.kiro/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.kiro/skills/impeccable/scripts/lib/target-args.mjs b/.kiro/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.kiro/skills/impeccable/scripts/live-target.mjs b/.kiro/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.kiro/skills/impeccable/scripts/live.mjs +++ b/.kiro/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.opencode/skills/impeccable/SKILL.md b/.opencode/skills/impeccable/SKILL.md index dc22c1d99..4ed65b04f 100644 --- a/.opencode/skills/impeccable/SKILL.md +++ b/.opencode/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .opencode/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .opencode/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .opencode/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.opencode/skills/impeccable/reference/live.md b/.opencode/skills/impeccable/reference/live.md index 8804e24b9..bf49a7c40 100644 --- a/.opencode/skills/impeccable/reference/live.md +++ b/.opencode/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .opencode/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.opencode/skills/impeccable/scripts/context.mjs b/.opencode/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.opencode/skills/impeccable/scripts/context.mjs +++ b/.opencode/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.opencode/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.opencode/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.opencode/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.opencode/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.opencode/skills/impeccable/scripts/lib/target-args.mjs b/.opencode/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.opencode/skills/impeccable/scripts/live-target.mjs b/.opencode/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.opencode/skills/impeccable/scripts/live.mjs +++ b/.opencode/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.pi/skills/impeccable/SKILL.md b/.pi/skills/impeccable/SKILL.md index 89d2c2229..cf599d710 100644 --- a/.pi/skills/impeccable/SKILL.md +++ b/.pi/skills/impeccable/SKILL.md @@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .pi/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .pi/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .pi/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.pi/skills/impeccable/reference/live.md b/.pi/skills/impeccable/reference/live.md index 94814d43c..e8e3e1921 100644 --- a/.pi/skills/impeccable/reference/live.md +++ b/.pi/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .pi/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.pi/skills/impeccable/scripts/context.mjs b/.pi/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.pi/skills/impeccable/scripts/context.mjs +++ b/.pi/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.pi/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.pi/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.pi/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.pi/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.pi/skills/impeccable/scripts/lib/target-args.mjs b/.pi/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.pi/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.pi/skills/impeccable/scripts/live-target.mjs b/.pi/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.pi/skills/impeccable/scripts/live.mjs +++ b/.pi/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.qoder/skills/impeccable/SKILL.md b/.qoder/skills/impeccable/SKILL.md index c5e2558ac..c1892c5b3 100644 --- a/.qoder/skills/impeccable/SKILL.md +++ b/.qoder/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .qoder/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .qoder/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .qoder/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.qoder/skills/impeccable/reference/live.md b/.qoder/skills/impeccable/reference/live.md index a9cddb328..2b819b64c 100644 --- a/.qoder/skills/impeccable/reference/live.md +++ b/.qoder/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .qoder/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.qoder/skills/impeccable/scripts/context.mjs b/.qoder/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.qoder/skills/impeccable/scripts/context.mjs +++ b/.qoder/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.qoder/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.qoder/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.qoder/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.qoder/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.qoder/skills/impeccable/scripts/lib/target-args.mjs b/.qoder/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.qoder/skills/impeccable/scripts/live-target.mjs b/.qoder/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.qoder/skills/impeccable/scripts/live.mjs b/.qoder/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.qoder/skills/impeccable/scripts/live.mjs +++ b/.qoder/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.rovodev/skills/impeccable/SKILL.md b/.rovodev/skills/impeccable/SKILL.md index 0103d7530..ba8b8d50e 100644 --- a/.rovodev/skills/impeccable/SKILL.md +++ b/.rovodev/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .rovodev/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .rovodev/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .rovodev/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.rovodev/skills/impeccable/reference/live.md b/.rovodev/skills/impeccable/reference/live.md index b2af9c60e..d3d4f5caa 100644 --- a/.rovodev/skills/impeccable/reference/live.md +++ b/.rovodev/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .rovodev/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.rovodev/skills/impeccable/scripts/context.mjs b/.rovodev/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.rovodev/skills/impeccable/scripts/context.mjs +++ b/.rovodev/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.rovodev/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.rovodev/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.rovodev/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.rovodev/skills/impeccable/scripts/lib/target-args.mjs b/.rovodev/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.rovodev/skills/impeccable/scripts/live-target.mjs b/.rovodev/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.rovodev/skills/impeccable/scripts/live.mjs +++ b/.rovodev/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.trae-cn/skills/impeccable/SKILL.md b/.trae-cn/skills/impeccable/SKILL.md index 0da891bf2..426372fc6 100644 --- a/.trae-cn/skills/impeccable/SKILL.md +++ b/.trae-cn/skills/impeccable/SKILL.md @@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .trae-cn/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .trae-cn/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae-cn/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.trae-cn/skills/impeccable/reference/live.md b/.trae-cn/skills/impeccable/reference/live.md index f2b229ff7..ba78670e6 100644 --- a/.trae-cn/skills/impeccable/reference/live.md +++ b/.trae-cn/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae-cn/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.trae-cn/skills/impeccable/scripts/context.mjs b/.trae-cn/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.trae-cn/skills/impeccable/scripts/context.mjs +++ b/.trae-cn/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.trae-cn/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.trae-cn/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.trae-cn/skills/impeccable/scripts/lib/target-args.mjs b/.trae-cn/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.trae-cn/skills/impeccable/scripts/live-target.mjs b/.trae-cn/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.trae-cn/skills/impeccable/scripts/live.mjs +++ b/.trae-cn/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/.trae/skills/impeccable/SKILL.md b/.trae/skills/impeccable/SKILL.md index e773b252d..4ff89b7fe 100644 --- a/.trae/skills/impeccable/SKILL.md +++ b/.trae/skills/impeccable/SKILL.md @@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .trae/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .trae/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/.trae/skills/impeccable/reference/live.md b/.trae/skills/impeccable/reference/live.md index 253cb6958..c8279f19c 100644 --- a/.trae/skills/impeccable/reference/live.md +++ b/.trae/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/.trae/skills/impeccable/scripts/context.mjs b/.trae/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/.trae/skills/impeccable/scripts/context.mjs +++ b/.trae/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/.trae/skills/impeccable/scripts/lib/impeccable-paths.mjs b/.trae/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/.trae/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/.trae/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/.trae/skills/impeccable/scripts/lib/target-args.mjs b/.trae/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/.trae/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/.trae/skills/impeccable/scripts/live-target.mjs b/.trae/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/.trae/skills/impeccable/scripts/live.mjs +++ b/.trae/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); } diff --git a/plugin/skills/impeccable/SKILL.md b/plugin/skills/impeccable/SKILL.md index 84651167e..f6e4a70c1 100644 --- a/plugin/skills/impeccable/SKILL.md +++ b/plugin/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co You MUST do these steps before proceeding: -1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. +1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/context.mjs --target ` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task. 2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects. 3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins. 4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. diff --git a/plugin/skills/impeccable/reference/live.md b/plugin/skills/impeccable/reference/live.md index c9e83202e..8c2cccd0a 100644 --- a/plugin/skills/impeccable/reference/live.md +++ b/plugin/skills/impeccable/reference/live.md @@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR Execute in order. No step skipped, no step reordered. -1. `live.mjs`: boot. +1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/live.mjs --target ` instead; then run the rest of this live session from the returned `projectRoot`. 2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once. 3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`. diff --git a/plugin/skills/impeccable/scripts/context.mjs b/plugin/skills/impeccable/scripts/context.mjs index 04f334554..28e7117ea 100644 --- a/plugin/skills/impeccable/scripts/context.mjs +++ b/plugin/skills/impeccable/scripts/context.mjs @@ -5,11 +5,12 @@ * init 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 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty - * 4. cwd as a "nothing found" default + * 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 @@ -19,10 +20,25 @@ 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'; const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 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', +]); // ─── Update check ────────────────────────────────────────────────────────── // Piggyback a lightweight skill-version check on the once-per-session boot. @@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o 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()) { - 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 resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; } -export function loadContext(cwd = process.cwd()) { - const contextDir = resolveContextDir(cwd); - const productPath = firstExisting(contextDir, PRODUCT_NAMES); - const designPath = firstExisting(contextDir, DESIGN_NAMES); +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; return { hasProduct: !!product, product, - productPath: productPath ? path.relative(cwd, productPath) : null, + productPath: productPath ? path.relative(absCwd, productPath) : null, hasDesign: !!design, design, - designPath: designPath ? path.relative(cwd, designPath) : null, - contextDir, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + 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); + const rootContextDir = project.isMonorepo && 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; + } + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates: discoverTargetCandidates(project.repoRoot), + }; +} + +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: 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; + 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 (readWorkspacePatterns(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(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + 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('..')) + .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), + }; +} + +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); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + 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; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +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; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +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); @@ -89,6 +664,10 @@ function safeRead(p) { } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Pull the register (`brand` or `product`) out of PRODUCT.md by looking * for a `## Register` section and reading the first non-empty line that @@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) { } async function cli() { - const ctx = loadContext(process.cwd()); + 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) { @@ -244,6 +840,10 @@ async function cli() { 'Stop the current task, load reference/init.md, and follow its ' + 'instructions to write PRODUCT.md before resuming.', ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + 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); @@ -252,6 +852,10 @@ async function cli() { if (ctx.hasDesign) { parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } 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.` @@ -261,6 +865,60 @@ async function cli() { 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); +} + +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, + }, 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 diff --git a/plugin/skills/impeccable/scripts/lib/impeccable-paths.mjs b/plugin/skills/impeccable/scripts/lib/impeccable-paths.mjs index 30d24cadb..91121dd59 100644 --- a/plugin/skills/impeccable/scripts/lib/impeccable-paths.mjs +++ b/plugin/skills/impeccable/scripts/lib/impeccable-paths.mjs @@ -1,50 +1,52 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveProjectRoot } from '../context.mjs'; export const IMPECCABLE_DIR = '.impeccable'; export const LIVE_DIR = 'live'; export const CRITIQUE_DIR = 'critique'; -export function getImpeccableDir(cwd = process.cwd()) { - return path.join(cwd, IMPECCABLE_DIR); +export function getImpeccableDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); } -export function getDesignSidecarPath(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), 'design.json'); +export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), 'design.json'); } -export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { + const projectRoot = resolveProjectRoot(cwd, options); const candidates = [ - getDesignSidecarPath(cwd), - path.join(cwd, 'DESIGN.json'), + getDesignSidecarPath(cwd, options), + path.join(projectRoot, 'DESIGN.json'), ]; const contextLegacy = path.join(contextDir, 'DESIGN.json'); if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); return candidates; } -export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); } -export function getLiveDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), LIVE_DIR); +export function getLiveDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); } -export function getLiveConfigPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'config.json'); +export function getLiveConfigPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'config.json'); } export function getLegacyLiveConfigPath(scriptsDir) { return path.join(scriptsDir, 'config.json'); } -export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } - const primary = getLiveConfigPath(cwd); + const primary = getLiveConfigPath(cwd, { targetPath }); if (fs.existsSync(primary)) return primary; if (scriptsDir) { const legacy = getLegacyLiveConfigPath(scriptsDir); @@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p return primary; } -export function getLiveServerPath(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'server.json'); +export function getLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'server.json'); } -export function getLegacyLiveServerPath(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live.json'); +export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); } -export function readLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function readLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { @@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) { } } -export function writeLiveServerInfo(cwd = process.cwd(), info) { - const filePath = getLiveServerPath(cwd); +export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { + const filePath = getLiveServerPath(cwd, options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(info)); return filePath; } -export function removeLiveServerInfo(cwd = process.cwd()) { - for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { +export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { try { fs.unlinkSync(filePath); } catch {} } } -export function getLiveSessionsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'sessions'); +export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'sessions'); } -export function getLegacyLiveSessionsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'sessions'); +export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); } -export function getLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(getLiveDir(cwd), 'annotations'); +export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(getLiveDir(cwd, options), 'annotations'); } -export function getCritiqueDir(cwd = process.cwd()) { - return path.join(getImpeccableDir(cwd), CRITIQUE_DIR); +export function getCritiqueDir(cwd = process.cwd(), options = {}) { + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); } -export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { - return path.join(cwd, '.impeccable-live', 'annotations'); +export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); } function firstExisting(paths) { diff --git a/plugin/skills/impeccable/scripts/lib/target-args.mjs b/plugin/skills/impeccable/scripts/lib/target-args.mjs new file mode 100644 index 000000000..967925a42 --- /dev/null +++ b/plugin/skills/impeccable/scripts/lib/target-args.mjs @@ -0,0 +1,42 @@ +class TargetArgError extends Error { + constructor(message, code) { + super(message); + this.name = 'TargetArgError'; + this.code = code; + } +} + +export function parseTargetPath(args = [], { strict = false } = {}) { + let targetPath = null; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === '--target' || arg === '-t') { + const next = args[i + 1]; + if (next && !String(next).startsWith('-')) { + targetPath = String(next); + i++; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + continue; + } + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length); + if (value) { + targetPath = value; + continue; + } + if (strict) { + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); + } + } + } + return targetPath; +} + +export function parseTargetOptions(args = [], options = {}) { + const targetPath = parseTargetPath(args, options); + return targetPath ? { targetPath } : {}; +} diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index 0fc4d61ba..27005ef3c 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -21,7 +21,7 @@ import path from 'node:path'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; -import { resolveContextDir } from './context.mjs'; +import { loadContext } from './context.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 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()); +const PROJECT_CONTEXT = loadContext(process.cwd()); +const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; +const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath + ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) + : null; const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -371,10 +375,7 @@ 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. - try { - fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); - return true; - } catch { return false; } + return !!PROJECT_CONTEXT.hasProduct; } function statOrNull(filePath) { @@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const mdPath = DESIGN_MD_PATH; + const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); diff --git a/plugin/skills/impeccable/scripts/live-target.mjs b/plugin/skills/impeccable/scripts/live-target.mjs new file mode 100644 index 000000000..498bc5519 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-target.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { resolveProjectRoot } from './context.mjs'; +import { parseTargetPath } from './lib/target-args.mjs'; + +export function resolveLiveTarget(cwd = process.cwd(), args = []) { + const originalCwd = path.resolve(cwd); + let targetPath = null; + try { + targetPath = parseTargetPath(args, { strict: true }); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const absoluteTargetPath = targetPath + ? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath) + : null; + const projectRoot = targetPath + ? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath }) + : originalCwd; + return { + originalCwd, + projectRoot, + targetPath, + absoluteTargetPath, + targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {}, + }; +} diff --git a/plugin/skills/impeccable/scripts/live.mjs b/plugin/skills/impeccable/scripts/live.mjs index 0992da1bd..e0bd1ad24 100644 --- a/plugin/skills/impeccable/scripts/live.mjs +++ b/plugin/skills/impeccable/scripts/live.mjs @@ -21,14 +21,16 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadContext } from './context.mjs'; +import { loadContext, resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { resolveLiveTarget } from './live-target.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function liveCli() { const args = process.argv.slice(2); + const liveTarget = resolveLiveTarget(process.cwd(), args); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node live.mjs @@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command: - Starts (or reuses) the live server in the background - Injects the browser script tag - Reads PRODUCT.md / DESIGN.md for project context + - In monorepos, choose a child app first; --target is the fallback/manual path On success, prints a JSON blob with: - { ok, serverPort, serverToken, pageFile, hasContext, context } + { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath } + +On target_selection_required, prints: + { ok: false, error: "target_selection_required", targetCandidates } On config_missing, prints: { ok: false, error: "config_missing", configPath, hint } The agent should then: - 1. If config_missing, create the config and re-run this script - 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) - 3. Enter the poll loop: node live-poll.mjs`); + 1. If target_selection_required, ask which app to use and rerun from that child cwd + 2. If config_missing, create the config and re-run this script + 3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) + 4. Enter the poll loop: node live-poll.mjs`); + process.exit(0); + } + + const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions); + if (targetSelection) { + console.log(JSON.stringify({ + ok: false, + error: 'target_selection_required', + ...targetSelection, + hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.', + }, null, 2)); + process.exit(0); + } + + const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions); + const activeCwd = ctx.projectRoot; + const outputTargetPath = liveTarget.targetPath || null; + + const missingContext = missingLiveContext(ctx); + if (missingContext.length > 0) { + console.log(JSON.stringify({ + ok: false, + error: 'context_missing', + missing: missingContext, + nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document', + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)); process.exit(0); } // 1. Check config (fail fast if missing — no point starting anything else) - const checkOut = runScript('live-inject.mjs', ['--check']); + const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd }); const checkResult = safeParse(checkOut); if (!checkResult || !checkResult.ok) { - console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); + console.log(JSON.stringify({ + ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }), + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + })); process.exit(0); } // 2. Start server (or reuse existing) - const serverInfo = ensureServerRunning(); + const serverInfo = ensureServerRunning(activeCwd); if (!serverInfo) { console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); process.exit(1); } // 3. Inject the script tag at the current port - const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd }); const injectResult = safeParse(injectOut); if (!injectResult || !injectResult.ok) { console.log(JSON.stringify({ @@ -80,22 +123,23 @@ The agent should then: process.exit(1); } - // 4. Load PRODUCT.md + DESIGN.md context. - const ctx = loadContext(process.cwd()); - - // 5. Compute drift-heal: compare resolved inject targets against the + // 4. Compute drift-heal: compare resolved inject targets against the // project's HTML files. Orphans are HTML files not covered by config. // Warning only — the agent decides whether to act. - const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); - const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + const resolvedFiles = resolveFiles(activeCwd, checkResult.config); + const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config); - // 6. Emit everything the agent needs + // 5. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, pageFiles: resolvedFiles, + liveConfigPath: checkResult.path, configDrift: drift, + targetPath: outputTargetPath, + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -105,6 +149,13 @@ The agent should then: }, null, 2)); } +function missingLiveContext(ctx) { + const missing = []; + if (!ctx.hasProduct) missing.push('PRODUCT.md'); + if (!ctx.hasDesign) missing.push('DESIGN.md'); + return missing; +} + /** * Drift-heal scan. Walks the project for HTML files under common * page-source directories (public/, src/, app/, pages/) and reports any @@ -201,11 +252,11 @@ function globToRegex(pattern) { // Helpers // --------------------------------------------------------------------------- -function runScript(name, args) { +function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); + return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); } catch (err) { // execSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; @@ -219,10 +270,10 @@ function safeParse(out) { /** * Return { pid, port, token } for the running live server, starting one if needed. */ -function ensureServerRunning() { +function ensureServerRunning(cwd = process.cwd()) { // Try to reuse an existing server try { - const existing = readLiveServerInfo(process.cwd())?.info; + const existing = readLiveServerInfo(cwd)?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead @@ -232,7 +283,7 @@ function ensureServerRunning() { } catch { /* no PID file */ } // Start a new server - const out = runScript('live-server.mjs', ['--background']); + const out = runScript('live-server.mjs', ['--background'], { cwd }); return safeParse(out); }