diff --git a/DOCS.md b/DOCS.md index 3e62a12..9cce53f 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1891,7 +1891,8 @@ impactus`) is a thin brand wrapper over the real `pi` binary — NOT a fork: | `imp init [flags]` | The full impactus installer in place — every flag works (`imp init --harness-only -y`, `imp init --verify`, …). | | `imp update` | `npm install -g impactus@latest` + `pi update` (or install) + re-pin of the three Pi extension packages. Exit code keyed to the impactus self-update; the extension refresh is best-effort. | | `imp tui [args]` | Runs the project-stamped `imp/scripts/fia-tui.mjs` (errors with a `imp init` hint when the runtime is absent); `imp tui --once` passes through. | -| `imp doctor [--json]` | Read-only checkup — detection only, fixes nothing. Four sections: engines/subscriptions (Claude Code on PATH, the Codex login inside Pi, the Cursor CLI — all informative, never required), core CLIs (node floor, git, npm; gh/vercel as optional), Pi & imp (Pi version, the three pinned extension packages, the same update probe the launcher uses, timeboxed at 4 s), and — when run inside a project — the install: FIA runtime present, `.mcp.json` hygiene (an npx server without `-y` dies on a cold cache with "Connection closed") and a summarized `--verify` audit (full report stays in `npx impactus --verify`). Every finding ends in the exact command that fixes it. Exit 0 = no error-level finding; `--json` prints `{ ok, sections }` with no banner. | +| `imp doctor [--json]` | Read-only checkup — detection only, fixes nothing. Four sections: engines/subscriptions (Claude Code on PATH, the Codex login inside Pi, the Cursor CLI — all informative, never required), core CLIs (node floor, git, npm; gh/vercel as optional), Pi & imp (Pi version, the three pinned extension packages, the same update probe the launcher uses, timeboxed at 4 s), and — when run inside a project — the install: FIA runtime present, `.mcp.json` hygiene (an npx server without `-y` dies on a cold cache with "Connection closed"), the harness stamp state (missing/modified vs `imp/.harness-manifest.json`) and a summarized `--verify` audit (full report stays in `npx impactus --verify`). Every finding ends in the exact command that fixes it. Exit 0 = no error-level finding; `--json` prints `{ ok, sections }` with no banner. | +| `imp fix [flags]` | The remediating sibling of doctor — **restore-only**: it recreates what is MISSING (deleted harness files via `imp/.harness-manifest.json` + a fresh API download, runtime files the stamp manifest recorded, skills from skills-lock.json, the AGENTS.md harness block) and adds missing `-y` flags to npx MCP servers; it NEVER overwrites a file that exists with different content (those are reported as notes — updating outdated files stays with `--update-runtime`, adopting harness versions with `--agent-files replace`). A bare run prints the plan and asks one y/N; project-touching fixes require a clean git tree. Flags: `--dry-run` / `--json` (plan only, never mutate), `--yes` (skip the ask; required in CI — a non-TTY run without it prints the plan and exits 1), `--allow-dirty` (skip the git gate), `--commit` (one git commit per applied fix). Ends by re-planning: exit 0 only when nothing is left. | | `imp handoff [args]` | Runs the project-stamped `imp/scripts/handoff.mjs`: hands the newest interactive Pi conversation to the `claude` CLI with a continuation prompt pointing at the session transcript (same preamble the FDA relay uses). Works while Codex is down — that is the point. `--list` picks a session, `--session ` targets one, `--full` asks for a full transcript read, `--print` prints the prompt without launching. Also `npm run handoff`. | | `imp help` / `imp --version` | Help / bare version. | | anything else | Straight through to `pi` (e.g. `imp -p "prompt"`, `imp --continue`). | @@ -2011,6 +2012,7 @@ prompts (§6.1) fetch keys for you. ```bash imp doctor # read-only checkup: subscriptions, CLIs, Pi, project +imp fix # restore missing harness/runtime/skills files (plan + consent) imp update # impactus + Pi + the pinned extension packages npx impactus --update-runtime --dir . # new FDAs/gates/prompts into imp/ + .pi/ npx impactus --verify --dir . # audit that everything is still intact diff --git a/README.md b/README.md index 105ae21..21dd811 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ imp # open Pi here (installs Pi if it's missing) imp update # update impactus + Pi + the pinned Pi extensions imp tui # the project dashboard in the terminal imp doctor # read-only checkup: subscriptions, CLIs, Pi, project +imp fix # repair what doctor found (plan + consent; restores missing files only) imp handoff # continue the newest Pi conversation in `claude` # (Codex outage? your work keeps moving) ``` diff --git a/bin/imp.js b/bin/imp.js index 4da41eb..00e592d 100755 --- a/bin/imp.js +++ b/bin/imp.js @@ -63,6 +63,10 @@ Usage: imp tui Terminal dashboard — tasks, specs and runs (same as npm run tui) imp doctor Read-only checkup: subscriptions (Claude/Codex/Cursor), CLIs, Pi and this project (--json for machine output) + imp fix Repair what doctor found: shows the plan, asks first, + restores only what is MISSING (never overwrites edits). + Flags: --dry-run, --yes, --json (plan only), --commit + (one git commit per fix), --allow-dirty imp handoff Continue the newest Pi conversation in the \`claude\` CLI (works while Codex is down; --list picks a session) imp help Show this help @@ -151,6 +155,22 @@ if (cmd === 'doctor') { process.exit(healthy ? 0 : 1); } +if (cmd === 'fix') { + // Restore-only remediation with a consent gate — see src/steps/fix.js. + // `--json` prints the plan only (machine-readable output never mutates). + const json = rest.includes('--json'); + if (!json) banner(); + const { runFix } = await import('../src/steps/fix.js'); + const healthy = await runFix({ + json, + dryRun: rest.includes('--dry-run'), + yes: rest.includes('--yes') || rest.includes('-y'), + allowDirty: rest.includes('--allow-dirty'), + commit: rest.includes('--commit'), + }); + process.exit(healthy ? 0 : 1); +} + if (cmd === 'tui') { // The dashboard is stamped per project (imp/scripts/), not bundled here — // it must version-match the readers it depends on (decision record: diff --git a/src/lib/harness-manifest.js b/src/lib/harness-manifest.js new file mode 100644 index 0000000..100cb06 --- /dev/null +++ b/src/lib/harness-manifest.js @@ -0,0 +1,106 @@ +// The harness stamp manifest — `imp/.harness-manifest.json`, the harness +// counterpart of imp/.runtime-manifest.json. Written by setupHarness right +// after the merge, from the ADAPTED clone (README already renamed to +// imp/HARNESS.md, AGENTS.md removed for the marker merge, template-owned +// paths discarded), so its keys are exactly the project-relative paths the +// harness shipped. Each entry is either the sha1 of the file's harness +// content or `link:` for a symlink (the merge copies links verbatim, +// so the recorded target is what a healthy disk must show). +// +// The manifest makes the missing/pristine/modified classification possible +// for harness files — `imp doctor` reports it, `imp fix` restores the missing +// ones. A file the merge KEPT (the project's version won) records the HARNESS +// sha on purpose: it reads as "differs from the stamp", which is the truth, +// and keeps `imp fix` from ever overwriting it silently. + +import { createHash } from 'node:crypto'; +import { readFile, readdir, readlink, lstat, mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +export const HARNESS_MANIFEST = 'imp/.harness-manifest.json'; + +export function sha1(content) { + return createHash('sha1').update(content).digest('hex'); +} + +/** + * Walk the adapted harness clone and build the manifest map. Symlinks are + * recorded by target (`link:`), never followed — the merge copies + * them verbatim, and following one inside the clone could double-count its + * target's content. + * @returns {Promise>} rel path → sha1 | `link:` + */ +export async function collectHarnessManifest(cloneDir) { + const files = {}; + async function walk(rel) { + for (const entry of await readdir(join(cloneDir, rel), { withFileTypes: true })) { + if (entry.name === '.git') continue; + const entryRel = rel ? `${rel}/${entry.name}` : entry.name; + const full = join(cloneDir, entryRel); + if (entry.isSymbolicLink()) { + files[entryRel] = `link:${await readlink(full)}`; + } else if (entry.isDirectory()) { + await walk(entryRel); + } else { + files[entryRel] = sha1(await readFile(full)); + } + } + } + await walk(''); + return Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b))); +} + +/** Parsed manifest, or null when missing/invalid (older install — no baseline). */ +export async function readHarnessManifest(dir) { + try { + const parsed = JSON.parse(await readFile(join(dir, HARNESS_MANIFEST), 'utf8')); + return parsed && typeof parsed.files === 'object' ? parsed : null; + } catch { + return null; + } +} + +/** Write (replace) the manifest — the record of the LATEST stamp. */ +export async function writeHarnessManifest(dir, files, { stampedAt = new Date().toISOString() } = {}) { + const path = join(dir, HARNESS_MANIFEST); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ version: 1, stampedAt, files }, null, 2) + '\n', 'utf8'); +} + +/** + * Compare the manifest against the disk. + * missing → recorded but gone (or a broken link where a file should be) — restorable; + * modified → present but different (student edit, template-owned variant, retargeted link) — NEVER auto-touched; + * pristine → byte-identical to the stamp (for links: same target). + * @returns {Promise<{missing: string[], modified: string[], pristine: number}>} + */ +export async function classifyHarnessState(manifest, dir) { + const missing = []; + const modified = []; + let pristine = 0; + for (const [rel, expected] of Object.entries(manifest?.files ?? {})) { + const dest = join(dir, rel); + const st = await lstat(dest).catch(() => null); + if (!st) { + missing.push(rel); + continue; + } + if (String(expected).startsWith('link:')) { + const target = st.isSymbolicLink() ? await readlink(dest).catch(() => null) : null; + if (target === String(expected).slice(5)) pristine++; + else modified.push(rel); + continue; + } + if (st.isDirectory()) { + modified.push(rel); + continue; + } + // readFile follows symlinks: a link resolving to the stamped bytes counts + // as pristine; a DANGLING one reads as missing — restorable. + const content = await readFile(dest).catch(() => null); + if (content == null) missing.push(rel); + else if (sha1(content) === expected) pristine++; + else modified.push(rel); + } + return { missing, modified, pristine }; +} diff --git a/src/steps/doctor.js b/src/steps/doctor.js index 979ab0a..e089d1c 100644 --- a/src/steps/doctor.js +++ b/src/steps/doctor.js @@ -39,6 +39,7 @@ import { } from '../lib/pi-auth.js'; import { CLAUDE_INSTALL_HINT } from './preflight.js'; import { collectFindings } from './verify.js'; +import { classifyHarnessState, readHarnessManifest } from '../lib/harness-manifest.js'; const NODE_FLOOR = [22, 12]; @@ -203,6 +204,30 @@ async function projectSection(cwd) { rows.push(info('.mcp.json not found — no MCP servers registered for this project.')); } + // Harness stamp: classify against imp/.harness-manifest.json when the + // install recorded one. Missing files are fixable (`imp fix` restores + // them); files that differ are the student's (or template-owned) and are + // only reported — never an error. + const harnessManifest = await readHarnessManifest(cwd); + if (harnessManifest) { + const state = await classifyHarnessState(harnessManifest, cwd); + if (state.missing.length) { + rows.push( + warn( + `${state.missing.length} harness file(s) missing (deleted?) — e.g. ${state.missing.slice(0, 4).join(', ')}${state.missing.length > 4 ? ', …' : ''}. Restore with \`imp fix\`.`, + ), + ); + } + if (state.modified.length) { + rows.push(info(`${state.modified.length} harness file(s) differ from the stamp — your edits or template-owned variants (left alone).`)); + } + if (!state.missing.length && !state.modified.length) { + rows.push(ok(`Harness stamp intact (${state.pristine} file(s) match imp/.harness-manifest.json).`)); + } + } else if (existsSync(join(cwd, 'imp', 'HARNESS.md'))) { + rows.push(info('No harness stamp manifest (project from an older CLI) — the next `npx impactus --harness-only --dir .` run records one.')); + } + // Full install audit, summarized: the detailed report stays in --verify. try { const findings = await collectFindings(cwd); @@ -262,5 +287,8 @@ export async function runDoctor(flags = {}, impactusVersion = '0.0.0') { ? 'Everything the system needs is in place (○/⚠ items are optional or have their command above).' : pc.red('Problems found — each ✖ above ends with the command that fixes it.'), ); + if (report.sections.some((s) => s.rows.some((r) => r.level === 'warn' || r.level === 'error'))) { + console.log(pc.dim('Repairable findings can be applied with `imp fix` — it shows the plan and asks before touching anything.')); + } return report.ok; } diff --git a/src/steps/fix.js b/src/steps/fix.js new file mode 100644 index 0000000..adcd31c --- /dev/null +++ b/src/steps/fix.js @@ -0,0 +1,385 @@ +// `imp fix` — the remediating sibling of `imp doctor`. Contract (the norms +// every professional CLI converged on — cargo fix, ng update, expo install, +// copier): +// +// - A bare `imp fix` NEVER writes blind: it computes the plan, prints it, +// and asks ONE yes/no before applying (`--yes` skips the ask; without a +// TTY it prints the plan and exits 1 instead of hanging — CI-safe). +// - `--dry-run` and `--json` stop after the plan, always (machine-readable +// output never mutates the project). +// - Fixes that touch the project tree require a CLEAN git tree, so git +// itself is the undo — `--allow-dirty` is the named escape hatch, and +// `--commit` makes one commit per applied fix (à la ng update +// --create-commits). Machine-level fixes (Pi packages) skip the gate. +// - RESTORE-ONLY tier: every fix here recreates something missing or adds a +// flag — it never overwrites a file that exists with different content. +// Files that differ from their stamp are REPORTED (notes), never touched; +// updating outdated-but-present runtime files stays with +// `npx impactus --update-runtime` (which has its own per-file consent). +// - Idempotent: a second run right after a successful one finds nothing. +// - Exit 0 = nothing left to fix; 1 = fixes pending (plan modes) or a fix +// failed / findings remain (apply mode). + +import process from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { cp, mkdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import pc from 'picocolors'; +import { HARNESS } from '../config.js'; +import { run } from '../lib/proc.js'; +import { hasPi, installPiPackages, piPackageStatus, PI_PACKAGES } from '../lib/pi-auth.js'; +import { prunePiSkillCopies } from '../lib/skills.js'; +import { loadAuth, resolveApiBase } from '../lib/auth-client.js'; +import { downloadErrorMessage, fetchTemplateToDir } from '../lib/template-fetch.js'; +import { classifyHarnessState, readHarnessManifest } from '../lib/harness-manifest.js'; +import { mergeAgentsMd } from './harness.js'; +import { readRuntimeManifest, templateTrees } from './update-runtime.js'; + +const SAMPLE = (list, max = 4) => list.slice(0, max).join(', ') + (list.length > max ? ', …' : ''); + +/** + * Compute the fix plan: what is wrong AND how each item repairs it. Detection + * only — nothing is written until an item's `apply()` runs. `probes` lets + * tests bypass machine-level lookups (Pi on PATH, ~/.pi settings). + * @returns {Promise<{items: Array<{id,kind,label,detail,apply}>, notes: string[], cleanup: () => Promise}>} + */ +export async function collectFixPlan({ cwd = process.cwd(), probes = {} } = {}) { + const items = []; + const notes = []; + + // ── Machine tier: the Pi extension packages (no git involved) ───────────── + const piReady = await (probes.piReady ? probes.piReady() : hasPi()); + if (piReady) { + const status = (probes.piStatus ?? piPackageStatus)(); + const missingPkgs = PI_PACKAGES.filter((name) => status[name] === 'missing'); + if (missingPkgs.length) { + items.push({ + id: 'pi-packages', + kind: 'machine', + label: `Reinstall the missing Pi extension package(s): ${missingPkgs.join(', ')}`, + detail: 'Exact-pinned install into your Pi settings — the same thing `imp update` does.', + apply: async () => { + await installPiPackages(cwd); + return `${missingPkgs.length} package(s) reinstalled.`; + }, + }); + } + } + + // ── .mcp.json hygiene: npx servers need -y (cold cache kills the stdio) ─── + const mcpPath = join(cwd, '.mcp.json'); + if (existsSync(mcpPath)) { + try { + const mcp = JSON.parse(await readFile(mcpPath, 'utf8')); + const offenders = Object.entries(mcp?.mcpServers ?? {}) + .filter(([, cfg]) => { + if (cfg?.command !== 'npx') return false; + const args = Array.isArray(cfg?.args) ? cfg.args : []; + return !args.includes('-y') && !args.includes('--yes'); + }) + .map(([name]) => name); + if (offenders.length) { + items.push({ + id: 'mcp-npx-yes', + kind: 'project', + label: `Add "-y" to ${offenders.length} npx MCP server(s) in .mcp.json (${offenders.join(', ')})`, + detail: 'Without -y, a cold npx cache kills the server before the handshake ("Connection closed").', + apply: async () => { + const current = JSON.parse(await readFile(mcpPath, 'utf8')); + for (const name of offenders) { + const cfg = current.mcpServers[name]; + cfg.args = ['-y', ...(Array.isArray(cfg.args) ? cfg.args : [])]; + } + await writeFile(mcpPath, JSON.stringify(current, null, 2) + '\n', 'utf8'); + return `${offenders.length} server(s) fixed.`; + }, + }); + } + } catch { + notes.push('.mcp.json is not valid JSON — fix cannot repair a hand-edited file; restore it from git (or fix the syntax) first.'); + } + } + + // ── Agent skills: restore lock entries, prune legacy .pi/skills copies ──── + let lockNames = []; + try { + const lock = JSON.parse(await readFile(join(cwd, 'skills-lock.json'), 'utf8')); + lockNames = Object.keys(lock?.skills ?? {}); + } catch { + /* no lockfile — no official skills to audit */ + } + const missingSkills = lockNames.filter((n) => !existsSync(join(cwd, '.agents', 'skills', n))); + if (missingSkills.length) { + items.push({ + id: 'skills-missing', + kind: 'project', + label: `Restore ${missingSkills.length} agent skill(s) missing from .agents/skills/ (${SAMPLE(missingSkills)})`, + detail: 'Runs `npx skills experimental_install` — reinstalls exactly what skills-lock.json records.', + apply: async () => { + const r = await run('npx', ['skills', 'experimental_install'], { cwd, timeout: 300_000 }); + if (!r.ok) throw new Error(`npx skills experimental_install failed:\n${(r.stderr || r.stdout || '').trim().slice(-400)}`); + return `${missingSkills.length} skill(s) reinstalled.`; + }, + }); + } + const dupeSkills = existsSync(join(cwd, '.pi')) + ? lockNames.filter((n) => existsSync(join(cwd, '.pi', 'skills', n)) && existsSync(join(cwd, '.agents', 'skills', n))) + : []; + if (dupeSkills.length) { + items.push({ + id: 'pi-skill-dupes', + kind: 'project', + label: `Remove ${dupeSkills.length} duplicated skill copy(ies) from .pi/skills/ (${SAMPLE(dupeSkills)})`, + detail: 'Pi already reads .agents/skills/ — the copies only produce the "Skill conflicts" panel.', + apply: async () => `${await prunePiSkillCopies(cwd)} copy(ies) removed.`, + }); + } + + // ── FIA runtime: bring back files the STAMP recorded and the disk lost ──── + // Restore-only and manifest-gated on purpose: a file in the runtime manifest + // was stamped once, so its absence means "deleted" — recreating it from the + // bundled template is additive. Template files ABSENT from the manifest are + // new in a later runtime and belong to --update-runtime, not here. + const runtimeManifest = await readRuntimeManifest(cwd); + if (runtimeManifest) { + const goneRuntime = Object.keys(runtimeManifest.files ?? {}).filter((rel) => !existsSync(join(cwd, rel))); + if (goneRuntime.length) { + const trees = templateTrees(); + items.push({ + id: 'runtime-missing', + kind: 'project', + label: `Restore ${goneRuntime.length} missing FIA runtime file(s) (${SAMPLE(goneRuntime)})`, + detail: 'Recreated from the templates bundled in this impactus version; run `npx impactus --update-runtime` after, if you want everything current.', + apply: async () => { + let restored = 0; + const unavailable = []; + for (const rel of goneRuntime) { + const tree = trees.find((t) => rel === t.prefix || rel.startsWith(`${t.prefix}/`)); + const src = tree ? join(tree.src, rel.slice(tree.prefix.length + 1)) : null; + if (!src || !existsSync(src)) { + unavailable.push(rel); + continue; + } + await mkdir(dirname(join(cwd, rel)), { recursive: true }); + await cp(src, join(cwd, rel)); + restored++; + } + return ( + `${restored} file(s) restored.` + + (unavailable.length ? ` Not in the current templates (left out): ${SAMPLE(unavailable)}` : '') + ); + }, + }); + } + } + + // ── Harness: restore missing stamped files + the AGENTS.md block ────────── + // One lazy, memoized download serves both items; cleanup() removes the tmp + // clone after the apply loop (or immediately when nothing used it). + let cloneState = null; + const fetchClone = async () => { + if (cloneState) return cloneState; + const tmpRoot = mkdtempSync(join(tmpdir(), 'imp-fix-harness-')); + const cloneDir = join(tmpRoot, 'repo'); + const res = await fetchTemplateToDir(resolveApiBase({}), (await loadAuth())?.token ?? null, 'harness', cloneDir); + if (!res.ok) { + await rm(tmpRoot, { recursive: true, force: true }); + throw new Error(downloadErrorMessage('harness', res.reason)); + } + // Same adaptation the stamp applied, so clone paths line up with manifest keys. + let agentsMd = null; + if (existsSync(join(cloneDir, 'README.md'))) { + await mkdir(dirname(join(cloneDir, HARNESS.readmeAs)), { recursive: true }); + await rename(join(cloneDir, 'README.md'), join(cloneDir, HARNESS.readmeAs)); + } + if (existsSync(join(cloneDir, 'AGENTS.md'))) { + agentsMd = await readFile(join(cloneDir, 'AGENTS.md'), 'utf8'); + await rm(join(cloneDir, 'AGENTS.md'), { force: true }); + } + cloneState = { tmpRoot, cloneDir, agentsMd }; + return cloneState; + }; + const cleanup = async () => { + if (cloneState) await rm(cloneState.tmpRoot, { recursive: true, force: true }); + cloneState = null; + }; + + const harnessManifest = await readHarnessManifest(cwd); + if (harnessManifest) { + const state = await classifyHarnessState(harnessManifest, cwd); + if (state.missing.length) { + items.push({ + id: 'harness-missing', + kind: 'project', + label: `Restore ${state.missing.length} missing harness file(s) (${SAMPLE(state.missing)})`, + detail: 'Re-downloads the harness from the community API and copies ONLY the missing paths — existing files are never touched.', + apply: async () => { + const { cloneDir } = await fetchClone(); + let restored = 0; + const leftovers = []; + for (const rel of state.missing) { + const entry = String(harnessManifest.files[rel]); + const dest = join(cwd, rel); + await mkdir(dirname(dest), { recursive: true }); + if (entry.startsWith('link:')) { + // A dangling link at dest would have classified as missing — + // remove the husk before recreating, then link to the stamped target. + await rm(dest, { force: true }); + await symlink(entry.slice(5), dest); + restored++; + } else if (existsSync(join(cloneDir, rel))) { + await rm(dest, { force: true }); + await cp(join(cloneDir, rel), dest); + restored++; + } else { + leftovers.push(rel); // the current harness no longer ships it + } + } + return ( + `${restored} file(s) restored.` + + (leftovers.length ? ` No longer shipped by the harness (left out): ${SAMPLE(leftovers)}` : '') + ); + }, + }); + } + if (state.modified.length) { + notes.push( + `${state.modified.length} harness file(s) differ from the stamp (${SAMPLE(state.modified)}) — ` + + 'your edits or template-owned variants; left alone. To adopt the harness version, re-run the installer with --agent-files replace.', + ); + } + + // The AGENTS.md harness block (merged by marker, so it lives outside the manifest). + const agentsPath = join(cwd, 'AGENTS.md'); + const agentsContent = existsSync(agentsPath) ? await readFile(agentsPath, 'utf8') : ''; + if (!agentsContent.includes(HARNESS.markerStart)) { + items.push({ + id: 'agents-md-block', + kind: 'project', + label: existsSync(agentsPath) + ? 'Re-append the harness block to AGENTS.md (markers not found)' + : 'Recreate AGENTS.md with the harness block', + detail: 'Same idempotent marker merge the installer uses — your own AGENTS.md content is kept.', + apply: async () => { + const { agentsMd } = await fetchClone(); + if (!agentsMd) return 'The current harness ships no AGENTS.md — nothing to merge.'; + return `AGENTS.md: harness block ${await mergeAgentsMd(agentsPath, agentsMd)}.`; + }, + }); + } + } + + return { items, notes, cleanup }; +} + +/** True when the folder's git tree is clean (no repo counts as NOT clean). */ +async function gitClean(cwd) { + const r = await run('git', ['status', '--porcelain'], { cwd }); + return r.ok && r.stdout.trim() === ''; +} + +const KIND_TAG = { machine: pc.dim('[machine]'), project: pc.dim('[project]') }; + +function printPlan(items, notes) { + console.log(pc.bold(`imp fix — ${items.length} fix(es) to apply`)); + items.forEach((item, i) => { + console.log(` ${i + 1}. ${item.label} ${KIND_TAG[item.kind] ?? ''}`); + console.log(pc.dim(` ${item.detail}`)); + }); + for (const note of notes) console.log(pc.yellow(` ⚠ ${note}`)); +} + +/** + * Entry point of `imp fix`. + * @returns {Promise} true = nothing pending (and, in apply mode, nothing failed). + */ +export async function runFix(flags = {}) { + const cwd = process.cwd(); + const plan = await collectFixPlan({ cwd }); + const { items, notes } = plan; + + try { + // ── Plan-only modes ────────────────────────────────────────────────────── + if (flags.json) { + console.log( + JSON.stringify( + { ok: items.length === 0, pending: items.map(({ id, kind, label, detail }) => ({ id, kind, label, detail })), notes }, + null, + 2, + ), + ); + return items.length === 0; + } + if (items.length === 0) { + console.log('Nothing to fix — everything `imp fix` knows how to repair is in place.'); + for (const note of notes) console.log(pc.yellow(` ⚠ ${note}`)); + if (notes.length) console.log(pc.dim('Notes are report-only: fix never overwrites a file that exists with different content.')); + return true; + } + printPlan(items, notes); + if (flags.dryRun) { + console.log(pc.dim('\n--dry-run: nothing was changed. Run `imp fix` (or `imp fix --yes`) to apply.')); + return false; + } + + // ── Git gate (project-tier fixes only) ─────────────────────────────────── + const touchesProject = items.some((i) => i.kind === 'project'); + if (touchesProject && !flags.allowDirty && !(await gitClean(cwd))) { + console.log(''); + console.log(pc.red('Your git tree is not clean (or this folder has no git repo).')); + console.log('Commit or stash your changes first — git is the undo for everything fix touches.'); + console.log('To proceed anyway: imp fix --allow-dirty'); + return false; + } + + // ── Consent ────────────────────────────────────────────────────────────── + if (!flags.yes) { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + console.log(pc.yellow('\nNot a terminal — nothing was applied. Re-run with --yes to apply this plan.')); + return false; + } + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = (await rl.question(`\nApply these ${items.length} fix(es)? [y/N] `)).trim().toLowerCase(); + rl.close(); + if (answer !== 'y' && answer !== 'yes') { + console.log('Nothing was changed.'); + return false; + } + } + + // ── Apply ──────────────────────────────────────────────────────────────── + console.log(''); + const failures = []; + for (const item of items) { + try { + const outcome = await item.apply(); + console.log(` ✅ ${item.label}${outcome ? pc.dim(` — ${outcome}`) : ''}`); + if (flags.commit && item.kind === 'project') { + await run('git', ['add', '-A'], { cwd }); + await run('git', ['commit', '-q', '-m', `fix(imp): ${item.label}`], { cwd }); + } + } catch (err) { + failures.push(item); + console.log(pc.red(` ✖ ${item.label} — ${err?.message || err}`)); + } + } + + // ── Convergence check: a fixed project must re-plan to zero ───────────── + const after = await collectFixPlan({ cwd }); + await after.cleanup(); + const remaining = after.items; + console.log(''); + if (failures.length === 0 && remaining.length === 0) { + console.log(pc.bold('All fixes applied — `imp fix` finds nothing else.')); + if (!flags.commit) console.log(pc.dim('Review with `git diff` and commit when happy (or use `imp fix --commit` next time).')); + } else { + if (failures.length) console.log(pc.red(`${failures.length} fix(es) failed — see the ✖ lines above.`)); + if (remaining.length) console.log(pc.yellow(`${remaining.length} finding(s) still pending — run \`imp doctor\` for the full picture.`)); + } + return failures.length === 0 && remaining.length === 0; + } finally { + await plan.cleanup(); + } +} diff --git a/src/steps/harness.js b/src/steps/harness.js index 07789eb..26c7a2c 100644 --- a/src/steps/harness.js +++ b/src/steps/harness.js @@ -6,6 +6,7 @@ import { HARNESS } from '../config.js'; import { presentAgentFiles, backupAgentFiles } from '../lib/agent-backup.js'; import { run } from '../lib/proc.js'; import { downloadErrorMessage, fetchTemplateToDir } from '../lib/template-fetch.js'; +import { collectHarnessManifest, writeHarnessManifest } from '../lib/harness-manifest.js'; import { migrateLegacyFiaLayout } from './update-runtime.js'; import { toolPending } from './preflight.js'; import * as ui from '../lib/ui.js'; @@ -145,6 +146,18 @@ export async function setupHarness(ctx) { else if (result === 'created') ui.success('AGENTS.md created with the harness instructions.'); else ui.info('AGENTS.md already contained the harness block — kept.'); } + + // Stamp manifest — sha per file the (adapted) clone shipped, the baseline + // `imp doctor` classifies against and `imp fix` restores from. Files the + // merge KEPT record the harness sha on purpose ("differs from the stamp" + // is the truth for them). Best-effort: a failure here never aborts the + // install — doctor/fix just skip the harness checks. + try { + await writeHarnessManifest(dir, await collectHarnessManifest(tmpClone)); + ui.info('Harness stamp manifest recorded (imp/.harness-manifest.json — read by imp doctor/fix).'); + } catch (err) { + ui.warn(`Could not record the harness manifest: ${err?.message || err} — imp doctor/fix will skip the harness checks.`); + } } finally { await rm(tmpRoot, { recursive: true, force: true }); } @@ -270,9 +283,11 @@ function reportKeptFiles(keptFiles, { harnessOnly = true } = {}) { /** * Append the harness agent instructions to the project's AGENTS.md between * markers. Idempotent: if the start marker is already there, do nothing. + * Exported for `imp fix` (restoring a deleted harness block reuses the exact + * same merge). * @returns {Promise<'appended'|'created'|'skipped'>} */ -async function mergeAgentsMd(projectFile, harnessContent) { +export async function mergeAgentsMd(projectFile, harnessContent) { const block = [HARNESS.markerStart, '', harnessContent.trim(), '', HARNESS.markerEnd].join('\n'); if (!existsSync(projectFile)) { await writeFile(projectFile, block + '\n', 'utf8'); diff --git a/test/fix.test.js b/test/fix.test.js new file mode 100644 index 0000000..7a1c5c8 --- /dev/null +++ b/test/fix.test.js @@ -0,0 +1,135 @@ +// `imp fix` — the plan computed by collectFixPlan and the apply() of each +// local fixer (mcp -y, skill dupes, runtime restore, harness classification). +// Machine-level probes (Pi on PATH, ~/.pi settings) are injected off so the +// plan only reflects the fixture project. Network fixers (harness re-download) +// are asserted at the plan level, never applied. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { collectFixPlan } from '../src/steps/fix.js'; +import { writeHarnessManifest, sha1 } from '../src/lib/harness-manifest.js'; + +const NO_PI = { piReady: async () => false }; + +async function planFor(cwd) { + const plan = await collectFixPlan({ cwd, probes: NO_PI }); + await plan.cleanup(); + return plan; +} + +test('fix: a clean empty folder plans nothing', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fix-clean-')); + const { items, notes } = await planFor(dir); + assert.deepEqual(items, []); + assert.deepEqual(notes, []); +}); + +test('fix: npx MCP server without -y is planned, applied, and the plan converges to zero', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fix-mcp-')); + writeFileSync( + join(dir, '.mcp.json'), + JSON.stringify( + { + mcpServers: { + playwright: { command: 'npx', args: ['@playwright/mcp@latest'] }, + convex: { command: 'npx', args: ['-y', 'convex@latest', 'mcp', 'start'] }, + }, + }, + null, + 2, + ) + '\n', + ); + const { items } = await planFor(dir); + const item = items.find((i) => i.id === 'mcp-npx-yes'); + assert.ok(item, 'plans the -y fix'); + assert.match(item.label, /playwright/); + assert.doesNotMatch(item.label, /convex/); + + await item.apply(); + const after = JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf8')); + assert.deepEqual(after.mcpServers.playwright.args, ['-y', '@playwright/mcp@latest']); + assert.deepEqual(after.mcpServers.convex.args, ['-y', 'convex@latest', 'mcp', 'start']); + + const replan = await planFor(dir); + assert.equal(replan.items.find((i) => i.id === 'mcp-npx-yes'), undefined, 'idempotent'); +}); + +test('fix: broken .mcp.json is a note (hand-edited files are never repaired)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fix-badmcp-')); + writeFileSync(join(dir, '.mcp.json'), '{ not json'); + const { items, notes } = await planFor(dir); + assert.equal(items.length, 0); + assert.match(notes[0], /not valid JSON/); +}); + +test('fix: duplicated .pi/skills copies are planned and pruned', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fix-dupes-')); + writeFileSync(join(dir, 'skills-lock.json'), JSON.stringify({ skills: { alpha: {} } })); + mkdirSync(join(dir, '.agents', 'skills', 'alpha'), { recursive: true }); + mkdirSync(join(dir, '.pi', 'skills', 'alpha'), { recursive: true }); + writeFileSync(join(dir, '.pi', 'skills', 'alpha', 'SKILL.md'), 'copy'); + + const { items } = await planFor(dir); + const item = items.find((i) => i.id === 'pi-skill-dupes'); + assert.ok(item); + await item.apply(); + assert.equal(existsSync(join(dir, '.pi', 'skills', 'alpha')), false); + assert.equal(existsSync(join(dir, '.agents', 'skills', 'alpha')), true, 'canonical copy stays'); +}); + +test('fix: skills recorded in the lock but absent from .agents/skills are planned (restore via npx skills)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fix-skillsmiss-')); + writeFileSync(join(dir, 'skills-lock.json'), JSON.stringify({ skills: { alpha: {}, beta: {} } })); + mkdirSync(join(dir, '.agents', 'skills', 'alpha'), { recursive: true }); + const { items } = await planFor(dir); + const item = items.find((i) => i.id === 'skills-missing'); + assert.ok(item); + assert.match(item.label, /beta/); + assert.doesNotMatch(item.label, /alpha/); +}); + +test('fix: runtime files recorded in the stamp manifest and gone from disk are restored from the bundled template', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fix-runtime-')); + mkdirSync(join(dir, 'imp'), { recursive: true }); + writeFileSync( + join(dir, 'imp', '.runtime-manifest.json'), + JSON.stringify({ + files: { + 'imp/scripts/fia-tui.mjs': 'whatever-sha', + 'imp/no-longer-shipped.mjs': 'stale-sha', + }, + }), + ); + const { items } = await planFor(dir); + const item = items.find((i) => i.id === 'runtime-missing'); + assert.ok(item, 'plans the runtime restore'); + const outcome = await item.apply(); + assert.equal(existsSync(join(dir, 'imp', 'scripts', 'fia-tui.mjs')), true, 'restored from fia-templates'); + assert.match(outcome, /1 file\(s\) restored/); + assert.match(outcome, /no-longer-shipped/); + + // New-in-template files NOT in the manifest are never added here — that is + // --update-runtime's job (fix must not mix runtime versions). + const replan = await planFor(dir); + const again = replan.items.find((i) => i.id === 'runtime-missing'); + assert.ok(again, 'still pending: the manifest entry the template no longer ships'); + assert.match(again.label, /no-longer-shipped/); +}); + +test('fix: harness manifest → missing files planned for restore, modified ones only noted, AGENTS.md block planned', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fix-harness-')); + writeFileSync(join(dir, 'kept.md'), 'the project version\n'); + await writeHarnessManifest(dir, { + 'deleted.md': sha1('harness content'), + 'kept.md': sha1('harness version'), + }); + const { items, notes } = await planFor(dir); + const missing = items.find((i) => i.id === 'harness-missing'); + assert.ok(missing, 'plans the harness restore'); + assert.match(missing.label, /deleted\.md/); + assert.match(notes[0], /kept\.md/); + assert.match(notes[0], /left alone/); + assert.ok(items.find((i) => i.id === 'agents-md-block'), 'no marker in AGENTS.md → plans the block merge'); +}); diff --git a/test/harness-manifest.test.js b/test/harness-manifest.test.js new file mode 100644 index 0000000..391910d --- /dev/null +++ b/test/harness-manifest.test.js @@ -0,0 +1,83 @@ +// The harness stamp manifest (imp/.harness-manifest.json): collection from an +// adapted clone, roundtrip, and the missing/modified/pristine classification +// that doctor reports and fix restores from. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + collectHarnessManifest, + readHarnessManifest, + writeHarnessManifest, + classifyHarnessState, + sha1, +} from '../src/lib/harness-manifest.js'; + +function makeClone() { + const clone = mkdtempSync(join(tmpdir(), 'hm-clone-')); + mkdirSync(join(clone, '.claude', 'commands'), { recursive: true }); + mkdirSync(join(clone, '.agents', 'skills'), { recursive: true }); + writeFileSync(join(clone, '.claude', 'commands', 'start.md'), '# /start\n'); + writeFileSync(join(clone, '.mcp.json'), '{}\n'); + symlinkSync('../../.claude/commands/start.md', join(clone, '.agents', 'skills', 'start-link')); + return clone; +} + +test('collectHarnessManifest: files get sha1, symlinks get link:, keys are sorted', async () => { + const clone = makeClone(); + const files = await collectHarnessManifest(clone); + assert.equal(files['.claude/commands/start.md'], sha1('# /start\n')); + assert.equal(files['.mcp.json'], sha1('{}\n')); + assert.equal(files['.agents/skills/start-link'], 'link:../../.claude/commands/start.md'); + assert.deepEqual(Object.keys(files), [...Object.keys(files)].sort()); +}); + +test('write/read roundtrip lands at imp/.harness-manifest.json', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hm-proj-')); + await writeHarnessManifest(dir, { 'a.md': sha1('x') }); + const back = await readHarnessManifest(dir); + assert.equal(back.version, 1); + assert.deepEqual(back.files, { 'a.md': sha1('x') }); +}); + +test('readHarnessManifest: missing or invalid file → null (older install)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hm-none-')); + assert.equal(await readHarnessManifest(dir), null); + mkdirSync(join(dir, 'imp'), { recursive: true }); + writeFileSync(join(dir, 'imp', '.harness-manifest.json'), '{ not json'); + assert.equal(await readHarnessManifest(dir), null); +}); + +test('classifyHarnessState: pristine / modified / missing / link states', async () => { + const clone = makeClone(); + const files = await collectHarnessManifest(clone); + const manifest = { version: 1, files }; + + // A project that mirrors the clone exactly → everything pristine. + const dir = mkdtempSync(join(tmpdir(), 'hm-state-')); + mkdirSync(join(dir, '.claude', 'commands'), { recursive: true }); + mkdirSync(join(dir, '.agents', 'skills'), { recursive: true }); + writeFileSync(join(dir, '.claude', 'commands', 'start.md'), '# /start\n'); + writeFileSync(join(dir, '.mcp.json'), '{}\n'); + symlinkSync('../../.claude/commands/start.md', join(dir, '.agents', 'skills', 'start-link')); + let state = await classifyHarnessState(manifest, dir); + assert.deepEqual(state, { missing: [], modified: [], pristine: 3 }); + + // Edit one, delete one, retarget the link → each lands in its bucket. + writeFileSync(join(dir, '.mcp.json'), '{"edited":true}\n'); + rmSync(join(dir, '.claude', 'commands', 'start.md')); + rmSync(join(dir, '.agents', 'skills', 'start-link')); + symlinkSync('somewhere/else.md', join(dir, '.agents', 'skills', 'start-link')); + state = await classifyHarnessState(manifest, dir); + assert.deepEqual(state.missing, ['.claude/commands/start.md']); + assert.deepEqual(state.modified.sort(), ['.agents/skills/start-link', '.mcp.json']); + assert.equal(state.pristine, 0); +}); + +test('classifyHarnessState: a dangling symlink where a file was stamped counts as missing (restorable)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hm-dangling-')); + symlinkSync('gone/nowhere.md', join(dir, 'broken.md')); + const state = await classifyHarnessState({ files: { 'broken.md': sha1('content') } }, dir); + assert.deepEqual(state.missing, ['broken.md']); +}); diff --git a/test/imp-smoke.test.js b/test/imp-smoke.test.js index b092481..74133ad 100644 --- a/test/imp-smoke.test.js +++ b/test/imp-smoke.test.js @@ -34,6 +34,7 @@ test('imp: help exits 0 and prints the brand + usage', async () => { assert.match(res.stdout, /imp init/); assert.match(res.stdout, /imp update/); assert.match(res.stdout, /imp doctor/); + assert.match(res.stdout, /imp fix/); assert.match(res.stdout, /login openai-codex/); });