/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.
392 lines
12 KiB
TypeScript
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,
|
|
});
|
|
}
|