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.
This commit is contained in:
2026-08-09 16:45:30 -04:00
parent 5f8a5cbe5f
commit c605a709fb
15 changed files with 804 additions and 643 deletions

View File

@@ -16,10 +16,11 @@ import {
getCheck,
type CheckDefinition,
} from "./checks/registry.js";
import { runCheck, parseCheckArgs } from "./modes/check-runner.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 {
@@ -29,6 +30,7 @@ import {
markRunStatus,
reconcileRunStatus,
resetCheckEntry,
isCheckTerminal,
shouldRunOnResume,
} from "./run-state.js";
import { formatRunStatus } from "./status.js";
@@ -116,21 +118,58 @@ export async function handleHelpCommand(
* 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, rest } = splitFlags(args);
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 },
scope: { ...scope, cwd: ctx.cwd, target, fix },
existingState: existing,
ui: ctx.ui,
hasUI: ctx.hasUI,
sendChatMessage: ctx.sendChatMessage,
gitignore: !noGitignore,
});
const giNote = outcome.gitignoreAppended
@@ -145,7 +184,7 @@ export async function handleCheckCommand(
/**
* `/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}.
* `.pygienium/all-summary.md`. Delegates to {@link runAllChecks}.
*/
export async function handleAllCommand(
args: string,
@@ -246,32 +285,36 @@ export async function handleResumeCommand(
resetCheckEntry(state, entry.name);
}
print(ctx, `pygienium: resuming ${def.label}`);
const outcome = await runCheck({
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)})`,
`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>/` (and the legacy `.pygienium/checks/` root), apply
* filters, and write a single bundle to `pygienium/export.{md|json}`.
* `.pygienium/checks/<name>/`, apply filters, and write a single bundle to
* `.pygienium/export.{md|json}`.
*/
export async function handleExportCommand(
args: string,