Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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`). |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
20 changes: 20 additions & 0 deletions bin/imp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
106 changes: 106 additions & 0 deletions src/lib/harness-manifest.js
Original file line number Diff line number Diff line change
@@ -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:<target>` 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:<target>`), never followed — the merge copies
* them verbatim, and following one inside the clone could double-count its
* target's content.
* @returns {Promise<Record<string, string>>} rel path → sha1 | `link:<target>`
*/
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 };
}
28 changes: 28 additions & 0 deletions src/steps/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Loading
Loading