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

@@ -270,6 +270,34 @@ async function complexityGate(cwd: string): Promise<string | undefined> {
}
}
/**
* Verify hook: confirms the check actually produced its artifacts. After the
* scan phase `findings.md` must exist; after the fix phase `changes.md` must
* exist too. Without this, a sub-agent that returns empty/ok without writing
* its report would be stamped `complete` — a false positive. Mirrors
* {@link commentsVerify} / {@link todosVerify}.
*/
async function complexityVerify(
scope: CheckScope,
): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope);
try {
await stat(f);
} catch {
return `complexity verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope);
try {
await stat(c);
} catch {
return `complexity verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/** The excessive complexity check definition. */
export const complexityCheck = {
name: "complexity",
@@ -282,6 +310,7 @@ export const complexityCheck = {
buildScanTask: buildComplexityScanTask,
buildFixTask: buildComplexityFixTask,
gate: complexityGate,
verify: complexityVerify,
} as const;
// Self-register on import so index.ts auto-discovery picks it up.

View File

@@ -1104,6 +1104,31 @@ export async function deadCodeGate(cwd: string): Promise<string | undefined> {
return undefined;
}
/**
* Verify hook: confirms the check actually produced its artifacts (mirrors
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md`
* must exist; after `--fix` `changes.md` must exist too. Catches a sub-agent
* that returns ok with no output — which would otherwise be a false
* `complete`.
*/
async function deadCodeVerify(scope: CheckScope): Promise<string | undefined> {
const f = findingsPath(scope.target);
try {
await stat(f);
} catch {
return `dead-code verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope.target);
try {
await stat(c);
} catch {
return `dead-code verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/** The registered `CheckDefinition` (module self-registers on import). */
export const deadCodeCheck: CheckDefinition = {
name: "dead-code",
@@ -1116,6 +1141,7 @@ export const deadCodeCheck: CheckDefinition = {
buildScanTask: buildDeadCodeScanTask,
buildFixTask: buildDeadCodeFixTask,
gate: deadCodeGate,
verify: deadCodeVerify,
};
// Self-register so `index.ts` auto-discovers this check with zero wiring edits.

View File

@@ -69,6 +69,33 @@ function deepModulesGate(cwd: string): string | undefined {
return undefined;
}
/**
* Verify hook: confirms the check actually produced its artifacts (mirrors
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
* returns ok with no output — which would otherwise be a false `complete`.
*/
async function deepModulesVerify(
scope: CheckScope,
): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope.cwd);
try {
await stat(f);
} catch {
return `deep-modules verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope.cwd);
try {
await stat(c);
} catch {
return `deep-modules verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/**
* Build the scan task. The deep-modules scanner agent inspects the target,
* classifies modules by abstraction depth against the rubric, and writes a
@@ -155,6 +182,7 @@ const deepModulesCheck: CheckDefinition = {
buildScanTask: buildDeepScanTask,
buildFixTask: buildDeepFixTask,
gate: deepModulesGate,
verify: deepModulesVerify,
};
registerCheck(deepModulesCheck);

View File

@@ -89,6 +89,33 @@ function defensiveGuardsGate(cwd: string): string | undefined {
return undefined;
}
/**
* Verify hook: confirms the check actually produced its artifacts (mirrors
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
* returns ok with no output — which would otherwise be a false `complete`.
*/
async function defensiveGuardsVerify(
scope: CheckScope,
): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope.cwd);
try {
await stat(f);
} catch {
return `defensive-guards verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope.cwd);
try {
await stat(c);
} catch {
return `defensive-guards verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/**
* Build the scan task. The defensive-guards scanner agent inspects the target,
* classifies each guard as redundant or a legitimate boundary guard against the
@@ -185,6 +212,7 @@ const defensiveGuardsCheck: CheckDefinition = {
buildScanTask: buildDefensiveGuardsScanTask,
buildFixTask: buildDefensiveGuardsFixTask,
gate: defensiveGuardsGate,
verify: defensiveGuardsVerify,
};
registerCheck(defensiveGuardsCheck);

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,

View File

@@ -53,7 +53,7 @@ export const CLI_FLAGS: HelpFlag[] = [
},
{
name: "--fresh",
scope: "all, resume",
scope: "<check>, all, resume",
description:
"Re-dispatch completed checks too — reset their run-state entries and re-run.",
},
@@ -98,15 +98,15 @@ export const COMMANDS: HelpCommand[] = [
example: "/pygienium-help",
},
{
usage: "pygienium-<check> [path] [--fix]",
usage: "pygienium-<check> [path] [--fix] [--fresh]",
description:
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report.",
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report. Resume-aware: a completed/skipped check is skipped unless --fresh re-runs it.",
example: "/pygienium-comments src --fix",
},
{
usage: "pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
description:
"Run every registered check in sequence under one resumable run-state with a unified status strip; writes pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.",
"Run every registered check in sequence under one resumable run-state with a unified status strip; writes .pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.",
example: "/pygienium-all --fix",
},
{

View File

@@ -1,472 +0,0 @@
/**
* On-disk model for `pygienium/run-state.json`.
*
* This is the single source of truth for `/pygienium-status`, `/pygienium-resume`
* and the per-run progress the orchestrator reports. The shape is ported from
* piolium's `audit-state.ts`, with `audit` → `run` and `phase` → `check` renames
* applied so this extension's vocabulary is run/check throughout.
*
* Snake-case keys are an intentional, persisted on-disk contract — downstream
* tasks (06, 12, 13) read them back when resuming or reporting a run. Don't
* camelCase them.
*
* Writes go through `withFileMutationQueue` (process-local serialization) +
* temp-file-rename (atomic on POSIX). The combination prevents both
* intra-process write-write races and partially-written files on crash.
*
* Schema is forward-compatible by addition only within this build — no legacy
* migration paths exist, so new fields must be optional and additive.
*/
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
/** A hygiene run's execution mode — free string ("all" for /all, or a check name). */
export type RunMode = string;
export type RunStatus = "pending" | "in_progress" | "complete" | "failed";
export type CheckStatus =
| "pending"
| "in_progress"
| "complete"
| "failed"
| "skipped";
/**
* Per-check progress snapshot, persisted inside `checks.<name>`. Mirrors how
* the annotating check runner records status, artifacts, attempts and the last
* error so a resume can pick up where an interrupted run left off.
*/
export interface CheckState {
status: CheckStatus;
started_at?: string;
completed_at?: string;
error?: string;
artifacts?: string[];
attempt?: number;
max_attempts?: number;
retry_backoff_ms?: number;
next_retry_at?: string;
last_error?: string;
heartbeat_at?: string;
last_event_at?: string;
last_tool?: string;
last_tool_summary?: string;
run_id?: string;
}
/** One hygiene run: metadata + per-check progress. */
export interface HygieneRunState {
run_id: string;
commit?: string | null;
branch?: string;
repository?: string;
history_available?: boolean;
mode: RunMode;
model?: string;
agent_sdk?: string;
started_at: string;
completed_at?: string | null;
status: RunStatus;
/** `checks.<checkName>` → that check's progress state. */
checks: Record<string, CheckState>;
}
export interface HygieneStateFile {
runs: HygieneRunState[];
}
export interface ReadRunStateResult {
path: string;
exists: boolean;
state?: HygieneStateFile;
parseError?: string;
}
export function getRunStatePath(cwd: string): string {
return join(cwd, "pygienium", "run-state.json");
}
/**
* Read the run-state file without ever throwing on a corrupt or non-matching
* file — it surfaces a `parseError` instead so callers (status, resume) can
* report gracefully.
*/
export function readRunState(cwd: string): ReadRunStateResult {
const path = getRunStatePath(cwd);
if (!existsSync(path)) {
return { path, exists: false };
}
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isHygieneStateFile(parsed)) {
return {
path,
exists: true,
parseError:
"File is valid JSON but does not match expected run-state shape (missing `runs` array).",
};
}
return { path, exists: true, state: parsed };
} catch (err) {
return {
path,
exists: true,
parseError: err instanceof Error ? err.message : String(err),
};
}
}
function isHygieneStateFile(value: unknown): value is HygieneStateFile {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return Array.isArray(v.runs);
}
/**
* Atomically replace the state file. Callers should always go through
* `mutateRunState` rather than calling this directly so concurrent mutations
* within the same process serialize correctly.
*/
function writeRunStateRaw(path: string, state: HygieneStateFile): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
const json = `${JSON.stringify(state, null, "\t")}\n`;
writeFileSync(tmp, json);
renameSync(tmp, path);
}
/**
* Read-modify-write the run-state file under the file mutation queue.
* The transformer receives the current state (or a fresh empty file if none
* exists) and returns the new state. Returning `undefined` aborts the write
* (no-op transformer).
*/
export async function mutateRunState(
cwd: string,
transform: (state: HygieneStateFile) => HygieneStateFile | undefined,
): Promise<HygieneStateFile> {
const path = getRunStatePath(cwd);
return withFileMutationQueue(path, async () => {
const current = readRunStateOrEmpty(path);
const next = transform(current);
if (!next) return current;
writeRunStateRaw(path, next);
return next;
});
}
function readRunStateOrEmpty(path: string): HygieneStateFile {
if (!existsSync(path)) return { runs: [] };
const raw = readFileSync(path, "utf8");
if (raw.trim() === "") return { runs: [] };
try {
const parsed = JSON.parse(raw) as unknown;
if (isHygieneStateFile(parsed)) return parsed;
} catch {
// fall through to the corrupt-file backup below.
}
// The file exists with non-empty content that won't parse or doesn't match
// the expected shape. Run state is expensive and resumable, so never let
// the caller overwrite it blind: move the corrupt file aside first, then
// return empty so a fresh file is written alongside the preserved backup.
backupCorruptStateFile(path);
return { runs: [] };
}
/**
* Move a corrupt state file to `run-state.json.corrupt-<timestamp>` so a
* subsequent write doesn't destroy whatever run history it held. Best-effort:
* if the rename fails we leave the file in place rather than risk losing it.
*/
function backupCorruptStateFile(path: string): void {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
let backup = `${path}.corrupt-${stamp}`;
for (let n = 1; existsSync(backup); n++)
backup = `${path}.corrupt-${stamp}-${n}`;
try {
renameSync(path, backup);
} catch {
// Leave the original untouched if it can't be moved.
}
}
/** Most recent run by `started_at` (ISO timestamps sort lexically). */
export function latestRun(
state: HygieneStateFile,
): HygieneRunState | undefined {
if (state.runs.length === 0) return undefined;
return [...state.runs].sort((a, b) =>
a.started_at < b.started_at ? 1 : -1,
)[0];
}
/**
* Most recent resumable run across all modes. Preference order: an
* `in_progress` run (process killed mid-phase) outranks a `failed` run
* (orderly terminal state) because the former is more likely a transient
* outage. `complete` runs are never returned.
*
* Ties are broken by `started_at` (most recent first).
*/
export function latestResumableRun(
state: HygieneStateFile,
): HygieneRunState | undefined {
const sorted = [...state.runs].sort((a, b) =>
a.started_at < b.started_at ? 1 : -1,
);
return (
sorted.find((r) => r.status === "in_progress") ??
sorted.find((r) => r.status === "failed") ??
undefined
);
}
export interface InitRunOptions {
mode: RunMode;
model?: string;
agent_sdk?: string;
commit?: string | null;
branch?: string;
repository?: string;
history_available?: boolean;
/**
* Override the initial check list. Absent, `checks` starts empty and the
* orchestrator adds entries as each check transitions. Given one, every
* check is seeded as `{ status: "pending" }`.
*/
checks?: readonly string[];
}
/**
* Append a new hygiene run to the state file. Returns the appended run with a
* fresh ISO timestamp `run_id`.
*/
export async function initRun(
cwd: string,
options: InitRunOptions,
): Promise<HygieneRunState> {
const startedAt = new Date().toISOString();
const checks: Record<string, CheckState> = {};
for (const name of options.checks ?? []) checks[name] = { status: "pending" };
const run: HygieneRunState = {
run_id: startedAt,
mode: options.mode,
started_at: startedAt,
completed_at: null,
status: "in_progress",
checks,
...(options.model !== undefined && { model: options.model }),
...(options.agent_sdk !== undefined && { agent_sdk: options.agent_sdk }),
...(options.commit !== undefined && { commit: options.commit }),
...(options.branch !== undefined && { branch: options.branch }),
...(options.repository !== undefined && { repository: options.repository }),
...(options.history_available !== undefined && {
history_available: options.history_available,
}),
};
await mutateRunState(cwd, (state) => ({
...state,
runs: [...state.runs, run],
}));
return run;
}
export interface CheckUpdate {
status: CheckStatus;
error?: string;
artifacts?: string[];
attempt?: number;
max_attempts?: number;
retry_backoff_ms?: number | null;
next_retry_at?: string | null;
last_error?: string | null;
heartbeat_at?: string | null;
last_event_at?: string | null;
last_tool?: string | null;
last_tool_summary?: string | null;
run_id?: string | null;
}
/**
* Update a single check on the named run. Auto-stamps `started_at` on
* transitions into `in_progress` and `completed_at` on terminal states.
* Returns the updated run, or `undefined` if the run_id wasn't found.
*/
export async function setCheckStatus(
cwd: string,
runId: string,
check: string,
update: CheckUpdate,
): Promise<HygieneRunState | undefined> {
let updated: HygieneRunState | undefined;
await mutateRunState(cwd, (state) => {
const idx = state.runs.findIndex((r) => r.run_id === runId);
if (idx < 0) return undefined;
const run = state.runs[idx];
if (!run) return undefined;
const prev = run.checks[check] ?? { status: "pending" as const };
const now = new Date().toISOString();
const next: CheckState = {
...prev,
status: update.status,
...(update.error !== undefined && { error: update.error }),
...(update.artifacts !== undefined && { artifacts: update.artifacts }),
...(update.attempt !== undefined && { attempt: update.attempt }),
...(update.max_attempts !== undefined && {
max_attempts: update.max_attempts,
}),
};
if (update.retry_backoff_ms !== undefined) {
if (update.retry_backoff_ms === null) next.retry_backoff_ms = undefined;
else next.retry_backoff_ms = update.retry_backoff_ms;
}
if (update.next_retry_at !== undefined) {
if (update.next_retry_at === null) next.next_retry_at = undefined;
else next.next_retry_at = update.next_retry_at;
}
if (update.last_error !== undefined) {
if (update.last_error === null) next.last_error = undefined;
else next.last_error = update.last_error;
}
if (update.heartbeat_at !== undefined) {
if (update.heartbeat_at === null) next.heartbeat_at = undefined;
else next.heartbeat_at = update.heartbeat_at;
}
if (update.last_event_at !== undefined) {
if (update.last_event_at === null) next.last_event_at = undefined;
else next.last_event_at = update.last_event_at;
}
if (update.last_tool !== undefined) {
if (update.last_tool === null) next.last_tool = undefined;
else next.last_tool = update.last_tool;
}
if (update.last_tool_summary !== undefined) {
if (update.last_tool_summary === null) next.last_tool_summary = undefined;
else next.last_tool_summary = update.last_tool_summary;
}
if (update.run_id !== undefined) {
if (update.run_id === null) next.run_id = undefined;
else next.run_id = update.run_id;
}
if (update.status === "in_progress" && !next.started_at)
next.started_at = now;
if (update.status === "in_progress") next.completed_at = undefined;
if (update.status === "complete") {
next.error = undefined;
next.artifacts = undefined;
next.retry_backoff_ms = undefined;
next.next_retry_at = undefined;
next.last_error = undefined;
next.heartbeat_at = undefined;
next.last_event_at = undefined;
next.last_tool = undefined;
next.last_tool_summary = undefined;
next.run_id = undefined;
}
if (
update.status === "complete" ||
update.status === "failed" ||
update.status === "skipped"
) {
if (!next.started_at) next.started_at = now;
next.completed_at = now;
}
const checks = { ...run.checks, [check]: next };
const newRun: HygieneRunState = { ...run, checks };
updated = newRun;
const runs = [...state.runs];
runs[idx] = newRun;
return { ...state, runs };
});
return updated;
}
/**
* Wrapper around `setCheckStatus` that also mirrors the disk write onto the
* caller's in-memory `HygieneRunState`. Use this from orchestrators that hold
* a run object across multiple check transitions — otherwise their copy goes
* stale the moment any check completes, and downstream prerequisite checks
* read "pending" for already-completed checks.
*/
export async function applyPhaseStatus(
cwd: string,
run: HygieneRunState,
checkName: string,
update: CheckUpdate,
): Promise<void> {
const updated = await setCheckStatus(cwd, run.run_id, checkName, update);
if (!updated) return;
const fresh = updated.checks[checkName];
if (fresh) run.checks[checkName] = fresh;
}
/** Mark a hygiene run as complete or failed. */
export async function markRunStatus(
cwd: string,
runId: string,
status: RunStatus,
): Promise<HygieneRunState | undefined> {
let updated: HygieneRunState | undefined;
await mutateRunState(cwd, (state) => {
const idx = state.runs.findIndex((r) => r.run_id === runId);
if (idx < 0) return undefined;
const run = state.runs[idx];
if (!run) return undefined;
const completedAt =
status === "complete" || status === "failed"
? new Date().toISOString()
: run.completed_at;
const newRun: HygieneRunState = {
...run,
status,
completed_at: completedAt ?? null,
};
updated = newRun;
const runs = [...state.runs];
runs[idx] = newRun;
return { ...state, runs };
});
return updated;
}
export interface CheckTally {
total: number;
complete: number;
in_progress: number;
pending: number;
failed: number;
skipped: number;
}
export function tallyChecks(run: HygieneRunState): CheckTally {
const tally: CheckTally = {
total: 0,
complete: 0,
in_progress: 0,
pending: 0,
failed: 0,
skipped: 0,
};
for (const check of Object.values(run.checks)) {
tally.total++;
tally[check.status]++;
}
return tally;
}
/**
* The 04-hygiene-state deliverables list this name alongside the `tally*`
* helpers; the rename step (phase → check) yields `tallyChecks`, so this is a
* one-line alias kept for spelling compatibility with that spec.
*/
export const tallyPhases: typeof tallyChecks = tallyChecks;

View File

@@ -183,15 +183,17 @@ export function renderAllSummary(
lines.push(`## ${check.name}${entry?.status ?? "pending"}${fixTag}`);
lines.push("");
if (entry?.error) {
// Errors are terminal-run facts: a check that completed via a later
// resume/retry must not surface a stale error under a "complete"
// status (markCheckStatus clears it on success; this guard covers
// hand-edited or legacy state files too).
if (entry?.error && entry?.status !== "complete") {
lines.push(`- error: ${entry.error}`);
}
// Artifact paths (canonical root). Checks may also write under the
// legacy `.pygienium/checks/` root; reference the canonical one and
// note findings/changes counts regardless of root.
const findingsPath = `${state.cwd}/pygienium/checks/${check.name}/findings.md`;
const changesPath = `${state.cwd}/pygienium/checks/${check.name}/changes.md`;
// Artifact paths (canonical root: `.pygienium/checks/<name>/`).
const findingsPath = `${state.cwd}/.pygienium/checks/${check.name}/findings.md`;
const changesPath = `${state.cwd}/.pygienium/checks/${check.name}/changes.md`;
const fLines = lineCount(entry?.findings);
const cLines = lineCount(entry?.changes);
if (fLines > 0) {

View File

@@ -80,14 +80,6 @@ export const PHASE_FIX = "fix";
export const PHASE_VERIFY = "verify";
export const PHASE_CLEANUP = "cleanup";
export const CHECK_PHASES = [
PHASE_RECON,
PHASE_ANALYSIS,
PHASE_FIX,
PHASE_VERIFY,
PHASE_CLEANUP,
] as const;
function freshPhase(id: string): PhaseEntry {
return { id, status: "pending" };
}
@@ -216,7 +208,9 @@ export function applyPhaseStatus(
status === "skipped"
) {
phase.finishedAt = now;
if (error) phase.error = error;
// Always set (or clear) the error: a phase that previously failed
// and then succeeds on retry must not carry a stale error forward.
phase.error = error;
}
}
@@ -233,7 +227,9 @@ export function markCheckStatus(
const check = state.checks[checkName];
if (!check) return;
check.status = status;
if (error) check.error = error;
// Always set (or clear) the error: a check that previously failed
// and then succeeds on retry must not carry a stale error forward.
check.error = error;
if (status === "complete" || status === "failed" || status === "skipped") {
check.finishedAt = Date.now();
}
@@ -306,8 +302,8 @@ export function resetCheckEntry(
*/
/**
* Recompute the run-level status from check statuses. A run is `complete` only
* when every check is terminal-complete; `failed` if any failed without fixes
* completed; `partial` when some checks were skipped/failed but others ok.
* when every check is terminal-complete; `failed` when every check failed;
* `partial` when some checks failed/skipped but others succeeded.
*/
export function reconcileRunStatus(state: RunState): RunStatus {
const checks = Object.values(state.checks);
@@ -320,7 +316,11 @@ export function reconcileRunStatus(state: RunState): RunStatus {
if (c.status === "failed") anyFailed = true;
if (c.status === "skipped") anySkipped = true;
}
if (anyFailed) return "partial";
if (anyFailed) {
// Every check failed (none succeeded or were skipped) → the run failed;
// a mix of failures and successes is only partially complete.
return checks.every((c) => c.status === "failed") ? "failed" : "partial";
}
if (anySkipped) return "partial";
return "complete";
}