From 0306b4194907e2e9a9e11d8ddf273a6dc1ce89d1 Mon Sep 17 00:00:00 2001
From: Abdul Wahab <32850166+abdulwahabone@users.noreply.github.com>
Date: Sat, 20 Jun 2026 19:49:37 +0900
Subject: [PATCH] Add monorepo context support (#213)
Context files (PRODUCT.md / DESIGN.md) resolve child-first then fall back to the repo root, and /impeccable live lets the user pick a child app in a monorepo. Single-app behavior is unchanged. Closes #202. Co-Authored-By: abdulwahabone
---
scripts/test-suites.mjs | 4 +-
site/pages/changelog.astro | 3 +-
skill/SKILL.src.md | 2 +-
skill/reference/live.md | 2 +-
skill/scripts/context.mjs | 714 ++++++++++++++++++++++++-
skill/scripts/lib/impeccable-paths.mjs | 72 +--
skill/scripts/lib/target-args.mjs | 42 ++
skill/scripts/live-server.mjs | 17 +-
skill/scripts/live-target.mjs | 30 ++
skill/scripts/live.mjs | 93 +++-
tests/context.test.mjs | 520 +++++++++++++++++-
tests/impeccable-paths.test.mjs | 34 ++
tests/live-reference.test.mjs | 27 +
tests/live-target-context.test.mjs | 428 +++++++++++++++
tests/target-args.test.mjs | 20 +
15 files changed, 1911 insertions(+), 97 deletions(-)
create mode 100644 skill/scripts/lib/target-args.mjs
create mode 100644 skill/scripts/live-target.mjs
create mode 100644 tests/live-target-context.test.mjs
create mode 100644 tests/target-args.test.mjs
diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs
index afe89cdb1..24904e7ea 100644
--- a/scripts/test-suites.mjs
+++ b/scripts/test-suites.mjs
@@ -29,7 +29,7 @@ export const SUITES = {
/^site\/(pages|content|components|layouts)\//,
/^README(\.npm)?\.md$/,
/^cli\/bin\//,
- /^tests\/(build|cleanup-deprecated|cli-ignores|context|context-signals|critique-storage|design-parser|docs-integrity|hook|hook-build|impeccable-paths|shiki-theme|skills-cli|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
+ /^tests\/(build|cleanup-deprecated|cli-ignores|context|context-signals|critique-storage|design-parser|docs-integrity|hook|hook-build|impeccable-paths|shiki-theme|skills-cli|target-args|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
/^tests\/lib\//,
],
commands: [
@@ -61,6 +61,7 @@ export const SUITES = {
'tests/hook-build.test.mjs',
'tests/hook.test.mjs',
'tests/impeccable-paths.test.mjs',
+ 'tests/target-args.test.mjs',
'tests/shiki-theme.test.mjs',
'tests/test-suites.test.mjs',
'tests/zip.test.mjs',
@@ -139,6 +140,7 @@ export const SUITES = {
'tests/live-reference.test.mjs',
'tests/live-server.test.mjs',
'tests/live-session-store.test.mjs',
+ 'tests/live-target-context.test.mjs',
'tests/live-wrap.test.mjs',
'tests/live-wrap-buffer-aware.test.mjs',
],
diff --git a/site/pages/changelog.astro b/site/pages/changelog.astro
index 729bb0c05..4fdc45a3a 100644
--- a/site/pages/changelog.astro
+++ b/site/pages/changelog.astro
@@ -25,9 +25,10 @@ import '../styles/changelog-faq-kinpaku.css';
- The design hook now works in GitHub Copilot, so direct edits get the same post-edit design feedback that Claude Code, Codex, and Cursor already provide.
+ The next release extends Impeccable across more of your workflow: the design hook now runs in GitHub Copilot, and project context understands monorepos.
- Design hooks for GitHub Copilot. Installing the skill adds a
.github/hooks/impeccable.json hook that runs the detector after Copilot edits a UI file and feeds the findings back as a focused design reminder. It covers both the Copilot CLI and the cloud agent, and recognizes every edit path Copilot uses, including apply_patch.
+ - Monorepo-aware context. Open the repo root and Impeccable resolves
PRODUCT.md and DESIGN.md per app: each child app uses its own context files and falls back to the root files for anything it does not define. /impeccable live detects multiple apps, asks which one to work on, and then runs and stores live state inside that app.
diff --git a/skill/SKILL.src.md b/skill/SKILL.src.md
index b378800ac..30778142d 100644
--- a/skill/SKILL.src.md
+++ b/skill/SKILL.src.md
@@ -14,7 +14,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
You MUST do these steps before proceeding:
-1. Run `node {{scripts_path}}/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 {{scripts_path}}/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 {{scripts_path}}/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/skill/reference/live.md b/skill/reference/live.md
index 8871fd02e..c084d6d21 100644
--- a/skill/reference/live.md
+++ b/skill/reference/live.md
@@ -12,7 +12,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 {{scripts_path}}/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/skill/scripts/context.mjs b/skill/scripts/context.mjs
index 04f334554..28e7117ea 100644
--- a/skill/scripts/context.mjs
+++ b/skill/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/skill/scripts/lib/impeccable-paths.mjs b/skill/scripts/lib/impeccable-paths.mjs
index 30d24cadb..91121dd59 100644
--- a/skill/scripts/lib/impeccable-paths.mjs
+++ b/skill/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/skill/scripts/lib/target-args.mjs b/skill/scripts/lib/target-args.mjs
new file mode 100644
index 000000000..967925a42
--- /dev/null
+++ b/skill/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/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs
index 0fc4d61ba..27005ef3c 100644
--- a/skill/scripts/live-server.mjs
+++ b/skill/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/skill/scripts/live-target.mjs b/skill/scripts/live-target.mjs
new file mode 100644
index 000000000..498bc5519
--- /dev/null
+++ b/skill/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/skill/scripts/live.mjs b/skill/scripts/live.mjs
index 0992da1bd..e0bd1ad24 100644
--- a/skill/scripts/live.mjs
+++ b/skill/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/tests/context.test.mjs b/tests/context.test.mjs
index 4e7b698bd..bdf54536d 100644
--- a/tests/context.test.mjs
+++ b/tests/context.test.mjs
@@ -21,7 +21,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
-import { loadContext, resolveContextDir } from '../skill/scripts/context.mjs';
+import { loadContext, resolveContextDir, resolveProjectRoot } from '../skill/scripts/context.mjs';
import { fileURLToPath } from 'node:url';
const SCRIPT_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'scripts', 'context.mjs');
@@ -48,6 +48,12 @@ function write(rel, body = '# placeholder\n') {
return abs;
}
+function parseTargetSelection(stdout) {
+ const tail = stdout.split('TARGET_SELECTION_REQUIRED:\n')[1];
+ assert.ok(tail, `missing TARGET_SELECTION_REQUIRED block in:\n${stdout}`);
+ return JSON.parse(tail.split('\n\n')[0].trim());
+}
+
describe('resolveContextDir', () => {
it('returns cwd when PRODUCT.md is at the root', () => {
write('PRODUCT.md');
@@ -151,6 +157,514 @@ describe('loadContext', () => {
});
});
+describe('loadContext (monorepo project context)', () => {
+ function writeMonorepo() {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['apps/*', 'packages/*'],
+ }, null, 2));
+ write('turbo.json', JSON.stringify({ tasks: {} }));
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ for (const app of ['marketing', 'dashboard', 'admin']) {
+ write(`apps/${app}/src/App.jsx`, `export default function App() { return ${JSON.stringify(app)}; }\n`);
+ }
+ }
+
+ it('inherits root PRODUCT.md and DESIGN.md for child apps without project context', () => {
+ writeMonorepo();
+
+ for (const app of ['marketing', 'dashboard', 'admin']) {
+ const ctx = loadContext(scratch, { targetPath: `apps/${app}/src/App.jsx` });
+ assert.equal(ctx.hasProduct, true);
+ assert.equal(ctx.hasDesign, true);
+ assert.match(ctx.product, /Root product/);
+ assert.match(ctx.design, /Root design/);
+ assert.equal(ctx.productPath, 'PRODUCT.md');
+ assert.equal(ctx.designPath, 'DESIGN.md');
+ assert.equal(ctx.projectRoot, path.join(scratch, 'apps', app));
+ assert.equal(ctx.repoRoot, scratch);
+ assert.equal(ctx.isMonorepo, true);
+ }
+ });
+
+ it('lets child app context override root files independently', () => {
+ writeMonorepo();
+ write('apps/marketing/PRODUCT.md', '# Marketing product\n');
+ write('apps/marketing/DESIGN.md', '# Marketing design\n');
+ write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
+
+ const marketing = loadContext(scratch, { targetPath: 'apps/marketing/src/App.jsx' });
+ assert.match(marketing.product, /Marketing product/);
+ assert.match(marketing.design, /Marketing design/);
+ assert.equal(marketing.productPath, path.join('apps', 'marketing', 'PRODUCT.md'));
+ assert.equal(marketing.designPath, path.join('apps', 'marketing', 'DESIGN.md'));
+
+ const dashboard = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' });
+ assert.match(dashboard.product, /Dashboard product/);
+ assert.match(dashboard.design, /Root design/);
+ assert.equal(dashboard.productPath, path.join('apps', 'dashboard', 'PRODUCT.md'));
+ assert.equal(dashboard.designPath, 'DESIGN.md');
+
+ const admin = loadContext(scratch, { targetPath: 'apps/admin/src/App.jsx' });
+ assert.match(admin.product, /Root product/);
+ assert.match(admin.design, /Root design/);
+ assert.equal(admin.productPath, 'PRODUCT.md');
+ assert.equal(admin.designPath, 'DESIGN.md');
+ });
+
+ it('resolves child project roots from cwd inside a workspace', () => {
+ writeMonorepo();
+ const appDir = path.join(scratch, 'apps', 'dashboard');
+ const ctx = loadContext(appDir);
+ assert.match(ctx.product, /Root product/);
+ assert.match(ctx.design, /Root design/);
+ assert.equal(ctx.productPath, path.join('..', '..', 'PRODUCT.md'));
+ assert.equal(ctx.designPath, path.join('..', '..', 'DESIGN.md'));
+ assert.equal(resolveProjectRoot(appDir), appDir);
+ });
+
+ it('supports pnpm workspace patterns when resolving the active project', () => {
+ write('pnpm-workspace.yaml', 'packages:\n - "services/*"\n');
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('services/checkout/src/App.jsx');
+
+ const ctx = loadContext(scratch, { targetPath: 'services/checkout/src/App.jsx' });
+ assert.equal(ctx.projectRoot, path.join(scratch, 'services', 'checkout'));
+ assert.match(ctx.product, /Root product/);
+ assert.match(ctx.design, /Root design/);
+ });
+
+ it('supports pnpm workspace patterns with inline comments and flow arrays', () => {
+ write('pnpm-workspace.yaml', 'packages: ["services/*", "tools/*"] # workspace packages\n');
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('tools/inspector/PRODUCT.md', '# Inspector product\n');
+ write('tools/inspector/src/App.jsx');
+
+ const ctx = loadContext(scratch, { targetPath: 'tools/inspector/src/App.jsx' });
+ assert.equal(ctx.projectRoot, path.join(scratch, 'tools', 'inspector'));
+ assert.match(ctx.product, /Inspector product/);
+ assert.match(ctx.design, /Root design/);
+ });
+
+ it('honors negated pnpm workspace patterns', () => {
+ write('pnpm-workspace.yaml', 'packages:\n - "packages/**"\n - "!packages/private/**"\n');
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('packages/private/app/src/index.ts', 'export const hidden = true;\n');
+
+ const ctx = loadContext(scratch, { targetPath: 'packages/private/app/src/index.ts' });
+ assert.equal(ctx.projectRoot, scratch);
+ assert.match(ctx.product, /Root product/);
+ assert.match(ctx.design, /Root design/);
+ });
+
+ it('keeps unmatched child projects from being hijacked by an ancestor workspace', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['apps/*'],
+ }, null, 2));
+ write('PRODUCT.md', '# Ancestor product\n');
+ write('side-project/PRODUCT.md', '# Side project product\n');
+ write('side-project/src/App.jsx', 'export default null;\n');
+
+ const ctx = loadContext(path.join(scratch, 'side-project'), { targetPath: 'src/App.jsx' });
+ assert.equal(ctx.projectRoot, path.join(scratch, 'side-project'));
+ assert.match(ctx.product, /Side project product/);
+ assert.equal(ctx.productPath, 'PRODUCT.md');
+ });
+
+ it('does not reuse stale project resolution after workspace markers change', () => {
+ write('PRODUCT.md', '# Root product\n');
+ write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
+ write('apps/dashboard/src/App.jsx', 'export default null;\n');
+
+ const before = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' });
+ assert.equal(before.projectRoot, scratch);
+ assert.match(before.product, /Root product/);
+
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['apps/*'],
+ }, null, 2));
+
+ const after = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' });
+ assert.equal(after.projectRoot, path.join(scratch, 'apps', 'dashboard'));
+ assert.match(after.product, /Dashboard product/);
+ });
+
+ it('does not escape a nested git repo to an ancestor workspace', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['repos/*'],
+ }, null, 2));
+ write('PRODUCT.md', '# Outer product\n');
+ write('DESIGN.md', '# Outer design\n');
+ write('repos/standalone/.git/HEAD', 'ref: refs/heads/main\n');
+ write('repos/standalone/PRODUCT.md', '# Standalone product\n');
+ write('repos/standalone/src/App.jsx', 'export default null;\n');
+
+ const project = path.join(scratch, 'repos', 'standalone');
+ const ctx = loadContext(project, { targetPath: 'src/App.jsx' });
+ assert.equal(ctx.isMonorepo, false);
+ assert.equal(ctx.projectRoot, project);
+ assert.equal(ctx.repoRoot, project);
+ assert.match(ctx.product, /Standalone product/);
+ assert.equal(ctx.productPath, 'PRODUCT.md');
+ assert.equal(ctx.designPath, null);
+ });
+
+ it('resolves an explicit root target into a nested-git workspace child', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['repos/*'],
+ }, null, 2));
+ write('PRODUCT.md', '# Outer product\n');
+ write('DESIGN.md', '# Outer design\n');
+ write('repos/standalone/.git/HEAD', 'ref: refs/heads/main\n');
+ write('repos/standalone/PRODUCT.md', '# Standalone product\n');
+ write('repos/standalone/src/App.jsx', 'export default null;\n');
+
+ const project = path.join(scratch, 'repos', 'standalone');
+ const ctx = loadContext(scratch, { targetPath: 'repos/standalone/src/App.jsx' });
+ assert.equal(ctx.isMonorepo, true);
+ assert.equal(ctx.projectRoot, project);
+ assert.equal(ctx.repoRoot, scratch);
+ assert.match(ctx.product, /Standalone product/);
+ assert.match(ctx.design, /Outer design/);
+ assert.equal(ctx.productPath, path.join('repos', 'standalone', 'PRODUCT.md'));
+ assert.equal(ctx.designPath, 'DESIGN.md');
+ });
+
+ it('supports double-star workspace patterns by resolving the shallow child project', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['libs/**'],
+ }, null, 2));
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('libs/ui/PRODUCT.md', '# UI product\n');
+ write('libs/ui/src/index.ts', 'export const ui = true;\n');
+
+ const ctx = loadContext(scratch, { targetPath: 'libs/ui/src/index.ts' });
+ assert.equal(ctx.projectRoot, path.join(scratch, 'libs', 'ui'));
+ assert.match(ctx.product, /UI product/);
+ assert.match(ctx.design, /Root design/);
+ assert.equal(ctx.productPath, path.join('libs', 'ui', 'PRODUCT.md'));
+ assert.equal(ctx.designPath, 'DESIGN.md');
+ });
+
+ it('supports packages/**/* workspace patterns without promoting src folders to projects', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['packages/**/*'],
+ }, null, 2));
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('packages/dashboard/PRODUCT.md', '# Dashboard package product\n');
+ write('packages/dashboard/src/index.ts', 'export const dashboard = true;\n');
+
+ const ctx = loadContext(scratch, { targetPath: 'packages/dashboard/src/index.ts' });
+ assert.equal(ctx.projectRoot, path.join(scratch, 'packages', 'dashboard'));
+ assert.match(ctx.product, /Dashboard package product/);
+ assert.match(ctx.design, /Root design/);
+ assert.equal(ctx.productPath, path.join('packages', 'dashboard', 'PRODUCT.md'));
+ assert.equal(ctx.designPath, 'DESIGN.md');
+ });
+
+ it('supports packages/** workspace patterns for nested package roots', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['packages/**'],
+ }, null, 2));
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('packages/group/app/package.json', JSON.stringify({ name: '@acme/app' }, null, 2));
+ write('packages/group/app/PRODUCT.md', '# Group app product\n');
+ write('packages/group/app/src/index.ts', 'export const app = true;\n');
+
+ const ctx = loadContext(scratch, { targetPath: 'packages/group/app/src/index.ts' });
+ assert.equal(ctx.projectRoot, path.join(scratch, 'packages', 'group', 'app'));
+ assert.match(ctx.product, /Group app product/);
+ assert.match(ctx.design, /Root design/);
+ });
+
+ it('does not discover dependency or generated directories as workspace candidates', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['packages/**'],
+ }, null, 2));
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('packages/ui/package.json', JSON.stringify({ name: '@acme/ui' }, null, 2));
+ write('packages/ui/src/index.ts', 'export const ui = true;\n');
+ write('packages/ui/node_modules/dep/package.json', JSON.stringify({ name: 'dep' }, null, 2));
+ write('packages/ui/node_modules/dep/src/index.ts', 'export const dep = true;\n');
+ write('packages/ui/dist/package.json', JSON.stringify({ name: '@acme/ui-dist' }, null, 2));
+ write('packages/ui/dist/src/index.ts', 'export const dist = true;\n');
+
+ const res = spawnSync(process.execPath, [SCRIPT_PATH], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ const selection = parseTargetSelection(res.stdout);
+
+ assert.deepEqual(selection.targetCandidates.map((candidate) => candidate.path), ['packages/ui']);
+ });
+
+ it('uses apps and packages folders as a fallback when a monorepo marker exists', () => {
+ write('nx.json', '{}\n');
+ write('PRODUCT.md', '# Root product\n');
+ write('DESIGN.md', '# Root design\n');
+ write('packages/ui/src/index.ts');
+
+ const ctx = loadContext(scratch, { targetPath: 'packages/ui/src/index.ts' });
+ assert.equal(ctx.projectRoot, path.join(scratch, 'packages', 'ui'));
+ assert.match(ctx.product, /Root product/);
+ assert.match(ctx.design, /Root design/);
+ });
+
+ it('does not treat turbo.json alone as a monorepo marker', () => {
+ write('turbo.json', JSON.stringify({ tasks: {} }));
+ write('PRODUCT.md', '# Root product\n');
+ write('src/App.jsx', 'export default null;\n');
+
+ const ctx = loadContext(scratch);
+ assert.equal(ctx.isMonorepo, false);
+
+ const res = spawnSync(process.execPath, [SCRIPT_PATH], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ assert.doesNotMatch(res.stdout, /MONOREPO_TARGET_REQUIRED/);
+ });
+
+ it('supports --target in the CLI', async () => {
+ writeMonorepo();
+ write('apps/dashboard/PRODUCT.md', '# Dashboard product\n\n## Register\n\nproduct\n');
+ const { spawnSync } = await import('node:child_process');
+ const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'apps/dashboard/src/App.jsx'], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ assert.match(res.stdout, /# Dashboard product/);
+ assert.match(res.stdout, /# DESIGN\.md\n\n# Root design/);
+ assert.match(res.stdout, /RESOLVED_CONTEXT:/);
+ assert.match(res.stdout, /"targetPath": "apps\/dashboard\/src\/App\.jsx"/);
+ assert.match(res.stdout, /"productPath": "apps\/dashboard\/PRODUCT\.md"/);
+ assert.match(res.stdout, /"designPath": "DESIGN\.md"/);
+ assert.match(res.stdout, /NEXT STEP: This project's register is `product`\./);
+ });
+
+ it('asks for an app when the CLI runs from a monorepo root without selection', () => {
+ writeMonorepo();
+ const res = spawnSync(process.execPath, [SCRIPT_PATH], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/);
+ assert.match(res.stdout, /"targetPath": null/);
+ assert.match(res.stdout, /"path": "apps\/dashboard"/);
+ assert.match(res.stdout, /"targetExample": "apps\/dashboard\/src\/App\.jsx"/);
+ assert.match(res.stdout, /"productStatus": "inherited"/);
+ assert.match(res.stdout, /"productPath": "PRODUCT\.md"/);
+ assert.match(res.stdout, /"designStatus": "inherited"/);
+ assert.match(res.stdout, /"designPath": "DESIGN\.md"/);
+ assert.doesNotMatch(res.stdout, /# PRODUCT\.md/);
+ assert.doesNotMatch(res.stdout, /# DESIGN\.md/);
+ assert.doesNotMatch(res.stdout, /MONOREPO_TARGET_REQUIRED/);
+ });
+
+ it('describes child, inherited, and mixed context sources in app selection candidates', () => {
+ writeMonorepo();
+ write('apps/admin/PRODUCT.md', '# Admin product\n');
+ write('apps/admin/DESIGN.md', '# Admin design\n');
+ write('apps/marketing/PRODUCT.md', '# Marketing product\n');
+
+ const res = spawnSync(process.execPath, [SCRIPT_PATH], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ const selection = parseTargetSelection(res.stdout);
+ const byPath = Object.fromEntries(selection.targetCandidates.map((candidate) => [candidate.path, candidate]));
+
+ assert.deepEqual(
+ {
+ productStatus: byPath['apps/admin'].productStatus,
+ productPath: byPath['apps/admin'].productPath,
+ designStatus: byPath['apps/admin'].designStatus,
+ designPath: byPath['apps/admin'].designPath,
+ },
+ {
+ productStatus: 'child',
+ productPath: 'apps/admin/PRODUCT.md',
+ designStatus: 'child',
+ designPath: 'apps/admin/DESIGN.md',
+ },
+ );
+ assert.deepEqual(
+ {
+ productStatus: byPath['apps/dashboard'].productStatus,
+ productPath: byPath['apps/dashboard'].productPath,
+ designStatus: byPath['apps/dashboard'].designStatus,
+ designPath: byPath['apps/dashboard'].designPath,
+ },
+ {
+ productStatus: 'inherited',
+ productPath: 'PRODUCT.md',
+ designStatus: 'inherited',
+ designPath: 'DESIGN.md',
+ },
+ );
+ assert.deepEqual(
+ {
+ productStatus: byPath['apps/marketing'].productStatus,
+ productPath: byPath['apps/marketing'].productPath,
+ designStatus: byPath['apps/marketing'].designStatus,
+ designPath: byPath['apps/marketing'].designPath,
+ },
+ {
+ productStatus: 'child',
+ productPath: 'apps/marketing/PRODUCT.md',
+ designStatus: 'inherited',
+ designPath: 'DESIGN.md',
+ },
+ );
+ });
+
+ it('marks missing context files in app selection candidates', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['apps/*'],
+ }, null, 2));
+ write('PRODUCT.md', '# Root product\n');
+ write('apps/dashboard/src/App.jsx', 'export default null;\n');
+
+ const res = spawnSync(process.execPath, [SCRIPT_PATH], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ const selection = parseTargetSelection(res.stdout);
+ const dashboard = selection.targetCandidates.find((candidate) => candidate.path === 'apps/dashboard');
+ assert.equal(dashboard.productStatus, 'inherited');
+ assert.equal(dashboard.productPath, 'PRODUCT.md');
+ assert.equal(dashboard.designStatus, 'missing');
+ assert.equal(dashboard.designPath, null);
+ });
+
+ it('asks for app selection before init when root context is missing but child context exists', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['apps/*'],
+ }, null, 2));
+ write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
+ write('apps/dashboard/src/App.jsx', 'export default null;\n');
+
+ const res = spawnSync(process.execPath, [SCRIPT_PATH], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/);
+ assert.match(res.stdout, /"path": "apps\/dashboard"/);
+ assert.doesNotMatch(res.stdout, /^NO_PRODUCT_MD:/);
+ });
+
+ it('lets --target . explicitly select the monorepo root', () => {
+ writeMonorepo();
+ const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', '.'], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ assert.match(res.stdout, /# PRODUCT\.md\n\n# Root product/);
+ assert.match(res.stdout, /# DESIGN\.md\n\n# Root design/);
+ assert.match(res.stdout, /"targetPath": "\."/);
+ assert.doesNotMatch(res.stdout, /TARGET_SELECTION_REQUIRED/);
+ });
+
+ it('does not parse --help as a --target value', () => {
+ writeMonorepo();
+ const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', '--help'], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 1);
+ assert.match(res.stderr, /--target requires a path value/);
+ assert.equal(res.stdout, '');
+ });
+
+ it('uses the last --target value when duplicate target flags are provided', () => {
+ writeMonorepo();
+ write('apps/marketing/PRODUCT.md', '# Marketing product\n');
+ write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
+
+ const res = spawnSync(process.execPath, [
+ SCRIPT_PATH,
+ '--target', 'apps/marketing/src/App.jsx',
+ '--target', 'apps/dashboard/src/App.jsx',
+ ], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0, res.stderr);
+ assert.match(res.stdout, /# Dashboard product/);
+ assert.match(res.stdout, /"targetPath": "apps\/dashboard\/src\/App\.jsx"/);
+ assert.doesNotMatch(res.stdout, /# Marketing product/);
+ });
+
+ it('warns when --target names a missing path in a monorepo', () => {
+ writeMonorepo();
+ const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'apps/dashboard/routes/pricing'], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+
+ assert.equal(res.status, 0, res.stderr);
+ assert.match(res.stdout, /RESOLVED_CONTEXT:/);
+ assert.match(res.stdout, /"targetExists": false/);
+ assert.match(res.stdout, /MONOREPO_TARGET_REQUIRED/);
+ });
+
+ it('asks for app selection even when root PRODUCT.md is absent', () => {
+ write('package.json', JSON.stringify({
+ private: true,
+ workspaces: ['apps/*'],
+ }, null, 2));
+ write('apps/dashboard/PRODUCT.md', '# Dashboard product\n');
+ write('apps/dashboard/src/App.jsx', 'export default null;\n');
+
+ const res = spawnSync(process.execPath, [SCRIPT_PATH], {
+ cwd: scratch,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
+ });
+ assert.equal(res.status, 0);
+ assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/);
+ assert.match(res.stdout, /"path": "apps\/dashboard"/);
+ assert.doesNotMatch(res.stdout, /^NO_PRODUCT_MD:/);
+ });
+});
+
describe('loadContext (IMPECCABLE_CONTEXT_DIR escape hatch)', () => {
it('reads from the override path when defaults are empty', () => {
write('design/PRODUCT.md', '# overridden product\n');
@@ -257,6 +771,10 @@ describe('context.mjs update check', () => {
const skillScript = path.join(scratch, 'skill', 'scripts', 'context.mjs');
fs.mkdirSync(path.dirname(skillScript), { recursive: true });
fs.copyFileSync(SCRIPT_PATH, skillScript);
+ const targetArgsSrc = path.join(path.dirname(SCRIPT_PATH), 'lib', 'target-args.mjs');
+ const targetArgsDest = path.join(path.dirname(skillScript), 'lib', 'target-args.mjs');
+ fs.mkdirSync(path.dirname(targetArgsDest), { recursive: true });
+ fs.copyFileSync(targetArgsSrc, targetArgsDest);
fs.writeFileSync(
path.join(scratch, 'skill', 'SKILL.md'),
`---\nname: impeccable\nversion: ${LOCAL_VERSION}\n---\n\nbody\n`,
diff --git a/tests/impeccable-paths.test.mjs b/tests/impeccable-paths.test.mjs
index 3a8fb552a..f1d54a81e 100644
--- a/tests/impeccable-paths.test.mjs
+++ b/tests/impeccable-paths.test.mjs
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
import {
getDesignSidecarPath,
+ getCritiqueDir,
getLegacyLiveServerPath,
getLiveAnnotationsDir,
getLiveConfigPath,
@@ -80,6 +81,39 @@ describe('impeccable project paths', () => {
assert.equal(getLiveAnnotationsDir(tmp), join(tmp, '.impeccable', 'live', 'annotations'));
});
+ it('places .impeccable state under the active monorepo child project', () => {
+ writeFileSync(join(tmp, 'package.json'), JSON.stringify({
+ private: true,
+ workspaces: ['apps/*'],
+ }));
+ mkdirSync(join(tmp, 'apps', 'dashboard', 'src'), { recursive: true });
+ writeFileSync(join(tmp, 'apps', 'dashboard', 'src', 'App.jsx'), 'export default null;\n');
+ const options = { targetPath: 'apps/dashboard/src/App.jsx' };
+ const projectRoot = join(tmp, 'apps', 'dashboard');
+
+ assert.equal(getDesignSidecarPath(tmp, options), join(projectRoot, '.impeccable', 'design.json'));
+ assert.equal(getLiveConfigPath(tmp, options), join(projectRoot, '.impeccable', 'live', 'config.json'));
+ assert.equal(getLiveServerPath(tmp, options), join(projectRoot, '.impeccable', 'live', 'server.json'));
+ assert.equal(getLiveSessionsDir(tmp, options), join(projectRoot, '.impeccable', 'live', 'sessions'));
+ assert.equal(getLiveAnnotationsDir(tmp, options), join(projectRoot, '.impeccable', 'live', 'annotations'));
+ assert.equal(getCritiqueDir(tmp, options), join(projectRoot, '.impeccable', 'critique'));
+ assert.equal(getLegacyLiveServerPath(tmp, options), join(projectRoot, '.impeccable-live.json'));
+ assert.equal(resolveLiveConfigPath({ cwd: tmp, scriptsDir: join(tmp, 'scripts'), env: {}, targetPath: options.targetPath }), getLiveConfigPath(tmp, options));
+ });
+
+ it('does not let a root live config shadow a child project live config path', () => {
+ writeFileSync(join(tmp, 'turbo.json'), '{"tasks":{}}');
+ mkdirSync(join(tmp, 'apps', 'admin', 'src'), { recursive: true });
+ writeFileSync(join(tmp, 'apps', 'admin', 'src', 'App.jsx'), 'export default null;\n');
+ mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
+ writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), '{"source":"root"}');
+
+ assert.equal(
+ resolveLiveConfigPath({ cwd: tmp, scriptsDir: join(tmp, 'scripts'), env: {}, targetPath: 'apps/admin/src/App.jsx' }),
+ join(tmp, 'apps', 'admin', '.impeccable', 'live', 'config.json'),
+ );
+ });
+
it('reads new live server state before legacy recovery state', () => {
mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
writeFileSync(getLiveServerPath(tmp), JSON.stringify({ port: 8401, token: 'new' }));
diff --git a/tests/live-reference.test.mjs b/tests/live-reference.test.mjs
index 1f62cc8fa..f434bb16c 100644
--- a/tests/live-reference.test.mjs
+++ b/tests/live-reference.test.mjs
@@ -7,6 +7,33 @@ import { compileProviderBlocks } from '../scripts/lib/utils.js';
const ROOT = process.cwd();
describe('live reference authoring contract', () => {
+ it('keeps setup guidance focused on inferred target paths', () => {
+ const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
+ const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
+
+ assert.match(skillSrc, /infer the concrete path and run `node \{\{scripts_path\}\}\/context\.mjs --target ` instead/);
+ assert.doesNotMatch(skillSrc, /Use this same scripts directory for all Impeccable helper commands/);
+ assert.doesNotMatch(skillSrc, /walk upward for the nearest project `\.agents`, `\.claude`, or `\.cursor` skill/);
+ assert.doesNotMatch(skillSrc, /## Context diagnostics/);
+ assert.doesNotMatch(liveMd, /walk upward for the nearest project `\.agents`, `\.claude`, or `\.cursor` skill/);
+ });
+
+ it('keeps monorepo live guidance short and target-driven', () => {
+ const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
+ const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
+
+ assert.match(skillSrc, /--target /);
+ assert.doesNotMatch(skillSrc, /TARGET_SELECTION_REQUIRED/);
+ assert.doesNotMatch(skillSrc, /productStatus/);
+ assert.doesNotMatch(skillSrc, /designStatus/);
+ assert.match(liveMd, /infer the concrete path and run `node \{\{scripts_path\}\}\/live\.mjs --target ` instead/);
+ assert.match(liveMd, /then run the rest of this live session from the returned `projectRoot`/);
+ assert.doesNotMatch(liveMd, /target_selection_required/);
+ assert.doesNotMatch(liveMd, /rerun with the chosen app path as `--target`/);
+ assert.doesNotMatch(liveMd, /productStatus/);
+ assert.doesNotMatch(liveMd, /designStatus/);
+ });
+
it('keeps the live prompt focused on the foreground poll loop', () => {
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
const manualAgentMd = readFileSync(join(ROOT, 'skill/agents/impeccable-manual-edit-applier.md'), 'utf-8');
diff --git a/tests/live-target-context.test.mjs b/tests/live-target-context.test.mjs
new file mode 100644
index 000000000..9e715bf87
--- /dev/null
+++ b/tests/live-target-context.test.mjs
@@ -0,0 +1,428 @@
+import { describe, it, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { tmpdir } from 'node:os';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = join(__dirname, '..');
+const LIVE_SCRIPT = join(REPO_ROOT, 'skill', 'scripts', 'live.mjs');
+const LIVE_POLL_SCRIPT = join(REPO_ROOT, 'skill', 'scripts', 'live-poll.mjs');
+const LIVE_SERVER_SCRIPT = join(REPO_ROOT, 'skill', 'scripts', 'live-server.mjs');
+const TARGET = 'apps/dashboard/src/App.jsx';
+
+describe('live target-aware monorepo context', () => {
+ let tmp;
+
+ beforeEach(() => {
+ tmp = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-live-target-')));
+ setupMonorepo(tmp);
+ });
+
+ afterEach(() => {
+ stopLive(tmp);
+ rmSync(tmp, { recursive: true, force: true });
+ });
+
+ it('does not let root live config shadow the child project config path', () => {
+ writeRootLiveConfig(tmp);
+
+ const res = runNode(LIVE_SCRIPT, ['--target', TARGET], tmp);
+ assert.equal(res.status, 0, res.stderr);
+ const payload = JSON.parse(res.stdout);
+
+ assert.equal(payload.ok, false);
+ assert.equal(payload.error, 'config_missing');
+ assert.equal(payload.path, join(tmp, 'apps', 'dashboard', '.impeccable', 'live', 'config.json'));
+ assert.equal(payload.projectRoot, join(tmp, 'apps', 'dashboard'));
+ assert.equal(payload.repoRoot, tmp);
+ });
+
+ it('boots live from the child project and inherits root context when child files are missing', async () => {
+ writeChildLiveConfig(tmp);
+
+ const payload = bootLive(tmp);
+ try {
+ assert.equal(payload.ok, true);
+ assert.equal(payload.targetPath, TARGET);
+ assert.equal(payload.projectRoot, join(tmp, 'apps', 'dashboard'));
+ assert.equal(payload.repoRoot, tmp);
+ assert.equal(payload.productPath, 'PRODUCT.md');
+ assert.equal(payload.designPath, 'DESIGN.md');
+ assert.match(payload.product, /ROOT PRODUCT LIVE INHERIT/);
+ assert.match(payload.design, /ROOT DESIGN LIVE INHERIT/);
+ assert.deepEqual(payload.pageFiles, ['public/index.html']);
+ assert.equal(payload.liveConfigPath, join(tmp, 'apps', 'dashboard', '.impeccable', 'live', 'config.json'));
+
+ assert.equal(existsSync(join(tmp, 'apps', 'dashboard', '.impeccable', 'live', 'server.json')), true);
+ assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'server.json')), false);
+
+ const raw = await fetchDesignRaw(payload);
+ assert.match(raw, /ROOT DESIGN LIVE INHERIT/);
+ } finally {
+ stopLive(tmp);
+ }
+ });
+
+ it('continues the live lifecycle from the projectRoot returned by --target', () => {
+ writeChildLiveConfig(tmp);
+
+ const payload = bootLive(tmp);
+ try {
+ assert.equal(payload.ok, true);
+ assert.equal(payload.projectRoot, join(tmp, 'apps', 'dashboard'));
+
+ const poll = runNode(LIVE_POLL_SCRIPT, ['--timeout=50'], payload.projectRoot);
+ assert.equal(poll.status, 0, `stdout:\n${poll.stdout}\nstderr:\n${poll.stderr}`);
+ assert.deepEqual(JSON.parse(poll.stdout), { type: 'timeout' });
+
+ const stop = runNode(LIVE_SERVER_SCRIPT, ['stop', '--keep-inject'], payload.projectRoot);
+ assert.equal(stop.status, 0, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`);
+ assert.match(stop.stdout, /Stopped live server/);
+ } finally {
+ stopLive(tmp);
+ }
+ });
+
+ it('boots live from the child cwd without --target after the app is selected', async () => {
+ writeChildLiveConfig(tmp);
+
+ const childRoot = join(tmp, 'apps', 'dashboard');
+ const res = runNode(LIVE_SCRIPT, [], childRoot);
+ assert.equal(res.status, 0, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
+ const payload = JSON.parse(res.stdout);
+ try {
+ assert.equal(payload.ok, true);
+ assert.equal(payload.targetPath, null);
+ assert.equal(payload.projectRoot, childRoot);
+ assert.equal(payload.repoRoot, tmp);
+ assert.equal(payload.productPath, join('..', '..', 'PRODUCT.md'));
+ assert.equal(payload.designPath, join('..', '..', 'DESIGN.md'));
+ assert.match(payload.product, /ROOT PRODUCT LIVE INHERIT/);
+ assert.match(payload.design, /ROOT DESIGN LIVE INHERIT/);
+ assert.equal(payload.liveConfigPath, join(childRoot, '.impeccable', 'live', 'config.json'));
+ assert.equal(existsSync(join(childRoot, '.impeccable', 'live', 'server.json')), true);
+ assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'server.json')), false);
+ } finally {
+ stopLive(tmp);
+ }
+ });
+
+ it('boots live with child PRODUCT.md override and inherited root DESIGN.md', async () => {
+ writeChildLiveConfig(tmp);
+ write(tmp, 'apps/dashboard/PRODUCT.md', '# DASHBOARD PRODUCT LIVE OVERRIDE\n');
+
+ const payload = bootLive(tmp);
+ try {
+ assert.equal(payload.ok, true);
+ assert.equal(payload.productPath, join('apps', 'dashboard', 'PRODUCT.md'));
+ assert.equal(payload.designPath, 'DESIGN.md');
+ assert.match(payload.product, /DASHBOARD PRODUCT LIVE OVERRIDE/);
+ assert.match(payload.design, /ROOT DESIGN LIVE INHERIT/);
+
+ const raw = await fetchDesignRaw(payload);
+ assert.match(raw, /ROOT DESIGN LIVE INHERIT/);
+ assert.doesNotMatch(raw, /DASHBOARD PRODUCT LIVE OVERRIDE/);
+ } finally {
+ stopLive(tmp);
+ }
+ });
+
+ it('blocks live before server start when PRODUCT.md is missing everywhere', () => {
+ rmSync(join(tmp, 'PRODUCT.md'), { force: true });
+ writeChildLiveConfig(tmp);
+
+ const payload = runLiveContextMissing(tmp);
+
+ assert.deepEqual(payload.missing, ['PRODUCT.md']);
+ assert.equal(payload.nextCommand, 'init');
+ assert.equal(payload.projectRoot, join(tmp, 'apps', 'dashboard'));
+ assert.equal(payload.repoRoot, tmp);
+ assert.equal(payload.productPath, null);
+ assert.equal(payload.designPath, 'DESIGN.md');
+ assertNoLiveBootSideEffects(tmp);
+ });
+
+ it('blocks live before server start when DESIGN.md is missing everywhere', () => {
+ rmSync(join(tmp, 'DESIGN.md'), { force: true });
+ writeChildLiveConfig(tmp);
+
+ const payload = runLiveContextMissing(tmp);
+
+ assert.deepEqual(payload.missing, ['DESIGN.md']);
+ assert.equal(payload.nextCommand, 'document');
+ assert.equal(payload.projectRoot, join(tmp, 'apps', 'dashboard'));
+ assert.equal(payload.repoRoot, tmp);
+ assert.equal(payload.productPath, 'PRODUCT.md');
+ assert.equal(payload.designPath, null);
+ assertNoLiveBootSideEffects(tmp);
+ });
+
+ it('blocks live before server start with init first when both context files are missing', () => {
+ rmSync(join(tmp, 'PRODUCT.md'), { force: true });
+ rmSync(join(tmp, 'DESIGN.md'), { force: true });
+ writeChildLiveConfig(tmp);
+
+ const payload = runLiveContextMissing(tmp);
+
+ assert.deepEqual(payload.missing, ['PRODUCT.md', 'DESIGN.md']);
+ assert.equal(payload.nextCommand, 'init');
+ assert.equal(payload.productPath, null);
+ assert.equal(payload.designPath, null);
+ assertNoLiveBootSideEffects(tmp);
+ });
+
+ it('asks for an app before starting live from a monorepo root', () => {
+ writeRootLiveConfig(tmp);
+ writeChildLiveConfig(tmp);
+ write(tmp, 'apps/admin/PRODUCT.md', '# ADMIN PRODUCT LIVE\n');
+ write(tmp, 'apps/admin/DESIGN.md', '# ADMIN DESIGN LIVE\n');
+ write(tmp, 'apps/marketing/PRODUCT.md', '# MARKETING PRODUCT LIVE\n');
+
+ const res = runNode(LIVE_SCRIPT, [], tmp);
+ assert.equal(res.status, 0, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
+ const payload = JSON.parse(res.stdout);
+
+ assert.equal(payload.ok, false);
+ assert.equal(payload.error, 'target_selection_required');
+ assert.equal(payload.repoRoot, tmp);
+ assert.equal(payload.projectRoot, tmp);
+ assert.deepEqual(payload.targetCandidates.map((candidate) => candidate.path), [
+ 'apps/admin',
+ 'apps/dashboard',
+ 'apps/marketing',
+ ]);
+ const byPath = Object.fromEntries(payload.targetCandidates.map((candidate) => [candidate.path, candidate]));
+ assert.deepEqual(
+ {
+ productStatus: byPath['apps/admin'].productStatus,
+ productPath: byPath['apps/admin'].productPath,
+ designStatus: byPath['apps/admin'].designStatus,
+ designPath: byPath['apps/admin'].designPath,
+ },
+ {
+ productStatus: 'child',
+ productPath: 'apps/admin/PRODUCT.md',
+ designStatus: 'child',
+ designPath: 'apps/admin/DESIGN.md',
+ },
+ );
+ assert.deepEqual(
+ {
+ productStatus: byPath['apps/dashboard'].productStatus,
+ productPath: byPath['apps/dashboard'].productPath,
+ designStatus: byPath['apps/dashboard'].designStatus,
+ designPath: byPath['apps/dashboard'].designPath,
+ },
+ {
+ productStatus: 'inherited',
+ productPath: 'PRODUCT.md',
+ designStatus: 'inherited',
+ designPath: 'DESIGN.md',
+ },
+ );
+ assert.deepEqual(
+ {
+ productStatus: byPath['apps/marketing'].productStatus,
+ productPath: byPath['apps/marketing'].productPath,
+ designStatus: byPath['apps/marketing'].designStatus,
+ designPath: byPath['apps/marketing'].designPath,
+ },
+ {
+ productStatus: 'child',
+ productPath: 'apps/marketing/PRODUCT.md',
+ designStatus: 'inherited',
+ designPath: 'DESIGN.md',
+ },
+ );
+ assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'server.json')), false);
+ assert.equal(existsSync(join(tmp, 'apps', 'dashboard', '.impeccable', 'live', 'server.json')), false);
+ assert.doesNotMatch(readFileSync(join(tmp, 'public', 'root.html'), 'utf-8'), /live\.js/);
+ assert.doesNotMatch(readFileSync(join(tmp, 'apps', 'dashboard', 'public', 'index.html'), 'utf-8'), /live\.js/);
+ });
+
+});
+
+describe('live single-repo context setup guard', () => {
+ let tmp;
+
+ beforeEach(() => {
+ tmp = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-live-single-')));
+ });
+
+ afterEach(() => {
+ runNode(LIVE_SERVER_SCRIPT, ['stop', '--keep-inject'], tmp);
+ rmSync(tmp, { recursive: true, force: true });
+ });
+
+ it('starts live without app selection when PRODUCT.md and DESIGN.md exist', () => {
+ setupSingleRepo(tmp);
+
+ const res = runNode(LIVE_SCRIPT, [], tmp);
+ assert.equal(res.status, 0, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
+ const payload = JSON.parse(res.stdout);
+ try {
+ assert.equal(payload.ok, true);
+ assert.equal(payload.targetPath, null);
+ assert.equal(payload.projectRoot, tmp);
+ assert.equal(payload.repoRoot, tmp);
+ assert.equal(payload.productPath, 'PRODUCT.md');
+ assert.equal(payload.designPath, 'DESIGN.md');
+ assert.match(payload.product, /SINGLE PRODUCT/);
+ assert.match(payload.design, /SINGLE DESIGN/);
+ assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'server.json')), true);
+ } finally {
+ runNode(LIVE_SERVER_SCRIPT, ['stop', '--keep-inject'], tmp);
+ }
+ });
+
+ it('routes missing PRODUCT.md to init without app selection', () => {
+ setupSingleRepo(tmp, { product: false, design: true });
+
+ const payload = runSingleRepoMissingContext(tmp);
+
+ assert.deepEqual(payload.missing, ['PRODUCT.md']);
+ assert.equal(payload.nextCommand, 'init');
+ assert.equal(payload.productPath, null);
+ assert.equal(payload.designPath, 'DESIGN.md');
+ assertNoSingleRepoLiveBootSideEffects(tmp);
+ });
+
+ it('routes missing DESIGN.md to document without app selection', () => {
+ setupSingleRepo(tmp, { product: true, design: false });
+
+ const payload = runSingleRepoMissingContext(tmp);
+
+ assert.deepEqual(payload.missing, ['DESIGN.md']);
+ assert.equal(payload.nextCommand, 'document');
+ assert.equal(payload.productPath, 'PRODUCT.md');
+ assert.equal(payload.designPath, null);
+ assertNoSingleRepoLiveBootSideEffects(tmp);
+ });
+
+ it('routes missing PRODUCT.md and DESIGN.md to init first without app selection', () => {
+ setupSingleRepo(tmp, { product: false, design: false });
+
+ const payload = runSingleRepoMissingContext(tmp);
+
+ assert.deepEqual(payload.missing, ['PRODUCT.md', 'DESIGN.md']);
+ assert.equal(payload.nextCommand, 'init');
+ assert.equal(payload.productPath, null);
+ assert.equal(payload.designPath, null);
+ assertNoSingleRepoLiveBootSideEffects(tmp);
+ });
+});
+
+function setupMonorepo(root) {
+ run('git', ['init', '-q'], root);
+ write(root, 'package.json', JSON.stringify({ private: true, workspaces: ['apps/*'] }, null, 2));
+ write(root, 'turbo.json', JSON.stringify({ tasks: {} }, null, 2));
+ write(root, 'PRODUCT.md', '# ROOT PRODUCT LIVE INHERIT\n');
+ write(root, 'DESIGN.md', '# ROOT DESIGN LIVE INHERIT\n');
+ write(root, 'apps/dashboard/src/App.jsx', 'export default function Dashboard() { return Dashboard; }\n');
+ write(root, 'apps/dashboard/public/index.html', 'Dashboard\n');
+ write(root, 'apps/marketing/src/App.jsx', 'export default function Marketing() { return Marketing; }\n');
+ write(root, 'apps/admin/src/App.jsx', 'export default function Admin() { return Admin; }\n');
+}
+
+function setupSingleRepo(root, { product = true, design = true } = {}) {
+ run('git', ['init', '-q'], root);
+ write(root, 'package.json', JSON.stringify({ private: true, name: 'single-app' }, null, 2));
+ if (product) write(root, 'PRODUCT.md', '# SINGLE PRODUCT\n');
+ if (design) write(root, 'DESIGN.md', '# SINGLE DESIGN\n');
+ write(root, 'public/index.html', 'Single\n');
+ write(root, '.impeccable/live/config.json', JSON.stringify({
+ files: ['public/index.html'],
+ insertBefore: '
Root