Files
pygienium/src/commands.ts
Michael Freno c605a709fb feat(run): resume-aware per-check runs, verify hooks, run-state hardening
/pygienium-<check> is now resume-aware (terminal checks skipped unless
--fresh) and shares run-state with all/resume; every check gets a verify
hook that fails loudly when a sub-agent returns ok with no artifact;
run-state clears stale errors on retry success and reconciles a run as
failed only when every check failed. Drops the superseded
hygiene-state.ts model.
2026-08-09 16:45:30 -04:00

392 lines
12 KiB
TypeScript

/**
* commands.ts — pygienium slash-command handlers.
*
* Keeps `index.ts` thin: `index.ts` only binds these handlers to pi command
* names. Each handler accepts the narrow context slice it needs (`cwd`, `mode`,
* `hasUI`, `ui`) so they are unit-testable without a full pi runtime — tests
* construct a minimal `PygieniumCtx`.
*
* @module pygienium/commands
*/
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { resolve } from "node:path";
import {
getAllChecks,
getCheck,
type CheckDefinition,
} from "./checks/registry.js";
import {
runCheck,
parseCheckArgs,
type CheckRunOutcome,
} from "./modes/check-runner.js";
import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js";
import { buildPygieniumHelpLines } from "./help.js";
import {
loadRunState,
runStatePath,
saveRunState,
markRunStatus,
reconcileRunStatus,
resetCheckEntry,
isCheckTerminal,
shouldRunOnResume,
} from "./run-state.js";
import { formatRunStatus } from "./status.js";
import {
exportRun,
parseExportFilters,
exportBundlePath,
type ExportFilters,
} from "./export.js";
import type { SendChatMessage } from "./phases.js";
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
export type PygieniumCtx = Pick<
ExtensionCommandContext,
"cwd" | "mode" | "hasUI" | "ui"
> & {
/** Optional callback to post messages to the chat window. */
sendChatMessage?: SendChatMessage;
};
function print(ctx: PygieniumCtx, line: string): void {
// In TUI mode, also surface the first line as a notification.
if (ctx.mode === "tui" && ctx.ui?.notify) {
ctx.ui.notify(line, "info");
}
process.stdout.write(`${line}\n`);
}
/** Resolve an optional `[path]` argument to an absolute cwd. */
function resolveCwd(args: string, ctxCwd: string): string {
const tok = args.trim().split(/\s+/)[0];
if (!tok || tok.startsWith("--")) return ctxCwd;
return resolve(ctxCwd, tok);
}
/**
* Strip leading flag tokens (`--fix`, `--fresh`, `--no-gitignore`) from args,
* returning the remainder (the positional `[path]`).
*/
function splitFlags(args: string): {
fix: boolean;
fresh: boolean;
rest: string;
noGitignore: boolean;
} {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fix = tokens.includes("--fix");
const fresh = tokens.includes("--fresh");
const noGitignore = tokens.includes("--no-gitignore");
const rest = tokens
.filter((t) => t !== "--fix" && t !== "--fresh" && t !== "--no-gitignore")
.join(" ");
return { fix, fresh, rest, noGitignore };
}
/**
* Parse `/pygienium-resume` args: an optional `[path]` positional plus the
* `--fresh` flag.` returns the resolved cwd and whether a fresh re-dispatch
* is requested.
*/
function parseResumeArgs(
args: string,
ctxCwd: string,
): { cwd: string; fresh: boolean; gitignore: boolean } {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fresh = tokens.includes("--fresh");
const gitignore = !tokens.includes("--no-gitignore");
const positional = tokens.find((t) => !t.startsWith("--"));
const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd;
return { cwd, fresh, gitignore };
}
/** `/pygienium-help` */
export async function handleHelpCommand(
_args: string,
_ctx: PygieniumCtx,
): Promise<void> {
for (const line of buildPygieniumHelpLines()) {
process.stdout.write(`${line}\n`);
}
}
/**
* `/pygienium-<check> [path] [--fix]` — the per-check command handler.
* Exported so `index.ts` can bind one per registered `CheckDefinition` and so
* tests can invoke it directly with a stub context.
*/
/**
* `/pygienium-<check> [path] [--fix] [--fresh] [--no-gitignore]` — the
* per-check command handler. Exported so `index.ts` binds one per registered
* `CheckDefinition` and tests invoke it directly with a stub context.
*
* Resume-aware (parity with `/pygienium-all` and `/pygienium-resume`): a check
* already terminal (`complete`/`skipped`) is NOT re-dispatched unless `--fresh`
* resets its run-state entry. A failed/pending/in-progress check is re-run from
* analysis — recovering the exact failure mode the MagniFluo run exposed
* (sub-agent returns ok with no output → verify now fails loudly → resume
* re-runs the analysis and the artifact lands).
*/
export async function handleCheckCommand(
check: CheckDefinition,
args: string,
ctx: PygieniumCtx,
): Promise<void> {
const { fix, fresh, rest, noGitignore } = splitFlags(args);
const target = resolveCwd(rest, ctx.cwd);
const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd);
// Resume semantics: skip an already-terminal check unless --fresh forces a
// reset. This mirrors the all/resume skip predicate so running the same
// per-check command again after a success is a no-op (use --fresh to
// re-scan deliberately).
const existing = await loadRunState(ctx.cwd);
const entry = existing?.checks[check.name];
if (entry && isCheckTerminal(entry) && !fresh) {
print(
ctx,
`pygienium ${check.label}: already ${entry.status} (use --fresh to re-run)`,
);
return;
}
// Reset the entry when --fresh, or when the fix flag changed since the prior
// run: the phase skeleton (fix phase present only with --fix) must match
// the requested mode, otherwise re-running analysis wouldn't record a fix
// phase entry on a scan-only→--fix transition (and vice versa).
if (existing && entry && (fresh || entry.fix !== fix)) {
resetCheckEntry(existing, check.name, fix);
await saveRunState(existing);
}
const outcome = await runCheck({
check,
cwd: ctx.cwd,
scope: { ...scope, cwd: ctx.cwd, target, fix },
existingState: existing,
ui: ctx.ui,
hasUI: ctx.hasUI,
sendChatMessage: ctx.sendChatMessage,
gitignore: !noGitignore,
});
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print(
ctx,
`pygienium ${check.label}: ${outcome.status}${outcome.error ? `${outcome.error}` : ""}${giNote}`,
);
}
/**
* `/pygienium-all [path] [--fix] [--fresh] [--only=a,b]` — run every
* registered check in sequence under a unified status strip, writing
* `.pygienium/all-summary.md`. Delegates to {@link runAllChecks}.
*/
export async function handleAllCommand(
args: string,
ctx: PygieniumCtx,
): Promise<void> {
const checks = getAllChecks();
if (checks.length === 0) {
print(ctx, "pygienium: no checks registered.");
return;
}
const parsed = parseAllArgs(args, ctx.cwd);
const outcome = await runAllChecks({
cwd: ctx.cwd,
target: parsed.target,
fix: parsed.fix,
fresh: parsed.fresh,
gitignore: parsed.gitignore,
only: parsed.only,
ui: ctx.ui,
hasUI: ctx.hasUI,
sendChatMessage: ctx.sendChatMessage,
});
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print(
ctx,
`pygienium: all-run ${outcome.status}${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})${giNote}`,
);
}
/** `/pygienium-status [path]` — print run-state progress as a line list. */
export async function handleStatusCommand(
args: string,
ctx: PygieniumCtx,
): Promise<void> {
const cwd = resolveCwd(args, ctx.cwd);
const state = await loadRunState(cwd);
if (!state) {
print(ctx, "pygienium: no run state found.");
return;
}
for (const line of formatRunStatus(state)) {
print(ctx, line);
}
}
/**
* `/pygienium-resume [path] [--fresh]` — resume the most recent non-complete
* run by re-dispatching every check that isn't terminal (`complete`/`skipped`).
* Pass `--fresh` to re-dispatch even completed checks (their run-state entries
* are reset and the check re-runs analysis → fix → verify → cleanup fresh).
*/
export async function handleResumeCommand(
args: string,
ctx: PygieniumCtx,
): Promise<void> {
const { cwd, fresh, gitignore } = parseResumeArgs(args, ctx.cwd);
let state = await loadRunState(cwd);
if (!state) {
print(ctx, "pygienium: no run state to resume.");
return;
}
// Pick the latest resumable run — with the single-file run-state model this
// is the loaded run unless it's already fully complete AND --fresh wasn't set.
const resumable = Object.values(state.checks).some((c) =>
shouldRunOnResume(c, fresh),
);
if (!resumable) {
print(
ctx,
`pygienium: run already ${state.status}; nothing to resume (use --fresh to re-run).`,
);
return;
}
// Re-dispatch this run's checks in stored order, skipping terminal ones
// unless --fresh.
let ran = 0;
let skipped = 0;
let giAppended = false;
for (const entry of Object.values(state.checks)) {
const def = getCheck(entry.name);
if (!def) {
print(
ctx,
`pygienium: check "${entry.name}" is no longer registered; skipping.`,
);
skipped++;
continue;
}
if (!shouldRunOnResume(entry, fresh)) {
skipped++;
continue;
}
if (fresh) {
resetCheckEntry(state, entry.name);
}
print(ctx, `pygienium: resuming ${def.label}`);
const outcome: CheckRunOutcome = await runCheck({
check: def,
cwd,
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
ui: ctx.ui,
hasUI: ctx.hasUI,
existingState: state,
sendChatMessage: ctx.sendChatMessage,
gitignore,
});
state = outcome.state;
giAppended = giAppended || outcome.gitignoreAppended === true;
ran++;
print(ctx, `pygienium ${def.label}: ${outcome.status}`);
}
markRunStatus(state, reconcileRunStatus(state));
await saveRunState(state);
const giNote = giAppended ? " · .pygienium/ added to .gitignore" : "";
print(
ctx,
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})${giNote}`,
);
}
/**
* `/pygienium-export [path] [--check=<n>[,<n>]] [--status=<s>[,<s>]] [--out=md|json]`
* — collect every check's `findings.md`/`changes.md` artifacts from
* `.pygienium/checks/<name>/`, apply filters, and write a single bundle to
* `.pygienium/export.{md|json}`.
*/
export async function handleExportCommand(
args: string,
ctx: PygieniumCtx,
): Promise<void> {
const cwd = resolveCwd(args, ctx.cwd);
const filters: ExportFilters = parseExportFilters(args);
const state = await loadRunState(cwd);
const result = await exportRun(cwd, state, filters);
if (result.entries.length === 0) {
print(
ctx,
`pygienium: nothing to export (no findings.md/changes.md under ${cwd}/.pygienium/checks/).`,
);
return;
}
const filterDesc = [
filters.check ? `check=${filters.check.join(",")}` : null,
filters.status ? `status=${filters.status.join(",")}` : null,
]
.filter(Boolean)
.join(" ");
const suffix = filterDesc ? ` [${filterDesc}]` : "";
print(
ctx,
`pygienium export: ${result.entries.length} check(s) → ${exportBundlePath(cwd, result.format)}${suffix}`,
);
}
/** A minimal command-registration callback shape (matches `pi.registerCommand`). */
export type RegisterCommandFn = (
name: string,
options: {
description?: string;
handler: (args: string, ctx: PygieniumCtx) => Promise<void>;
},
) => void;
/**
* Auto-register `/pygienium-help` plus one `/pygienium-<check>` per registered
* `CheckDefinition`, plus the `all`/`resume`/`status`/`export` commands.
* Called from `index.ts` so that adding a check never requires editing command
* wiring.
*/
export function registerPygieniumCommands(register: RegisterCommandFn): void {
register("pygienium-help", {
description: "Show pygienium commands, checks, and usage.",
handler: handleHelpCommand,
});
for (const def of getAllChecks()) {
register(`pygienium-${def.name}`, {
description: def.description,
handler: (args, ctx) => handleCheckCommand(def, args, ctx),
});
}
register("pygienium-all", {
description: "Run every registered pygienium check in sequence.",
handler: handleAllCommand,
});
register("pygienium-resume", {
description: "Resume the most recent in-progress or failed pygienium run.",
handler: handleResumeCommand,
});
register("pygienium-status", {
description: "Show progress of the current or latest pygienium run.",
handler: handleStatusCommand,
});
register("pygienium-export", {
description: "Export finalized findings and changes for a pygienium run.",
handler: handleExportCommand,
});
}