mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Field feedback from two more Codex sessions drove both changes.
JIT instructions (live/instructions.mjs): every event live-poll prints
now carries _instructions, the authoritative next step for that exact
situation with real ids, paths, and line numbers substituted, and only
the active path's rules (a svelte-component session never sees JSX
guidance). The boot payload carries loop instructions the same way.
Instructions are versioned with the scripts, so they cannot drift from
behavior, and live.md's plumbing can keep shrinking toward contract plus
craft guidance. The Codex poll-discipline failure observed in the field
("the long poll was started, but I yielded the task instead of actively
servicing its result") gets a named anti-pattern in both the harness
policy and the boot instructions.
LLM e2e agent: default provider/model moves from Claude Haiku 4.5 to
OpenAI gpt-5.6-terra at medium reasoning effort via an Anthropic-shaped
shim over the ai SDK (the three call sites stay provider-agnostic;
Anthropic and DeepSeek remain selectable). The harness should exercise
the model tier that actually drives live sessions. Both the react and
sveltekit fixtures pass end to end with terra driving the trimmed
live.md and the new _instructions.
This work was produced with AI assistance (Claude Code).
Co-Authored-By: Claude Code <noreply@anthropic.com>
352 lines
13 KiB
JavaScript
352 lines
13 KiB
JavaScript
/**
|
|
* CLI entry point: prepare everything needed to enter the live variant poll loop.
|
|
*
|
|
* Does (all in one command):
|
|
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
|
|
* 2. Start the live server in the background (or reuse a running one)
|
|
* 3. Inject the browser script tag into the project's entry file
|
|
* 4. Read PRODUCT.md / DESIGN.md for project context
|
|
* 5. Print a single JSON blob with everything the agent needs
|
|
*
|
|
* After this, the agent's only remaining steps are:
|
|
* - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
|
|
* - Enter the harness-native poll loop: `node live-poll.mjs`
|
|
*
|
|
* Usage:
|
|
* node live.mjs # Prepare everything, print JSON, exit
|
|
* node live.mjs --help
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { resolveTargetSelection } from './context.mjs';
|
|
import { resolveFiles } from './live-inject.mjs';
|
|
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
|
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
|
|
import { resolveLiveTarget } from './live-target.mjs';
|
|
import { bootInstructions } from './live/instructions.mjs';
|
|
import { resolveRoots, writeRootsManifest } from './live/roots.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
|
|
|
|
Prepare everything for live variant mode in a single command:
|
|
- Checks .impeccable/live/config.json (required, created once per project)
|
|
- Starts (or reuses) the live server in the background
|
|
- Injects the browser script tag
|
|
- Reads PRODUCT.md / DESIGN.md for project context
|
|
- Prepares the harness-native foreground/background poll loop
|
|
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
|
|
|
On success, prints a JSON blob with:
|
|
{ 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 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);
|
|
}
|
|
|
|
// Legacy workspace-monorepo selection first: it carries richer candidate
|
|
// metadata (context inheritance status) than the roots scan.
|
|
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 <path> only as a fallback or explicit path diagnostic.',
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
const rootsResult = resolveRoots({
|
|
cwd: liveTarget.originalCwd,
|
|
targetPath: liveTarget.absoluteTargetPath,
|
|
});
|
|
if (rootsResult.selection) {
|
|
console.log(JSON.stringify({
|
|
ok: false,
|
|
error: 'target_selection_required',
|
|
targetCandidates: rootsResult.selection.candidates,
|
|
hint: 'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target <path into that app>.',
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
const roots = rootsResult.manifest;
|
|
const activeCwd = roots.appRoot;
|
|
const outputTargetPath = liveTarget.targetPath || null;
|
|
|
|
// Gate on readable CONTENT, not path existence, so an empty or unreadable
|
|
// PRODUCT.md routes to init instead of passing the gate and then reporting
|
|
// hasProduct: false in the same payload.
|
|
const product = safeRead(roots.productPath);
|
|
const design = safeRead(roots.designPath);
|
|
const missingContext = [];
|
|
if (!product) missingContext.push('PRODUCT.md');
|
|
if (!design) missingContext.push('DESIGN.md');
|
|
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: roots.appRoot,
|
|
repoRoot: roots.repoRoot,
|
|
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
|
|
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
// Persist the decision before anything else spawns, so every helper the
|
|
// agent runs later (from any cwd inside the repo) lands on the same roots.
|
|
writeRootsManifest(roots);
|
|
|
|
// 1. Check config (fail fast if missing — no point starting anything else)
|
|
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 }),
|
|
targetPath: outputTargetPath,
|
|
projectRoot: roots.appRoot,
|
|
repoRoot: roots.repoRoot,
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
|
|
// 2. Start server (or reuse existing)
|
|
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), '--token', String(serverInfo.token)],
|
|
{ cwd: activeCwd },
|
|
);
|
|
const injectResult = safeParse(injectOut);
|
|
if (!injectResult || !injectResult.ok) {
|
|
console.log(JSON.stringify({
|
|
ok: false,
|
|
error: 'inject_failed',
|
|
detail: injectResult || injectOut,
|
|
serverPort: serverInfo.port,
|
|
}));
|
|
process.exit(1);
|
|
}
|
|
|
|
// 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(activeCwd, checkResult.config);
|
|
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
|
|
|
// 5. Emit everything the agent needs. The surface brief rides along so the
|
|
// agent does not spend three more tool calls (and a --help miss) on
|
|
// surface-brief.mjs before the first poll.
|
|
let surfaceBrief = null;
|
|
let surfaceBriefPath = null;
|
|
try {
|
|
const resolvedBrief = resolveSurfaceBrief(roots.appRoot, liveTarget.absoluteTargetPath || null);
|
|
if (resolvedBrief?.brief) {
|
|
surfaceBrief = resolvedBrief.brief.text ?? safeRead(resolvedBrief.brief.path);
|
|
surfaceBriefPath = resolvedBrief.brief.path
|
|
? path.relative(liveTarget.originalCwd, resolvedBrief.brief.path)
|
|
: null;
|
|
}
|
|
} catch { /* briefs are optional context */ }
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
serverPort: serverInfo.port,
|
|
serverToken: serverInfo.token,
|
|
pageFiles: resolvedFiles,
|
|
liveConfigPath: checkResult.path,
|
|
configDrift: drift,
|
|
targetPath: outputTargetPath,
|
|
projectRoot: roots.appRoot,
|
|
repoRoot: roots.repoRoot,
|
|
roots,
|
|
hasProduct: !!product,
|
|
product,
|
|
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
|
|
hasDesign: !!design,
|
|
design,
|
|
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
|
|
hasSurfaceBrief: !!surfaceBrief,
|
|
surfaceBrief,
|
|
surfaceBriefPath,
|
|
_instructions: bootInstructions({ scriptsPath: __dirname }),
|
|
}, null, 2));
|
|
}
|
|
|
|
function safeRead(p) {
|
|
if (!p) return null;
|
|
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
|
|
}
|
|
|
|
function relOrNull(base, p) {
|
|
return p ? path.relative(base, p) : null;
|
|
}
|
|
|
|
/**
|
|
* Drift-heal scan. Walks the project for HTML files under common
|
|
* page-source directories (public/, src/, app/, pages/) and reports any
|
|
* that aren't covered by the resolved inject targets. This is purely
|
|
* advisory — the agent can ignore it, or suggest the user add the
|
|
* orphans to config.files.
|
|
*
|
|
* Skipped if config.files already contains at least one glob pattern
|
|
* covering everything in practice (signaled by the orphan count being 0).
|
|
*/
|
|
function scanForDrift(rootDir, resolvedFiles, config) {
|
|
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
|
const IGNORE_DIRS = new Set([
|
|
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
|
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
|
]);
|
|
|
|
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
|
|
|
// Files matching the user's `exclude` globs are intentional omissions,
|
|
// not drift. Compile them to regexes so the orphan list stays signal.
|
|
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
|
.map((p) => globToRegex(p));
|
|
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
|
|
|
const orphans = [];
|
|
|
|
const walk = (dir, relBase) => {
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
catch { return; }
|
|
for (const e of entries) {
|
|
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
|
if (e.isDirectory()) {
|
|
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
|
walk(path.join(dir, e.name), rel);
|
|
} else if (e.isFile() && e.name.endsWith('.html')) {
|
|
if (resolvedSet.has(rel)) continue;
|
|
if (isUserExcluded(rel)) continue;
|
|
orphans.push(rel);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const root of SCAN_ROOTS) {
|
|
const abs = path.join(rootDir, root);
|
|
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
walk(abs, root);
|
|
}
|
|
}
|
|
|
|
if (orphans.length === 0) return null;
|
|
const capped = orphans.slice(0, 20);
|
|
return {
|
|
orphans: capped,
|
|
orphanCount: orphans.length,
|
|
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
|
* to avoid a circular import (live-inject.mjs already imports nothing
|
|
* from live.mjs). The two must stay in sync.
|
|
*/
|
|
function globToRegex(pattern) {
|
|
let re = '';
|
|
let i = 0;
|
|
while (i < pattern.length) {
|
|
const c = pattern[i];
|
|
if (c === '*') {
|
|
if (pattern[i + 1] === '*') {
|
|
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
|
else { re += '.*'; i += 2; }
|
|
} else {
|
|
re += '[^/]*';
|
|
i += 1;
|
|
}
|
|
} else if (c === '?') {
|
|
re += '[^/]';
|
|
i += 1;
|
|
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
|
re += '\\' + c;
|
|
i += 1;
|
|
} else {
|
|
re += c;
|
|
i += 1;
|
|
}
|
|
}
|
|
return new RegExp('^' + re + '$');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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: options.cwd || process.cwd(), timeout: 15_000 });
|
|
} catch (err) {
|
|
// execSync throws on non-zero exit; return stdout if any
|
|
return err.stdout || err.message || '';
|
|
}
|
|
}
|
|
|
|
function safeParse(out) {
|
|
try { return JSON.parse(String(out).trim()); } catch { return null; }
|
|
}
|
|
|
|
/**
|
|
* Return { pid, port, token } for the running live server, starting one if needed.
|
|
*/
|
|
function ensureServerRunning(cwd = process.cwd()) {
|
|
// Try to reuse an existing server
|
|
try {
|
|
const existing = readLiveServerInfo(cwd)?.info;
|
|
if (existing && existing.pid) {
|
|
try {
|
|
process.kill(existing.pid, 0); // throws if dead
|
|
return existing;
|
|
} catch { /* stale PID file — the server script will clean it up */ }
|
|
}
|
|
} catch { /* no PID file */ }
|
|
|
|
// Start a new server
|
|
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
|
return safeParse(out);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-execute
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const _running = process.argv[1];
|
|
if (_running?.endsWith('live.mjs') || _running?.endsWith('live.mjs/')) {
|
|
liveCli();
|
|
}
|